Add vitepress docs
This commit is contained in:
+5
-1
@@ -1,6 +1,10 @@
|
||||
node_modules
|
||||
docs
|
||||
dist
|
||||
build
|
||||
.vscode
|
||||
.svelte-kit
|
||||
docs/.vitepress/dist
|
||||
docs/.vitepress/cached
|
||||
docs/.vitepress/cache
|
||||
docs/reference
|
||||
docs/**/*.html
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false.
|
||||
@@ -0,0 +1,144 @@
|
||||
import {defineConfig} from "vitepress"
|
||||
import typeDocSidebar from "../reference/typedoc-sidebar.json"
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default defineConfig({
|
||||
title: "Welshman",
|
||||
description: "The official Welshman documentation",
|
||||
themeConfig: {
|
||||
// https://vitepress.dev/reference/default-theme-config
|
||||
nav: [
|
||||
{text: "Guide", link: "/what-is-welshman"},
|
||||
{text: "Reference", link: "/reference/"},
|
||||
],
|
||||
|
||||
sidebar: {
|
||||
"/reference/": [...typeDocSidebar],
|
||||
"/": [
|
||||
{
|
||||
text: "Introduction",
|
||||
items: [
|
||||
{text: "What is Welshman", link: "/what-is-welshman"},
|
||||
{text: "Getting started", link: "/getting-started"},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "@welshman/lib",
|
||||
link: "/lib/",
|
||||
items: [
|
||||
{text: "Utilities", link: "/lib/tools"},
|
||||
{text: "LRU cache", link: "/lib/lru"},
|
||||
{text: "Worker", link: "/lib/worker"},
|
||||
{text: "Deferred", link: "/lib/deferred"},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "@welshman/util",
|
||||
link: "/util/",
|
||||
items: [
|
||||
{text: "Address", link: "/util/address"},
|
||||
{text: "Kinds", link: "/util/kinds"},
|
||||
{text: "Encryptable", link: "/util/encryptable"},
|
||||
{text: "Events", link: "/util/events"},
|
||||
{text: "Filters", link: "/util/filters"},
|
||||
{text: "Handlers", link: "/util/handlers"},
|
||||
{text: "Links", link: "/util/links"},
|
||||
{text: "Profile", link: "/util/profile"},
|
||||
{text: "Relay", link: "/util/relay"},
|
||||
{text: "Repository", link: "/util/repository"},
|
||||
{text: "Tags", link: "/util/tags"},
|
||||
{text: "Zaps", link: "/util/zaps"},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "@welshman/content",
|
||||
link: "/content/",
|
||||
items: [
|
||||
{text: "Parser", link: "/content/parser"},
|
||||
{text: "Renderer", link: "/content/renderer"},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "@welshman/feeds",
|
||||
link: "/feeds/",
|
||||
items: [
|
||||
{text: "Core", link: "/feeds/core"},
|
||||
{text: "Utilities", link: "/feeds/utils"},
|
||||
{text: "Compiler", link: "/feeds/compiler"},
|
||||
{text: "Controller", link: "/feeds/controller"},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "@welshman/editor",
|
||||
link: "/editor/",
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
text: "@welshman/store",
|
||||
link: "/store/",
|
||||
items: [
|
||||
{text: "Basic utilities", link: "/store/basic"},
|
||||
{text: "Event stores", link: "/store/events"},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "@welshman/net",
|
||||
link: "/net/",
|
||||
items: [
|
||||
{text: "Context", link: "/net/context"},
|
||||
{text: "Executor", link: "/net/executor"},
|
||||
{text: "Subscribe", link: "/net/subscribe"},
|
||||
{text: "Publish", link: "/net/publish"},
|
||||
{text: "Sync", link: "/net/sync"},
|
||||
{text: "Pool", link: "/net/pool"},
|
||||
{text: "Targets", link: "/net/targets"},
|
||||
{text: "Tracker", link: "/net/tracker"},
|
||||
{text: "Connection", link: "/net/connection"},
|
||||
{text: "Socket", link: "/net/socket"},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "@welshman/dvm",
|
||||
link: "/dvm/",
|
||||
items: [
|
||||
{text: "Handler", link: "/dvm/handler"},
|
||||
{text: "Request", link: "/dvm/request"},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "@welshman/signer",
|
||||
link: "/signer/",
|
||||
items: [
|
||||
{text: "ISigner", link: "/signer/isigner"},
|
||||
{text: "NIP-01", link: "/signer/nip-01"},
|
||||
{text: "NIP-07", link: "/signer/nip-07"},
|
||||
{text: "NIP-46", link: "/signer/nip-46"},
|
||||
{text: "NIP-55", link: "/signer/nip-55"},
|
||||
{text: "NIP-59", link: "/signer/nip-59"},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "@welshman/app",
|
||||
link: "/app/",
|
||||
items: [
|
||||
{text: "Context", link: "/app/context"},
|
||||
{text: "Storage", link: "/app/storage"},
|
||||
{text: "Router", link: "/app/router"},
|
||||
{text: "Session", link: "/app/session"},
|
||||
{text: "Collection", link: "/app/collection"},
|
||||
{text: "Commands", link: "/app/commands"},
|
||||
{text: "Subscription", link: "/app/subscription"},
|
||||
{text: "Publish (Thunks)", link: "/app/thunks"},
|
||||
{text: "Feed", link: "/app/feed"},
|
||||
{text: "Tag utilities", link: "/app/tags"},
|
||||
{text: "Topics", link: "/app/topics"},
|
||||
{text: "Web of Trust", link: "/app/wot"},
|
||||
{text: "Stores and Loaders", link: "/app/storesandloaders"},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
socialLinks: [{icon: "github", link: "https://github.com/vuejs/vitepress"}],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
# Collection Stores
|
||||
|
||||
The `collection` utility creates stores that handle caching, loading, and indexing of Nostr data. It provides a consistent pattern for managing entities that need to be fetched from the network and cached locally.
|
||||
|
||||
```typescript
|
||||
const {
|
||||
indexStore, // Map of all items by key
|
||||
deriveItem, // Get reactive item by key
|
||||
loadItem // Trigger network load
|
||||
} = collection({
|
||||
name: "storeName", // For persistence
|
||||
store: writable([]), // Base store
|
||||
getKey: item => item.id // How to index items
|
||||
load: async (key) => { // Network loader
|
||||
// Load logic here
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Available Collections
|
||||
|
||||
```typescript
|
||||
// Profiles
|
||||
profiles → profilesByPubkey → deriveProfile → loadProfile
|
||||
|
||||
// Lists
|
||||
follows → followsByPubkey → deriveFollows → loadFollows
|
||||
mutes → mutesByPubkey → deriveMutes → loadMutes
|
||||
pins → pinsByPubkey → derivePins → loadPins
|
||||
|
||||
// Relays
|
||||
relays → relaysByUrl → deriveRelay → loadRelay
|
||||
relaySelections → relaySelectionsByPubkey → deriveRelaySelections → loadRelaySelections
|
||||
inboxRelaySelections → inboxRelaySelectionsByPubkey → deriveInboxRelaySelections → loadInboxRelaySelections
|
||||
|
||||
// Identity
|
||||
handles → handlesByNip05 → deriveHandle → loadHandle
|
||||
zappers → zappersByLnurl → deriveZapper → loadZapper
|
||||
```
|
||||
|
||||
## Real World Examples
|
||||
|
||||
### Loading and Displaying Profiles
|
||||
|
||||
```typescript
|
||||
import {
|
||||
deriveProfile,
|
||||
loadProfile,
|
||||
displayProfile
|
||||
} from '@welshman/app'
|
||||
|
||||
// In a Svelte component
|
||||
let profile
|
||||
|
||||
// Subscribe to profile changes
|
||||
$: profile = $deriveProfile(pubkey)
|
||||
|
||||
// Load automatically triggers when needed
|
||||
onMount(() => {
|
||||
loadProfile(pubkey, {
|
||||
// Optional request params
|
||||
relays: ["wss://relay.example.com"]
|
||||
})
|
||||
})
|
||||
|
||||
// Display with fallback
|
||||
$: name = displayProfile(profile, "unknown")
|
||||
```
|
||||
|
||||
### Managing Relay Selections
|
||||
|
||||
```typescript
|
||||
import {
|
||||
deriveRelaySelections,
|
||||
loadRelaySelections,
|
||||
getReadRelayUrls,
|
||||
getWriteRelayUrls
|
||||
} from '@welshman/app'
|
||||
|
||||
// Get user's relay preferences
|
||||
const selections = deriveRelaySelections(pubkey).get()
|
||||
|
||||
// Load from network if needed
|
||||
await loadRelaySelections(pubkey)
|
||||
|
||||
// Get read/write URLs
|
||||
const readRelays = getReadRelayUrls(selections)
|
||||
const writeRelays = getWriteRelayUrls(selections)
|
||||
|
||||
// Use with router
|
||||
const relays = ctx.app.router
|
||||
.FromPubkey(pubkey)
|
||||
.getUrls()
|
||||
```
|
||||
|
||||
Each collection automatically:
|
||||
- Caches to IndexedDB
|
||||
- Deduplicates network requests
|
||||
- Updates reactively
|
||||
- Provides typed access
|
||||
- Handles loading states
|
||||
|
||||
The pattern is consistent across all stores, making it predictable to work with different types of nostr data.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Commands
|
||||
|
||||
High-level commands for common Nostr operations.
|
||||
Each command handles signing, encryption, and relay selection automatically.
|
||||
|
||||
## Available Commands
|
||||
|
||||
```typescript
|
||||
// List Management
|
||||
follow(pubkey)
|
||||
unfollow(pubkey)
|
||||
mute(pubkey)
|
||||
unmute(pubkey)
|
||||
pin(tag)
|
||||
unpin(tag)
|
||||
```
|
||||
|
||||
Each command returns a [`Thunk`](app/thunk) which:
|
||||
- Optimistically updates local state
|
||||
- Signs and publishes the event
|
||||
- Can be aborted within a delay window
|
||||
- Reports publish progress
|
||||
|
||||
## Real World Examples
|
||||
|
||||
### Following/Unfollowing Users
|
||||
|
||||
```typescript
|
||||
import {follow, unfollow, userFollows} from '@welshman/app'
|
||||
|
||||
// Follow with optimistic update
|
||||
const followUser = async (pubkey: string) => {
|
||||
|
||||
// Creates and publishes event with an updated follow list
|
||||
const thunk = await follow(pubkey)
|
||||
|
||||
// Track publish status per relay
|
||||
thunk.status.subscribe(statuses => {
|
||||
for (const [url, status] of Object.entries(statuses)) {
|
||||
console.log(`${url}: ${status}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Can abort within delay window
|
||||
setTimeout(() => thunk.controller.abort(), 1000)
|
||||
}
|
||||
|
||||
// Unfollow works the same way
|
||||
const unfollowUser = async (pubkey: string) => {
|
||||
const thunk = await unfollow(pubkey)
|
||||
|
||||
// Wait for completion
|
||||
const results = await thunk.result
|
||||
}
|
||||
```
|
||||
|
||||
### Managing Pins
|
||||
|
||||
```typescript
|
||||
import {pin, unpin, userPins} from '@welshman/app'
|
||||
|
||||
// Pin an event with context
|
||||
const pinEvent = async (event: TrustedEvent) => {
|
||||
const thunk = await pin([
|
||||
'e', event.id,
|
||||
ctx.app.router.Event(event).getUrl()
|
||||
])
|
||||
|
||||
// Handle specific relay errors
|
||||
thunk.status.subscribe(statuses => {
|
||||
for (const [url, {status, message}] of Object.entries(statuses)) {
|
||||
if (status === 'failure') {
|
||||
console.error(`Failed on ${url}: ${message}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
All commands:
|
||||
- Handle encryption automatically
|
||||
- Select appropriate relays
|
||||
- Update local state immediately
|
||||
- Allow soft-undo via abort
|
||||
- Report per-relay status
|
||||
- Return consistent Thunk interface
|
||||
|
||||
Commands provide a high-level way to modify the Nostr state without dealing with the complexities of event creation, encryption, and relay selection.
|
||||
@@ -0,0 +1,139 @@
|
||||
# Application Context
|
||||
|
||||
The `@welshman/app` package uses a global context system to configure core behaviors.
|
||||
Understanding the app context is essential as it powers [session/authentication](/app/session), [relay routing](/app/relay) and [request handling](/app/request).
|
||||
|
||||
## Basic Setup
|
||||
|
||||
```typescript
|
||||
import {ctx, setContext} from '@welshman/lib'
|
||||
import {getDefaultNetContext, getDefaultAppContext} from '@welshman/app'
|
||||
|
||||
// Initialize app with default settings
|
||||
setContext({
|
||||
net: getDefaultNetContext(),
|
||||
app: getDefaultAppContext()
|
||||
})
|
||||
|
||||
// Access context anywhere
|
||||
console.log(ctx.app.router)
|
||||
console.log(ctx.net.pool)
|
||||
```
|
||||
|
||||
## Default App Context
|
||||
|
||||
```typescript
|
||||
export type AppContext = {
|
||||
// Smart relay routing system
|
||||
router: Router
|
||||
|
||||
// Time to wait between batched requests (ms)
|
||||
requestDelay: number // default: 50
|
||||
|
||||
// Time to wait for NIP-42 relay auth (ms)
|
||||
authTimeout: number // default: 300
|
||||
|
||||
// Time to wait for request completion (ms)
|
||||
requestTimeout: number // default: 3000
|
||||
|
||||
// URL of metadata service (optional)
|
||||
dufflepudUrl?: string
|
||||
|
||||
// Additional relays for indexed content
|
||||
indexerRelays?: string[]
|
||||
}
|
||||
|
||||
// Example with custom settings
|
||||
setContext({
|
||||
app: getDefaultAppContext({
|
||||
requestDelay: 100,
|
||||
authTimeout: 500,
|
||||
requestTimeout: 5000,
|
||||
dufflepudUrl: "https://api.example.com",
|
||||
indexerRelays: [
|
||||
"wss://relay.example.com",
|
||||
"wss://indexed.example.com"
|
||||
]
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Network Context
|
||||
|
||||
```typescript
|
||||
export type NetContext = {
|
||||
// Global connection pool
|
||||
pool: Pool
|
||||
|
||||
// How to handle NIP-42 auth
|
||||
authMode: AuthMode // default: 'implicit'
|
||||
|
||||
// Event validation and handling
|
||||
onEvent: (url: string, event: TrustedEvent) => void
|
||||
isDeleted: (url: string, event: TrustedEvent) => boolean
|
||||
isValid: (url: string, event: TrustedEvent) => boolean
|
||||
|
||||
// Event signing (used by all packages)
|
||||
signEvent: (event: StampedEvent) => Promise<SignedEvent>
|
||||
|
||||
// Subscription optimization
|
||||
optimizeSubscriptions: (subs: Subscription[]) => RelaysAndFilters[]
|
||||
}
|
||||
|
||||
// Example with custom validation
|
||||
setContext({
|
||||
net: getDefaultNetContext({
|
||||
// Custom event validation
|
||||
isValid: (url, event) => {
|
||||
if (url === LOCAL_RELAY_URL) return true
|
||||
return hasValidSignature(event)
|
||||
},
|
||||
|
||||
// Track deleted events
|
||||
isDeleted: (url, event) =>
|
||||
repository.isDeleted(event),
|
||||
|
||||
// Custom event handling
|
||||
onEvent: (url, event) => {
|
||||
// Save to local repository
|
||||
repository.publish(event)
|
||||
|
||||
// Track which relay it came from
|
||||
tracker.track(event.id, url)
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
## Using Context Values
|
||||
|
||||
Once configured, context values are used throughout the app:
|
||||
|
||||
```typescript
|
||||
import {ctx} from '@welshman/lib'
|
||||
|
||||
// Smart relay routing
|
||||
const relays = ctx.app.router
|
||||
.ForPubkey(pubkey)
|
||||
.getUrls()
|
||||
|
||||
// Publish with timeout
|
||||
const pub = publish({
|
||||
event,
|
||||
relays,
|
||||
timeout: ctx.app.requestTimeout
|
||||
})
|
||||
|
||||
// Subscribe with auth
|
||||
const sub = subscribe({
|
||||
filters,
|
||||
relays,
|
||||
authTimeout: ctx.app.authTimeout
|
||||
})
|
||||
|
||||
// Check connection pool
|
||||
const connected = ctx.net.pool
|
||||
.get(relay)
|
||||
.socket.status === 'open'
|
||||
```
|
||||
@@ -0,0 +1,96 @@
|
||||
# Feed
|
||||
|
||||
The feed system provides a powerful way to compose and load complex `Nostr` queries. It supports user scopes, web of trust filtering, DVM integration, and thread construction.
|
||||
|
||||
## Controller
|
||||
|
||||
The `controller.load()` function is the main interface for fetching events from a feed. It handles all the complexity of relay selection, subscription management, and event filtering.
|
||||
|
||||
```typescript
|
||||
import {createFeedController} from '@welshman/app'
|
||||
import {scopeFeed, wotFeed} from '@welshman/feeds'
|
||||
|
||||
const controller = createFeedController({
|
||||
// Define what to load
|
||||
feed: scopeFeed("follows"),
|
||||
|
||||
// Optional configurations
|
||||
closeOnEose: true, // Close after getting all events
|
||||
onEvent: event => {}, // Handle events as they arrive
|
||||
onEose: url => {}, // Handle EOSE from each relay
|
||||
onComplete: () => {}, // Called when all relays complete
|
||||
})
|
||||
|
||||
// Load first 20 events
|
||||
const events = await controller.load(20)
|
||||
|
||||
// Load next 20 events
|
||||
const moreEvents = await controller.load(20)
|
||||
```
|
||||
|
||||
The controller maintains its state between loads, so subsequent calls will:
|
||||
- Continue from last position
|
||||
- Use appropriate time windows
|
||||
- Skip already seen events
|
||||
- Maintain relay connections
|
||||
|
||||
## Paginated Feed
|
||||
|
||||
```typescript
|
||||
import {intersectionFeed, scopeFeed, wotFeed} from '@welshman/feeds'
|
||||
|
||||
const HomeFeed = {
|
||||
let events = []
|
||||
let loading = false
|
||||
let controller
|
||||
|
||||
onMount(() => {
|
||||
// Create feed for home timeline
|
||||
controller = createFeedController({
|
||||
feed: intersectionFeed(
|
||||
// Content from follows
|
||||
scopeFeed("follows"),
|
||||
// Filtered by web of trust
|
||||
wotFeed({min: 0.1})
|
||||
),
|
||||
|
||||
// Handle events as they arrive
|
||||
onEvent: event => {
|
||||
events = [...events, event]
|
||||
},
|
||||
|
||||
// Track loading state
|
||||
onComplete: () => {
|
||||
loading = false
|
||||
}
|
||||
})
|
||||
|
||||
// Initial load
|
||||
loadMore()
|
||||
})
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loading) return
|
||||
loading = true
|
||||
|
||||
// Load next batch
|
||||
await controller.load(20)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key points about `controller.load()`:
|
||||
- Takes a limit parameter for batch size
|
||||
- Returns a promise of loaded events
|
||||
- Can be called repeatedly for pagination
|
||||
- Handles subscription lifecycle
|
||||
- Manages relay connections
|
||||
- Deduplicates events
|
||||
|
||||
The controller is stateful and maintains:
|
||||
- Current time window
|
||||
- Seen events
|
||||
- Active subscriptions
|
||||
- Relay connections
|
||||
|
||||
This makes it ideal for implementing infinite scroll feeds, thread loading, and other paginated content scenarios.
|
||||
@@ -0,0 +1,20 @@
|
||||
# @welshman/app
|
||||
|
||||
A comprehensive framework for building nostr clients, powering production applications like [Coracle](https://coracle.social) and [Flotilla](https://flotilla.social). It provides a complete toolkit for managing events, subscriptions, user data, and relay connections.
|
||||
|
||||
## What's Included
|
||||
|
||||
- **Repository System** - Event storage and query capabilities
|
||||
- **Router** - Intelligent relay selection for optimal networking
|
||||
- **Feed Controller** - Manages feed creation and updates
|
||||
- **Authentication** - User identity and key management
|
||||
- **Event Actions** - High-level operations like reacting, replying, etc.
|
||||
- **Profile Management** - User profile handling and metadata
|
||||
- **Relay Directories** - Discovery and management of relays
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @welshman/app
|
||||
```
|
||||
@@ -0,0 +1,103 @@
|
||||
# Router
|
||||
|
||||
The Router is the critical component to efficiently enable the `outbox model` in your Nostr application. It handles relay selection for reading, writing, and discovering events while considering relay quality, user preferences, and network conditions.
|
||||
|
||||
## Overview
|
||||
|
||||
The router provides scenarios for common **Nostr** operations:
|
||||
- Reading user profiles
|
||||
- Publishing events
|
||||
- Following threads
|
||||
- Handling DMs
|
||||
- Searching content
|
||||
|
||||
Each scenario considers:
|
||||
- User's relay preferences (NIP-65)
|
||||
- Event hints in tags
|
||||
- Relay quality scores
|
||||
- Fallback policies
|
||||
- Connection status
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import {ctx, setContext} from '@welshman/lib'
|
||||
import {getDefaultAppContext} from '@welshman/app'
|
||||
|
||||
// Initialize router
|
||||
setContext({
|
||||
app: getDefaultAppContext()
|
||||
})
|
||||
|
||||
// Use router scenarios
|
||||
const router = ctx.app.router
|
||||
|
||||
// Get relays for reading a profile
|
||||
const readRelays = router.ForPubkey(pubkey).getUrls()
|
||||
|
||||
// Get relays for publishing
|
||||
const writeRelays = router.FromUser().getUrls()
|
||||
|
||||
// Get relays for a thread
|
||||
const threadRelays = router.Replies(event).getUrls()
|
||||
```
|
||||
|
||||
## Thread Navigation
|
||||
|
||||
```typescript
|
||||
import {ctx} from '@welshman/lib'
|
||||
import {createEvent, NOTE} from '@welshman/util'
|
||||
import {publishThunk} from '@welshman/app'
|
||||
|
||||
const loadThread = async (event: TrustedEvent) => {
|
||||
// Get relays for root event
|
||||
const rootRelays = ctx.app.router
|
||||
.EventRoots(event)
|
||||
.getUrls()
|
||||
|
||||
// Get relays for replies
|
||||
const replyRelays = ctx.app.router
|
||||
.EventParents(event)
|
||||
.getUrls()
|
||||
|
||||
// Get relays for mentions
|
||||
const mentionRelays = ctx.app.router
|
||||
.EventMentions(event)
|
||||
.getUrls()
|
||||
|
||||
// Load from all relevant relays
|
||||
await Promise.all([
|
||||
subscribe({filters, relays: rootRelays}),
|
||||
subscribe({filters, relays: replyRelays}),
|
||||
subscribe({filters, relays: mentionRelays})
|
||||
])
|
||||
}
|
||||
|
||||
// Posting a reply
|
||||
const reply = async (parent: TrustedEvent, content: string) => {
|
||||
const event = createEvent(NOTE, {content})
|
||||
|
||||
// Get optimal relays for publishing
|
||||
const relays = ctx.app.router
|
||||
.PublishEvent(event)
|
||||
// Skip .onion relays
|
||||
.allowOnion(false)
|
||||
// Allow up to 5 relays
|
||||
.limit(5)
|
||||
.getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
```
|
||||
|
||||
## Router Features
|
||||
|
||||
- Smart relay selection based on context
|
||||
- Quality scoring of relays
|
||||
- Fallback strategies
|
||||
- Handling of special relay types (.onion, local)
|
||||
- Automatic weight calculation
|
||||
- Connection state awareness
|
||||
- NIP-65 compliance
|
||||
|
||||
The router is central to efficient nostr operations, ensuring events reach their intended audience while minimizing unnecessary network traffic.
|
||||
@@ -0,0 +1,186 @@
|
||||
# Session Management
|
||||
|
||||
The session system provides a unified way to handle different authentication methods:
|
||||
- Secret Key NIP-01
|
||||
- Nostr Extensions NIP-07
|
||||
- Bunker URL NIP-46
|
||||
- Amber or in-device NIP-55
|
||||
|
||||
while managing user state and encryption capabilities.
|
||||
|
||||
## Overview
|
||||
|
||||
Sessions are stored in local storage and can be:
|
||||
- Persisted across page reloads
|
||||
- Used with multiple accounts
|
||||
- Switched dynamically
|
||||
- Backed by different signing methods
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import {ctx, setContext} from '@welshman/lib'
|
||||
import {
|
||||
getDefaultNetContext,
|
||||
getDefaultAppContext,
|
||||
pubkey,
|
||||
sessions,
|
||||
session,
|
||||
addSession,
|
||||
getNip07
|
||||
} from '@welshman/app'
|
||||
|
||||
// Set up app config
|
||||
setContext({
|
||||
net: getDefaultNetContext(),
|
||||
app: getDefaultAppContext(),
|
||||
})
|
||||
|
||||
// Log in via NIP-07 extension (browser wallet)
|
||||
if (await getNip07()) {
|
||||
addSession({
|
||||
method: 'nip07',
|
||||
pubkey: await getNip07().getPublicKey()
|
||||
})
|
||||
}
|
||||
|
||||
// Get current session
|
||||
console.log(session.get()) // Current active session
|
||||
console.log(pubkey.get()) // Current pubkey
|
||||
```
|
||||
|
||||
## Multiple Sessions
|
||||
|
||||
```typescript
|
||||
import {sessions, pubkey, addSession, dropSession} from '@welshman/app'
|
||||
|
||||
// Add multiple sessions
|
||||
addSession({method: 'nip07', pubkey: 'abc...'})
|
||||
addSession({method: 'nip46', pubkey: 'def...', secret: '123'})
|
||||
|
||||
// Switch between sessions
|
||||
pubkey.set('abc...') // Activates that session
|
||||
|
||||
// Remove a session
|
||||
dropSession('abc...')
|
||||
|
||||
// List all sessions
|
||||
console.log(sessions.get())
|
||||
```
|
||||
|
||||
## NIP-46 (Bunker) Authentication
|
||||
|
||||
```typescript
|
||||
import {Nip46Broker, Nip46Signer} from '@welshman/signer'
|
||||
import {addSession} from '@welshman/app'
|
||||
|
||||
// Connect to a bunker
|
||||
const clientSecret = makeSecret()
|
||||
const relays = ['wss://relay.damus.io']
|
||||
const broker = Nip46Broker.get({relays, clientSecret})
|
||||
|
||||
// Generate nostrconnect URL for the bunker
|
||||
const connectUrl = await broker.makeNostrconnectUrl({
|
||||
name: "My App",
|
||||
url: "https://myapp.com"
|
||||
})
|
||||
|
||||
// Wait for user to approve in bunker
|
||||
const response = await broker.waitForNostrconnect(connectUrl)
|
||||
|
||||
// Create session
|
||||
addSession({
|
||||
method: 'nip46',
|
||||
pubkey: response.event.pubkey,
|
||||
secret: clientSecret,
|
||||
handler: {
|
||||
pubkey: response.event.pubkey,
|
||||
relays
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Using Session Signer
|
||||
|
||||
```typescript
|
||||
import {signer, session} from '@welshman/app'
|
||||
import {createEvent, NOTE} from '@welshman/util'
|
||||
|
||||
// Current session's signer is always ready to use
|
||||
const event = await signer.get().sign(
|
||||
createEvent(NOTE, {content: "Hello Nostr!"})
|
||||
)
|
||||
|
||||
// Encrypt content for private notes
|
||||
const encrypted = await signer.get().nip44.encrypt(
|
||||
pubkey,
|
||||
"Secret message"
|
||||
)
|
||||
```
|
||||
|
||||
## Session Persistence
|
||||
|
||||
Sessions are automatically persisted to local storage. On page load:
|
||||
|
||||
```typescript
|
||||
import {pubkey, sessions} from '@welshman/app'
|
||||
|
||||
// Sessions load automatically from local storage
|
||||
console.log(sessions.get()) // All stored sessions
|
||||
|
||||
// the current active session
|
||||
console.log(session.get())
|
||||
|
||||
// Last active pubkey is restored
|
||||
console.log(pubkey.get())
|
||||
```
|
||||
|
||||
## Session Types
|
||||
|
||||
```typescript
|
||||
type SessionNip07 = {
|
||||
method: "nip07"
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
type SessionNip46 = {
|
||||
method: "nip46"
|
||||
pubkey: string
|
||||
secret: string
|
||||
handler: {
|
||||
pubkey: string
|
||||
relays: string[]
|
||||
}
|
||||
}
|
||||
|
||||
type SessionNip01 = {
|
||||
method: "nip01"
|
||||
pubkey: string
|
||||
secret: string
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
import {tryCatch} from '@welshman/lib'
|
||||
import {addSession, getNip07} from '@welshman/app'
|
||||
|
||||
const login = async () => {
|
||||
const nip07 = await tryCatch(getNip07)
|
||||
|
||||
if (!nip07) {
|
||||
throw new Error("No NIP-07 extension found")
|
||||
}
|
||||
|
||||
const pubkey = await tryCatch(
|
||||
() => nip07.getPublicKey()
|
||||
)
|
||||
|
||||
if (!pubkey) {
|
||||
throw new Error("Failed to get public key")
|
||||
}
|
||||
|
||||
addSession({method: 'nip07', pubkey})
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,68 @@
|
||||
# Storage
|
||||
|
||||
The storage system provides IndexedDB persistence for stores and repositories.
|
||||
It's critical to initialize this early in your application lifecycle to ensure data consistency.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
initStorage,
|
||||
storageAdapters,
|
||||
throttled,
|
||||
repository,
|
||||
tracker,
|
||||
relays,
|
||||
handles,
|
||||
freshness,
|
||||
plaintext
|
||||
} from '@welshman/app'
|
||||
|
||||
// Real world example from Coracle
|
||||
const initializeStorage = async () => {
|
||||
const ready = initStorage("coracle-db", 1, {
|
||||
// Persist relay info
|
||||
relays: {
|
||||
keyPath: "url",
|
||||
store: throttled(3000, relays)
|
||||
},
|
||||
|
||||
// Persist NIP-05 handles
|
||||
handles: {
|
||||
keyPath: "nip05",
|
||||
store: throttled(3000, handles)
|
||||
},
|
||||
|
||||
// Track data freshness
|
||||
freshness: storageAdapters.fromObjectStore(
|
||||
freshness,
|
||||
{throttle: 3000}
|
||||
),
|
||||
|
||||
// Store decrypted content
|
||||
plaintext: storageAdapters.fromObjectStore(
|
||||
plaintext,
|
||||
{throttle: 3000}
|
||||
),
|
||||
|
||||
// Store events and their sources
|
||||
events: storageAdapters.fromRepositoryAndTracker(
|
||||
repository,
|
||||
tracker,
|
||||
{throttle: 3000}
|
||||
)
|
||||
})
|
||||
|
||||
// Wait for storage to be ready
|
||||
await ready
|
||||
|
||||
// App can now start loading data
|
||||
}
|
||||
```
|
||||
|
||||
The storage system:
|
||||
- Persists data across page reloads
|
||||
- Throttles writes for performance
|
||||
- Handles store migrations
|
||||
- Syncs bidirectionally
|
||||
- Supports custom adapters
|
||||
|
||||
Initialize storage before making any subscriptions or loading data to ensure proper caching behavior.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Stores and Loaders
|
||||
|
||||
The `@welshman/app` package provides a powerful system of collection-based reactive stores and loader utilities.
|
||||
|
||||
These utilities follow a consistent pattern for working with various types of Nostr data, making it easy to:
|
||||
|
||||
1. Query data from the repository
|
||||
2. Transform it into application-specific structures
|
||||
3. Access it reactively in your UI
|
||||
4. Trigger network loading when needed
|
||||
|
||||
## Core Concept
|
||||
|
||||
Each collection-based module exports a similar set of utilities:
|
||||
|
||||
```typescript
|
||||
// Common pattern across collection-based modules
|
||||
export const {
|
||||
// Main collection store (derived from repository)
|
||||
store: follows,
|
||||
|
||||
// Indexed map for efficient lookup
|
||||
indexStore: followsByPubkey,
|
||||
|
||||
// Function to get a reactive store for a specific item
|
||||
deriveItem: deriveFollows,
|
||||
|
||||
// Function to trigger loading an item from the network
|
||||
loadItem: loadFollows
|
||||
} = collection({
|
||||
name: "collection-name",
|
||||
store: baseStore,
|
||||
getKey: item => item.keyProperty,
|
||||
load: async (key) => { /* Loading logic */ }
|
||||
})
|
||||
```
|
||||
|
||||
## Available Collections
|
||||
|
||||
| Collection | Key | Kind | Description |
|
||||
|------------|-----|------|-------------|
|
||||
| `follows` | pubkey | 3 | User follow lists |
|
||||
| `mutes` | pubkey | 10000 | User mute lists |
|
||||
| `pins` | pubkey | 10001 | User pinned items |
|
||||
| `profiles` | pubkey | 0 | User profile metadata |
|
||||
| `relaySelections` | pubkey | 10002 | User relay preferences |
|
||||
| `inboxRelaySelections` | pubkey | 10005 | User inbox relay settings |
|
||||
| `zappers` | lnurl | - | Lightning zapper metadata |
|
||||
| `handles` | nip05 | - | NIP-05 identifier metadata |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Loading and Accessing Data
|
||||
|
||||
```typescript
|
||||
import { loadProfile, deriveProfile, profilesByPubkey } from '@welshman/app'
|
||||
|
||||
// Trigger loading a profile from the network
|
||||
await loadProfile('pubkey123')
|
||||
|
||||
// Get a reactive store for a specific profile
|
||||
const profile = deriveProfile('pubkey123')
|
||||
|
||||
// Access all profiles by pubkey
|
||||
const allProfiles = profilesByPubkey.get()
|
||||
const specificProfile = allProfiles.get('pubkey123')
|
||||
```
|
||||
|
||||
### User-Specific Collections
|
||||
|
||||
Several modules provide user-specific derived stores that automatically load data for the currently signed-in user:
|
||||
|
||||
```typescript
|
||||
import { userProfile, userFollows, userMutes, userPins } from '@welshman/app'
|
||||
|
||||
// These are derived stores that automatically:
|
||||
// 1. Watch for changes to the current user's pubkey
|
||||
// 2. Load the appropriate data when the user changes
|
||||
// 3. Provide the data reactively
|
||||
|
||||
userProfile.subscribe(profile => {
|
||||
// Current user's profile data
|
||||
})
|
||||
|
||||
userFollows.subscribe(follows => {
|
||||
// Current user's follow list
|
||||
})
|
||||
```
|
||||
|
||||
### Web of Trust Utilities
|
||||
|
||||
The `wot.ts` module provides additional utilities for analyzing the social graph:
|
||||
|
||||
```typescript
|
||||
import { getFollows, getFollowers, getNetwork, getWotScore } from '@welshman/app'
|
||||
|
||||
// Get users followed by a pubkey
|
||||
const followedUsers = getFollows('pubkey123')
|
||||
|
||||
// Get users following a pubkey
|
||||
const followers = getFollowers('pubkey123')
|
||||
|
||||
// Get extended network (follows-of-follows)
|
||||
const network = getNetwork('pubkey123')
|
||||
|
||||
// Calculate trust score between users
|
||||
const score = getWotScore('userPubkey', 'targetPubkey')
|
||||
```
|
||||
@@ -0,0 +1,153 @@
|
||||
# Subscription System
|
||||
|
||||
The subscription system extends Nostr's base subscription model with intelligent caching, repository integration, and configurable behaviors.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Local Repository**: Events are automatically cached and tracked
|
||||
- **Cache Intelligence**: Smart decisions about when to use cached data
|
||||
- **Relay Integration**: Works with the router for optimal relay selection
|
||||
- **Configurable Behavior**: Control caching and timeouts
|
||||
|
||||
## Configuration Options
|
||||
|
||||
```typescript
|
||||
type SubscribeRequest = {
|
||||
// Required
|
||||
filters: Filter[] // What to query
|
||||
|
||||
// Behavior Control
|
||||
closeOnEose?: boolean // Auto-close and use cache
|
||||
timeout?: number // Max time to wait
|
||||
authTimeout?: number // Time for auth negotiation
|
||||
requestDelay?: number // Delay between batched requests
|
||||
|
||||
// Optional
|
||||
relays?: string[] // Specific relays to query
|
||||
|
||||
// Event Handlers
|
||||
onEvent?: (event: TrustedEvent) => void
|
||||
onEose?: (url: string) => void
|
||||
onComplete?: () => void
|
||||
}
|
||||
```
|
||||
|
||||
## Cache Behavior Control
|
||||
|
||||
The `closeOnEose` parameter is crucial for controlling caching behavior:
|
||||
|
||||
```typescript
|
||||
// WITH closeOnEose: true (default for load())
|
||||
// - Checks cache first
|
||||
// - Returns cached results if complete
|
||||
// - Closes after EOSE
|
||||
// - Good for: Known events, historical data
|
||||
const loadKnownEvent = async (id: string) => {
|
||||
const events = await load({
|
||||
filters: [{ids: [id]}],
|
||||
closeOnEose: true
|
||||
})
|
||||
return events[0]
|
||||
}
|
||||
|
||||
// WITH closeOnEose: false
|
||||
// - Always queries relays
|
||||
// - Stays open for updates
|
||||
// - Ignores cache completeness
|
||||
// - Good for: Replaceable events, live data
|
||||
const watchProfile = (pubkey: string) => {
|
||||
return subscribe({
|
||||
filters: [{
|
||||
kinds: [PROFILE],
|
||||
authors: [pubkey]
|
||||
}],
|
||||
closeOnEose: false // Force relay query
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Common Usage Patterns
|
||||
|
||||
### One-time Queries
|
||||
|
||||
```typescript
|
||||
// Load specific event
|
||||
const event = await load({
|
||||
filters: [{ids: [eventId]}]
|
||||
// closeOnEose: true by default
|
||||
})
|
||||
|
||||
// Load latest profile
|
||||
const profile = await load({
|
||||
filters: [{
|
||||
kinds: [PROFILE],
|
||||
authors: [pubkey],
|
||||
limit: 1
|
||||
}],
|
||||
closeOnEose: false // Get latest from network
|
||||
})
|
||||
```
|
||||
|
||||
### Live Subscriptions
|
||||
|
||||
```typescript
|
||||
// Watch for updates
|
||||
const sub = subscribe({
|
||||
filters: [{
|
||||
kinds: [NOTE],
|
||||
since: now() // Only new events
|
||||
}],
|
||||
closeOnEose: false, // Stay open
|
||||
})
|
||||
|
||||
sub.on('event', (url, event) => {
|
||||
// Handle live events
|
||||
})
|
||||
```
|
||||
|
||||
### Smart Caching
|
||||
|
||||
```typescript
|
||||
// Profile loader with refresh control
|
||||
const loadProfile = async (pubkey: string, options = {}) => {
|
||||
const {
|
||||
forceRefresh = false, // Skip cache
|
||||
timeout = 3000, // Max wait time
|
||||
relays = [] // Optional relay override
|
||||
} = options
|
||||
|
||||
// Get optimal relays if not specified
|
||||
const targetRelays = relays.length > 0
|
||||
? relays
|
||||
: ctx.app.router.ForPubkey(pubkey).getUrls()
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const sub = subscribe({
|
||||
filters: [{
|
||||
kinds: [PROFILE],
|
||||
authors: [pubkey],
|
||||
limit: 1
|
||||
}],
|
||||
relays: targetRelays,
|
||||
closeOnEose: !forceRefresh, // Control cache behavior
|
||||
timeout,
|
||||
|
||||
onEvent: (url, event) => {
|
||||
resolve(event)
|
||||
sub.close()
|
||||
},
|
||||
|
||||
onComplete: () => resolve(null)
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Repository Integration
|
||||
|
||||
All events from subscriptions are automatically:
|
||||
- Saved to the repository
|
||||
- Tracked to their source relay
|
||||
- Checked against deletion status
|
||||
|
||||
The repository serves as an intelligent cache layer, making subsequent queries for the same data faster.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Tag Utilities
|
||||
|
||||
The tag utilities provide helper functions for creating properly formatted Nostr event tags with correct relay hints and metadata.
|
||||
These are especially useful when creating events that reference other events or users.
|
||||
|
||||
## Tag Creators
|
||||
|
||||
|
||||
### User Tags
|
||||
```typescript
|
||||
import {tagPubkey} from '@welshman/app'
|
||||
|
||||
// Create a p-tag with relay hint and profile name
|
||||
const tag = tagPubkey(authorPubkey)
|
||||
// => ["p", pubkey, "wss://relay.example.com", "username"]
|
||||
```
|
||||
|
||||
|
||||
### Event Reference Tags
|
||||
|
||||
```typescript
|
||||
import {
|
||||
tagEvent, // Basic event reference
|
||||
tagEventForQuote, // For quoting events
|
||||
tagEventForReply, // For reply threads
|
||||
tagEventForComment, // For NIP-23 comments
|
||||
tagEventForReaction // For reactions
|
||||
} from '@welshman/app'
|
||||
|
||||
// Real world example: Creating a reply
|
||||
const createReply = async (parent: TrustedEvent, content: string) => {
|
||||
// Get proper tags for a reply, including:
|
||||
// - All referenced pubkeys
|
||||
// - Root/reply markers
|
||||
// - Inherited mentions
|
||||
// - Relay hints
|
||||
const tags = tagEventForReply(parent)
|
||||
|
||||
const event = await signer.get().sign(
|
||||
createEvent(NOTE, {
|
||||
content,
|
||||
tags,
|
||||
created_at: now()
|
||||
})
|
||||
)
|
||||
|
||||
return publishThunk({
|
||||
event,
|
||||
// Use relay hints from tags
|
||||
relays: ctx.app.router.PublishEvent(event).getUrls()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
All tag creators:
|
||||
- Add appropriate relay hints using the router
|
||||
- Handle replaceable/parameterized events
|
||||
- Follow adequate NIP-10/NIP-22 conventions for threading
|
||||
- Include metadata like usernames
|
||||
- Deduplicate references
|
||||
- Preserve tag order
|
||||
|
||||
The tagging system is crucial for:
|
||||
- Thread construction
|
||||
- Event reactions
|
||||
- User mentions
|
||||
- Zap splits
|
||||
|
||||
Tag utilities ensure consistent and correct tag creation across the application while integrating with the router for relay hints.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Thunks
|
||||
|
||||
Thunks provide optimistic updates for event publishing. They immediately update the local repository while handling the actual signing and publishing asynchronously, making the UI feel more responsive.
|
||||
|
||||
## Overview
|
||||
|
||||
A thunk:
|
||||
- Updates local state immediately
|
||||
- Handles event signing in the background
|
||||
- Manages publish status per relay
|
||||
- Supports soft-undo via abort
|
||||
- Can be delayed/cancelled
|
||||
- Tracks successful publishes
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import {publishThunk} from '@welshman/app'
|
||||
import {createEvent, NOTE} from '@welshman/util'
|
||||
|
||||
const publish = async (content: string) => {
|
||||
// Get optimal relays for publishing
|
||||
const relays = ctx.app.router
|
||||
.FromUser()
|
||||
.getUrls()
|
||||
|
||||
// Create and publish thunk
|
||||
const thunk = await publishThunk({
|
||||
event: createEvent(NOTE, {content}),
|
||||
relays,
|
||||
delay: 3000, // 3s window for abort
|
||||
})
|
||||
|
||||
// Track publish status
|
||||
thunk.status.subscribe(statuses => {
|
||||
for (const [url, {status, message}] of Object.entries(statuses)) {
|
||||
console.log(`${url}: ${status} ${message}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Can abort within delay window
|
||||
setTimeout(() => {
|
||||
if (userWantsToCancel) {
|
||||
thunk.controller.abort()
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
// Wait for completion
|
||||
const results = await thunk.result
|
||||
return results
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,39 @@
|
||||
# Topics
|
||||
|
||||
The topics system provides a reactive way to track and count hashtags across all events in the repository. It automatically updates as new events arrive or are removed.
|
||||
|
||||
```typescript
|
||||
import {topics} from '@welshman/app'
|
||||
|
||||
// In a Svelte component
|
||||
<script>
|
||||
// Reactive list of all topics with counts
|
||||
$: topicList = $topics
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 20)
|
||||
</script>
|
||||
|
||||
<div class="topics">
|
||||
{#each topicList as {name, count}}
|
||||
<a href="/t/{name}">
|
||||
#{name}
|
||||
<span class="count">({count})</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
```
|
||||
|
||||
The store:
|
||||
- Updates automatically with new events
|
||||
- Maintains topic counts
|
||||
- Is throttled to prevent excess updates
|
||||
- Is case-insensitive
|
||||
- Integrates with the repository
|
||||
|
||||
Think of it as a live tag cloud that stays in sync with your local event cache.
|
||||
|
||||
This is commonly used for:
|
||||
- Tag clouds
|
||||
- Topic discovery
|
||||
- Content organization
|
||||
- Trending topics
|
||||
@@ -0,0 +1,63 @@
|
||||
# Web of Trust (WOT) Module
|
||||
|
||||
The `wot.ts` module provides utilities for implementing a Web of Trust system within Nostr applications. This system analyzes social connections (follows and mutes) to build a reputation graph that can be used for content filtering, user scoring, and discovery.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
- **Follow Trust**: Users gain positive reputation when followed by those in your network
|
||||
- **Mute Distrust**: Users lose reputation when muted by those in your network
|
||||
- **WOT Graph**: A reactive weighted directed graph representing trust relationships
|
||||
- **Contextual Scoring**: Reputation scores that adapt based on user's social graph
|
||||
|
||||
## API Reference
|
||||
|
||||
### Social Graph Navigation
|
||||
|
||||
```typescript
|
||||
// Get users followed by a specific pubkey
|
||||
getFollows(pubkey: string): string[]
|
||||
|
||||
// Get users who have muted a specific pubkey
|
||||
getMutes(pubkey: string): string[]
|
||||
|
||||
// Get followers of a specific pubkey
|
||||
getFollowers(pubkey: string): string[]
|
||||
|
||||
// Get users who have muted a specific pubkey
|
||||
getMuters(pubkey: string): string[]
|
||||
|
||||
// Get the extended network (follows-of-follows) for a pubkey
|
||||
getNetwork(pubkey: string): string[]
|
||||
```
|
||||
|
||||
### Trust Analysis
|
||||
|
||||
```typescript
|
||||
// Get follows of a user who also follow a target
|
||||
getFollowsWhoFollow(pubkey: string, target: string): string[]
|
||||
|
||||
// Get follows of a user who have muted a target
|
||||
getFollowsWhoMute(pubkey: string, target: string): string[]
|
||||
|
||||
// Calculate trust score between users
|
||||
getWotScore(pubkey: string, target: string): number
|
||||
```
|
||||
|
||||
### Reactive Stores
|
||||
|
||||
```typescript
|
||||
// Map of follower lists by pubkey
|
||||
followersByPubkey: Readable<Map<string, Set<string>>>
|
||||
|
||||
// Map of muter lists by pubkey
|
||||
mutersByPubkey: Readable<Map<string, Set<string>>>
|
||||
|
||||
// The full WOT graph with scores (pubkey → score)
|
||||
wotGraph: Readable<Map<string, number>>
|
||||
|
||||
// The maximum WOT score in the graph
|
||||
maxWot: Readable<number>
|
||||
|
||||
// Derive the WOT score for a specific user
|
||||
deriveUserWotScore(targetPubkey: string): Readable<number>
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
window.hierarchyData = "eJyd1VFv2yAQB/DvwjPNOGwM+HHTHipt09TsrYoq16ELKoHIkE1Tle8+4dQrSSfl4qdIzv39850Bv5AhhBRJe6+kbChwaMSKksE8OdMnG3wk7QvJ/+Vf320NacnnrU3JDISSZ+vXpAWuKNkPjrSkd12MJn54+G1c3Gw7/+Ds4+I1sNikrSP0WERakuL6Jt/h5niBkn5j3XownrT3WlaKat0ABQaVoMC4qiiwmisKTORnZZLn60ozCsBEQwGEBAoglaAAuhYUuKiAAm+UWB0oyTct+vgUvD92iWvFm7R4y1zoJnO6gXJs/SbgoVx9mRiHUxhfQt85PDKWYxSuqkL5unfJ4pWxHKPUvHw930O4opVcjTHEyVK+M677g0fGcowiuThX4pVMxDhKs8JZ7h9jP9jddUu6TCHMvNUK88fQ9c/YwyBzrwGMVOty1X2zOwZL+9NjtTjWLoocBhWVPkPlTFRegUo4Revm4xDQc31DpxwGlUqco3emN/bXHHZKYmBdv4OXxq/nsMccAuVMy3forPc65TAoADtFhZiHTjkU2pTjvT0DuWgm0Ppkhqeu/595e8H7lz39bo+blo67iI4zp+MQVuODiQrmnb37ZB368M1f+xNmF6JNYbjOmkKXwMPhL+By/ic="
|
||||
@@ -0,0 +1,99 @@
|
||||
:root {
|
||||
--light-hl-0: #AF00DB;
|
||||
--dark-hl-0: #C586C0;
|
||||
--light-hl-1: #000000;
|
||||
--dark-hl-1: #D4D4D4;
|
||||
--light-hl-2: #001080;
|
||||
--dark-hl-2: #9CDCFE;
|
||||
--light-hl-3: #A31515;
|
||||
--dark-hl-3: #CE9178;
|
||||
--light-hl-4: #008000;
|
||||
--dark-hl-4: #6A9955;
|
||||
--light-hl-5: #795E26;
|
||||
--dark-hl-5: #DCDCAA;
|
||||
--light-hl-6: #0000FF;
|
||||
--dark-hl-6: #569CD6;
|
||||
--light-hl-7: #0070C1;
|
||||
--dark-hl-7: #4FC1FF;
|
||||
--light-hl-8: #098658;
|
||||
--dark-hl-8: #B5CEA8;
|
||||
--light-hl-9: #267F99;
|
||||
--dark-hl-9: #4EC9B0;
|
||||
--light-hl-10: #000000FF;
|
||||
--dark-hl-10: #D4D4D4;
|
||||
--light-code-background: #FFFFFF;
|
||||
--dark-code-background: #1E1E1E;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) { :root {
|
||||
--hl-0: var(--light-hl-0);
|
||||
--hl-1: var(--light-hl-1);
|
||||
--hl-2: var(--light-hl-2);
|
||||
--hl-3: var(--light-hl-3);
|
||||
--hl-4: var(--light-hl-4);
|
||||
--hl-5: var(--light-hl-5);
|
||||
--hl-6: var(--light-hl-6);
|
||||
--hl-7: var(--light-hl-7);
|
||||
--hl-8: var(--light-hl-8);
|
||||
--hl-9: var(--light-hl-9);
|
||||
--hl-10: var(--light-hl-10);
|
||||
--code-background: var(--light-code-background);
|
||||
} }
|
||||
|
||||
@media (prefers-color-scheme: dark) { :root {
|
||||
--hl-0: var(--dark-hl-0);
|
||||
--hl-1: var(--dark-hl-1);
|
||||
--hl-2: var(--dark-hl-2);
|
||||
--hl-3: var(--dark-hl-3);
|
||||
--hl-4: var(--dark-hl-4);
|
||||
--hl-5: var(--dark-hl-5);
|
||||
--hl-6: var(--dark-hl-6);
|
||||
--hl-7: var(--dark-hl-7);
|
||||
--hl-8: var(--dark-hl-8);
|
||||
--hl-9: var(--dark-hl-9);
|
||||
--hl-10: var(--dark-hl-10);
|
||||
--code-background: var(--dark-code-background);
|
||||
} }
|
||||
|
||||
:root[data-theme='light'] {
|
||||
--hl-0: var(--light-hl-0);
|
||||
--hl-1: var(--light-hl-1);
|
||||
--hl-2: var(--light-hl-2);
|
||||
--hl-3: var(--light-hl-3);
|
||||
--hl-4: var(--light-hl-4);
|
||||
--hl-5: var(--light-hl-5);
|
||||
--hl-6: var(--light-hl-6);
|
||||
--hl-7: var(--light-hl-7);
|
||||
--hl-8: var(--light-hl-8);
|
||||
--hl-9: var(--light-hl-9);
|
||||
--hl-10: var(--light-hl-10);
|
||||
--code-background: var(--light-code-background);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--hl-0: var(--dark-hl-0);
|
||||
--hl-1: var(--dark-hl-1);
|
||||
--hl-2: var(--dark-hl-2);
|
||||
--hl-3: var(--dark-hl-3);
|
||||
--hl-4: var(--dark-hl-4);
|
||||
--hl-5: var(--dark-hl-5);
|
||||
--hl-6: var(--dark-hl-6);
|
||||
--hl-7: var(--dark-hl-7);
|
||||
--hl-8: var(--dark-hl-8);
|
||||
--hl-9: var(--dark-hl-9);
|
||||
--hl-10: var(--dark-hl-10);
|
||||
--code-background: var(--dark-code-background);
|
||||
}
|
||||
|
||||
.hl-0 { color: var(--hl-0); }
|
||||
.hl-1 { color: var(--hl-1); }
|
||||
.hl-2 { color: var(--hl-2); }
|
||||
.hl-3 { color: var(--hl-3); }
|
||||
.hl-4 { color: var(--hl-4); }
|
||||
.hl-5 { color: var(--hl-5); }
|
||||
.hl-6 { color: var(--hl-6); }
|
||||
.hl-7 { color: var(--hl-7); }
|
||||
.hl-8 { color: var(--hl-8); }
|
||||
.hl-9 { color: var(--hl-9); }
|
||||
.hl-10 { color: var(--hl-10); }
|
||||
pre, code { background: var(--code-background); }
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 12 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
||||
# @welshman/content
|
||||
|
||||
`@welshman/content` is a comprehensive content processing library designed specifically for Nostr applications.
|
||||
It provides a robust system for parsing, processing, and rendering Nostr content while handling various special formats and entities common in the Nostr ecosystem.
|
||||
|
||||
|
||||
## Core Concepts
|
||||
|
||||
The package is built around two main components:
|
||||
|
||||
1. **Parser**: Transforms raw content into structured elements
|
||||
```typescript
|
||||
const parsed = parse({
|
||||
content: "Hello #nostr, check nostr:npub1...",
|
||||
tags: [["p", "pubkey123"]]
|
||||
})
|
||||
```
|
||||
|
||||
2. **Renderer**: Converts parsed content into desired output format
|
||||
```typescript
|
||||
const html = renderAsHtml(parsed).toString()
|
||||
const text = renderAsText(parsed).toString()
|
||||
```
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
- Rendering Nostr notes with proper entity linking
|
||||
- Processing and displaying user content safely
|
||||
- Handling rich text content in Nostr clients
|
||||
- Converting between different content formats
|
||||
- Creating customized content displays
|
||||
|
||||
## Quick Example
|
||||
|
||||
```typescript
|
||||
import { parse, renderAsHtml, truncate } from '@welshman/content'
|
||||
|
||||
// Parse and process content
|
||||
const parsed = parse({
|
||||
content: `
|
||||
Hello #nostr!
|
||||
Check out this note: nostr:note1...
|
||||
https://example.com/image.jpg
|
||||
`,
|
||||
tags: [["p", "pubkey123"]]
|
||||
})
|
||||
|
||||
// Truncate if needed
|
||||
const truncated = truncate(parsed, {
|
||||
maxLength: 500,
|
||||
mediaLength: 150
|
||||
})
|
||||
|
||||
// Render as HTML
|
||||
const html = renderAsHtml(truncated, {
|
||||
entityBase: "https://your-app.com/"
|
||||
}).toString()
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @welshman/content
|
||||
```
|
||||
|
||||
This package is essential for applications that need to handle Nostr content in a structured and safe way, providing all the necessary tools for parsing, processing, and rendering Nostr-specific content formats.
|
||||
@@ -0,0 +1,181 @@
|
||||
# Content Parser
|
||||
|
||||
The content parser system in `@welshman/content` provides a powerful way to parse Nostr content into structured elements.
|
||||
It handles various types of content including Nostr entities, links, code blocks, and special formats.
|
||||
|
||||
## Content Types
|
||||
|
||||
### Basic Types
|
||||
```typescript
|
||||
enum ParsedType {
|
||||
Text = "text", // Plain text
|
||||
Newline = "newline", // Line breaks
|
||||
Topic = "topic", // Hashtags (#nostr)
|
||||
Code = "code", // Code blocks (inline and multi-line)
|
||||
Link = "link", // URLs
|
||||
LinkGrid = "link-grid" // Grid of media links
|
||||
}
|
||||
```
|
||||
|
||||
### Nostr-specific Types
|
||||
```typescript
|
||||
enum ParsedType {
|
||||
Event = "event", // Nostr events (note1/nevent1)
|
||||
Profile = "profile", // Profiles (npub1/nprofile1)
|
||||
Address = "address", // Addresses (naddr1)
|
||||
}
|
||||
```
|
||||
|
||||
### Special Format Types
|
||||
```typescript
|
||||
enum ParsedType {
|
||||
Cashu = "cashu", // Cashu tokens
|
||||
Invoice = "invoice", // Lightning invoices
|
||||
Ellipsis = "ellipsis" // Truncation marker
|
||||
}
|
||||
```
|
||||
|
||||
## Parsing Content
|
||||
|
||||
### Main Parser
|
||||
```typescript
|
||||
const parse = ({
|
||||
content = "",
|
||||
tags = []
|
||||
}: {
|
||||
content?: string
|
||||
tags?: string[][]
|
||||
}) => Parsed[]
|
||||
|
||||
// Example
|
||||
const parsed = parse({
|
||||
content: "Hello #nostr, check nostr:npub1...",
|
||||
tags: [["p", "pubkey123"]]
|
||||
})
|
||||
```
|
||||
|
||||
### Available Parsers
|
||||
|
||||
The system includes specialized parsers for each content type:
|
||||
|
||||
```typescript
|
||||
// Nostr Entities
|
||||
parseAddress(text: string, context: ParseContext): ParsedAddress | void
|
||||
parseEvent(text: string, context: ParseContext): ParsedEvent | void
|
||||
parseProfile(text: string, context: ParseContext): ParsedProfile | void
|
||||
|
||||
// Code Blocks
|
||||
parseCodeBlock(text: string, context: ParseContext): ParsedCode | void
|
||||
parseCodeInline(text: string, context: ParseContext): ParsedCode | void
|
||||
|
||||
// Special Formats
|
||||
parseCashu(text: string, context: ParseContext): ParsedCashu | void
|
||||
parseInvoice(text: string, context: ParseContext): ParsedInvoice | void
|
||||
|
||||
// Basic Content
|
||||
parseLink(text: string, context: ParseContext): ParsedLink | void
|
||||
parseNewline(text: string, context: ParseContext): ParsedNewline | void
|
||||
parseTopic(text: string, context: ParseContext): ParsedTopic | void
|
||||
```
|
||||
|
||||
## Content Processing
|
||||
|
||||
### Truncation
|
||||
```typescript
|
||||
type TruncateOpts = {
|
||||
minLength?: number // Minimum content length (default: 500)
|
||||
maxLength?: number // Maximum content length (default: 700)
|
||||
mediaLength?: number // Length value for media items (default: 200)
|
||||
entityLength?: number // Length value for entities (default: 30)
|
||||
}
|
||||
|
||||
const truncate = (
|
||||
content: Parsed[],
|
||||
options?: TruncateOpts
|
||||
) => Parsed[]
|
||||
|
||||
// Example
|
||||
const truncated = truncate(parsed, {
|
||||
maxLength: 1000,
|
||||
mediaLength: 150
|
||||
})
|
||||
```
|
||||
|
||||
### Link Processing
|
||||
```typescript
|
||||
// Consolidate consecutive image links into grids
|
||||
const reduceLinks = (content: Parsed[]) => Parsed[]
|
||||
|
||||
// Example
|
||||
const processed = reduceLinks(parsed)
|
||||
```
|
||||
|
||||
## Type Guards
|
||||
|
||||
```typescript
|
||||
// Basic content
|
||||
isText(parsed: Parsed): parsed is ParsedText
|
||||
isNewline(parsed: Parsed): parsed is ParsedNewline
|
||||
isCode(parsed: Parsed): parsed is ParsedCode
|
||||
isTopic(parsed: Parsed): parsed is ParsedTopic
|
||||
|
||||
// Links and media
|
||||
isLink(parsed: Parsed): parsed is ParsedLink
|
||||
isImage(parsed: Parsed): parsed is ParsedLink
|
||||
isLinkGrid(parsed: Parsed): parsed is ParsedLinkGrid
|
||||
|
||||
// Nostr entities
|
||||
isEvent(parsed: Parsed): parsed is ParsedEvent
|
||||
isProfile(parsed: Parsed): parsed is ParsedProfile
|
||||
isAddress(parsed: Parsed): parsed is ParsedAddress
|
||||
|
||||
// Special formats
|
||||
isCashu(parsed: Parsed): parsed is ParsedCashu
|
||||
isInvoice(parsed: Parsed): parsed is ParsedInvoice
|
||||
isEllipsis(parsed: Parsed): parsed is ParsedEllipsis
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```typescript
|
||||
// Parse content with tags
|
||||
const parsed = parse({
|
||||
content: `
|
||||
Hello #nostr!
|
||||
|
||||
Check out this note: nostr:note1...
|
||||
And this profile: nostr:npub1...
|
||||
|
||||
Some code: \`console.log("hello")\`
|
||||
|
||||
https://example.com/image.jpg
|
||||
https://example.com/image2.jpg
|
||||
`,
|
||||
tags: [
|
||||
["p", "pubkey123"],
|
||||
["e", "event456"]
|
||||
]
|
||||
})
|
||||
|
||||
// Process the content
|
||||
const processed = reduceLinks(parsed)
|
||||
|
||||
// Truncate if needed
|
||||
const final = truncate(processed, {
|
||||
maxLength: 500,
|
||||
mediaLength: 150
|
||||
})
|
||||
|
||||
// Check types and handle accordingly
|
||||
final.forEach(item => {
|
||||
if (isImage(item)) {
|
||||
// Handle image
|
||||
} else if (isProfile(item)) {
|
||||
// Handle profile reference
|
||||
} else if (isCode(item)) {
|
||||
// Handle code block
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
This parser system provides a robust foundation for handling Nostr content, with support for various content types and processing needs. The type-safe approach ensures reliable content handling while maintaining flexibility for different use cases.
|
||||
@@ -0,0 +1,195 @@
|
||||
# Content Renderer
|
||||
|
||||
The renderer system in `@welshman/content` provides flexible ways to convert parsed content into text or HTML output. It includes customizable rendering options and specialized handlers for each content type.
|
||||
|
||||
## Renderer Class
|
||||
|
||||
```typescript
|
||||
class Renderer {
|
||||
constructor(readonly options: RenderOptions)
|
||||
|
||||
// Core methods
|
||||
toString(): string
|
||||
addText(value: string): void
|
||||
addNewlines(count: number): void
|
||||
addLink(href: string, display: string): void
|
||||
addEntityLink(entity: string): void
|
||||
}
|
||||
```
|
||||
|
||||
## Render Options
|
||||
|
||||
```typescript
|
||||
type RenderOptions = {
|
||||
// String to use for newlines
|
||||
newline: string
|
||||
|
||||
// Base URL for Nostr entities
|
||||
entityBase: string
|
||||
|
||||
// Custom link rendering
|
||||
renderLink: (href: string, display: string) => string
|
||||
|
||||
// Custom entity rendering
|
||||
renderEntity: (entity: string) => string
|
||||
}
|
||||
```
|
||||
|
||||
## Built-in Renderers
|
||||
|
||||
### Text Renderer
|
||||
```typescript
|
||||
const textRenderOptions = {
|
||||
newline: "\n",
|
||||
entityBase: "",
|
||||
renderLink: (href, display) => href,
|
||||
renderEntity: (entity) => entity.slice(0, 16) + "…"
|
||||
}
|
||||
|
||||
const textRenderer = makeTextRenderer({
|
||||
// Override default options if needed
|
||||
})
|
||||
```
|
||||
|
||||
### HTML Renderer
|
||||
```typescript
|
||||
const htmlRenderOptions = {
|
||||
newline: "\n",
|
||||
entityBase: "https://njump.me/",
|
||||
renderLink: (href, display) => {
|
||||
const element = document.createElement("a")
|
||||
element.href = sanitizeUrl(href)
|
||||
element.target = "_blank"
|
||||
element.innerText = display
|
||||
return element.outerHTML
|
||||
},
|
||||
renderEntity: (entity) => entity.slice(0, 16) + "…"
|
||||
}
|
||||
|
||||
const htmlRenderer = makeHtmlRenderer({
|
||||
// Override default options if needed
|
||||
})
|
||||
```
|
||||
|
||||
## Content Type Renderers
|
||||
|
||||
```typescript
|
||||
// Basic content
|
||||
renderText(p: ParsedText, r: Renderer): void
|
||||
renderNewline(p: ParsedNewline, r: Renderer): void
|
||||
renderCode(p: ParsedCode, r: Renderer): void
|
||||
renderTopic(p: ParsedTopic, r: Renderer): void
|
||||
|
||||
// Links
|
||||
renderLink(p: ParsedLink, r: Renderer): void
|
||||
|
||||
// Nostr entities
|
||||
renderEvent(p: ParsedEvent, r: Renderer): void
|
||||
renderProfile(p: ParsedProfile, r: Renderer): void
|
||||
renderAddress(p: ParsedAddress, r: Renderer): void
|
||||
|
||||
// Special formats
|
||||
renderCashu(p: ParsedCashu, r: Renderer): void
|
||||
renderInvoice(p: ParsedInvoice, r: Renderer): void
|
||||
renderEllipsis(p: ParsedEllipsis, r: Renderer): void
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Text Rendering
|
||||
```typescript
|
||||
const parsed = parse({
|
||||
content: "Hello #nostr, check nostr:npub1...",
|
||||
tags: []
|
||||
})
|
||||
|
||||
// Render as plain text
|
||||
const text = renderAsText(parsed).toString()
|
||||
|
||||
// Render as HTML
|
||||
const html = renderAsHtml(parsed).toString()
|
||||
```
|
||||
|
||||
### Custom Rendering Options
|
||||
```typescript
|
||||
// Custom text renderer
|
||||
const customText = renderAsText(parsed, {
|
||||
entityBase: "nostr:",
|
||||
renderEntity: (entity) => entity.slice(0, 8)
|
||||
}).toString()
|
||||
|
||||
// Custom HTML renderer
|
||||
const customHtml = renderAsHtml(parsed, {
|
||||
entityBase: "https://example.com/",
|
||||
renderLink: (href, display) => `<a class="custom-link" href="${href}">${display}</a>`,
|
||||
renderEntity: (entity) => `<span class="entity">${entity}</span>`
|
||||
}).toString()
|
||||
```
|
||||
|
||||
### Rendering Individual Elements
|
||||
```typescript
|
||||
const renderer = makeHtmlRenderer()
|
||||
|
||||
// Render single element
|
||||
renderOne({
|
||||
type: ParsedType.Link,
|
||||
value: {
|
||||
url: new URL("https://example.com"),
|
||||
meta: {},
|
||||
isMedia: false
|
||||
},
|
||||
raw: "https://example.com"
|
||||
}, renderer)
|
||||
|
||||
// Render multiple elements
|
||||
renderMany([
|
||||
{
|
||||
type: ParsedType.Text,
|
||||
value: "Hello ",
|
||||
raw: "Hello "
|
||||
},
|
||||
{
|
||||
type: ParsedType.Topic,
|
||||
value: "nostr",
|
||||
raw: "#nostr"
|
||||
}
|
||||
], renderer)
|
||||
```
|
||||
|
||||
### Complete Example
|
||||
```typescript
|
||||
// Parse and process content
|
||||
const parsed = parse({
|
||||
content: `
|
||||
Check out this profile: nostr:npub1...
|
||||
|
||||
Code example:
|
||||
\`console.log("hello")\`
|
||||
|
||||
#nostr #bitcoin
|
||||
|
||||
https://example.com/image.jpg
|
||||
`,
|
||||
tags: []
|
||||
})
|
||||
|
||||
// Create custom renderer
|
||||
const renderer = makeHtmlRenderer({
|
||||
entityBase: "https://example.com/",
|
||||
renderLink: (href, display) => {
|
||||
if (href.endsWith('.jpg')) {
|
||||
return `<img src="${href}" alt="${display}">`
|
||||
}
|
||||
return `<a href="${href}">${display}</a>`
|
||||
},
|
||||
renderEntity: (entity) => {
|
||||
return `<span class="entity">${entity.slice(0, 8)}</span>`
|
||||
}
|
||||
})
|
||||
|
||||
// Render content
|
||||
const html = render(parsed, renderer).toString()
|
||||
```
|
||||
|
||||
|
||||
The renderer system provides a flexible way to output parsed content in various formats while maintaining control over the rendering process. Its modular design allows for easy customization and extension for specific application needs.
|
||||
@@ -0,0 +1,107 @@
|
||||
# DVM (Data Vending Machine) Handler
|
||||
|
||||
The DVM Handler module provides a framework for creating and managing Data Vending Machines in the Nostr ecosystem.
|
||||
A DVM is a service that listens for specific kinds of events and responds with processed data.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### DVM Handler
|
||||
```typescript
|
||||
type DVMHandler = {
|
||||
stop?: () => void
|
||||
handleEvent: (e: TrustedEvent) => AsyncGenerator<StampedEvent>
|
||||
}
|
||||
```
|
||||
A handler defines how to process specific kinds of events and generate responses.
|
||||
|
||||
### DVM Options
|
||||
```typescript
|
||||
type DVMOpts = {
|
||||
sk: string // Private key for signing responses
|
||||
relays: string[] // Relays to connect to
|
||||
handlers: Record<string, CreateDVMHandler> // Event handlers by kind
|
||||
expireAfter?: number // Response expiration time in seconds
|
||||
requireMention?: boolean // Require DVM to be mentioned in event
|
||||
}
|
||||
```
|
||||
|
||||
## Creating a DVM
|
||||
|
||||
```typescript
|
||||
import { DVM } from '@welshman/dvm'
|
||||
|
||||
// Create handlers for different event kinds
|
||||
const handlers = {
|
||||
// Handler for kind 5001
|
||||
"5001": (dvm: DVM) => ({
|
||||
handleEvent: async function*(event: TrustedEvent) {
|
||||
// Process event and yield responses
|
||||
yield {
|
||||
kind: 6001,
|
||||
content: "Processed result",
|
||||
created_at: now(),
|
||||
tags: []
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize DVM
|
||||
const dvm = new DVM({
|
||||
sk: "your-private-key",
|
||||
relays: ["wss://relay.example.com"],
|
||||
handlers,
|
||||
expireAfter: 3600, // 1 hour
|
||||
requireMention: true
|
||||
})
|
||||
|
||||
// Start the DVM
|
||||
await dvm.start()
|
||||
```
|
||||
|
||||
|
||||
## Example Implementation
|
||||
|
||||
```typescript
|
||||
import { DVM, CreateDVMHandler } from '@welshman/dvm'
|
||||
import { now } from '@welshman/lib'
|
||||
|
||||
// Create a search handler
|
||||
const createSearchHandler: CreateDVMHandler = (dvm) => ({
|
||||
handleEvent: async function*(event) {
|
||||
const query = event.content
|
||||
const results = await performSearch(query)
|
||||
|
||||
yield {
|
||||
kind: 6001,
|
||||
content: JSON.stringify(results),
|
||||
created_at: now(),
|
||||
tags: [
|
||||
["search", query],
|
||||
["results", String(results.length)]
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Initialize DVM
|
||||
const searchDVM = new DVM({
|
||||
sk: process.env.DVM_KEY!,
|
||||
relays: ["wss://relay1.com", "wss://relay2.com"],
|
||||
handlers: {
|
||||
"5001": createSearchHandler
|
||||
},
|
||||
expireAfter: 24 * 60 * 60, // 24 hours
|
||||
requireMention: true
|
||||
})
|
||||
|
||||
// Start DVM
|
||||
await searchDVM.start()
|
||||
|
||||
// Stop DVM when needed
|
||||
process.on('SIGINT', () => {
|
||||
searchDVM.stop()
|
||||
})
|
||||
```
|
||||
|
||||
The DVM Handler provides a robust foundation for building Nostr data services, with built-in support for common requirements like deduplication, response signing, and metadata management.
|
||||
@@ -0,0 +1,18 @@
|
||||
# @welshman/dvm
|
||||
|
||||
`@welshman/dvm` is a comprehensive package for building and interacting with Data Vending Machines (DVMs) in the Nostr ecosystem. It provides both server-side DVM implementation capabilities and client-side request handling.
|
||||
|
||||
## What is a DVM?
|
||||
|
||||
A Data Vending Machine (DVM) is a Nostr service that:
|
||||
- Listens for specific kinds of events
|
||||
- Processes these events according to defined rules
|
||||
- Responds with new events containing processed data
|
||||
- Optionally provides progress updates during processing
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @welshman/dvm
|
||||
```
|
||||
@@ -0,0 +1,119 @@
|
||||
# DVM Request
|
||||
|
||||
The DVM Request module provides utilities for making requests to Data Vending Machines (DVMs) and handling their responses.
|
||||
It includes support for progress tracking and result handling.
|
||||
|
||||
## Core Types
|
||||
|
||||
### DVMRequestOptions
|
||||
```typescript
|
||||
type DVMRequestOptions = {
|
||||
event: SignedEvent // The event to send to the DVM
|
||||
relays: string[] // Relays to use
|
||||
timeout?: number // Request timeout in milliseconds
|
||||
autoClose?: boolean // Auto-close subscription after result
|
||||
reportProgress?: boolean // Listen for progress events
|
||||
}
|
||||
```
|
||||
|
||||
### DVMEvent Enum
|
||||
```typescript
|
||||
enum DVMEvent {
|
||||
Progress = "progress", // DVM progress updates (kind 7000)
|
||||
Result = "result" // Final DVM result
|
||||
}
|
||||
```
|
||||
|
||||
## Making DVM Requests
|
||||
|
||||
### Basic Usage
|
||||
```typescript
|
||||
import { makeDvmRequest, DVMEvent } from '@welshman/dvm'
|
||||
|
||||
const request = makeDvmRequest({
|
||||
event: signedEvent,
|
||||
relays: ["wss://relay.example.com"],
|
||||
timeout: 30000, // 30 seconds
|
||||
})
|
||||
|
||||
// Handle results
|
||||
request.emitter.on(DVMEvent.Result, (url, event) => {
|
||||
console.log('Received result:', event)
|
||||
})
|
||||
|
||||
// Handle progress updates
|
||||
request.emitter.on(DVMEvent.Progress, (url, event) => {
|
||||
console.log('Progress update:', event)
|
||||
})
|
||||
```
|
||||
|
||||
## Response Handling
|
||||
|
||||
### Result Events
|
||||
```typescript
|
||||
request.emitter.on(DVMEvent.Result, (url: string, event: TrustedEvent) => {
|
||||
// Handle the DVM result
|
||||
const result = JSON.parse(event.content)
|
||||
|
||||
// Process tags
|
||||
const requestTag = event.tags.find(t => t[0] === 'request')
|
||||
const expirationTag = event.tags.find(t => t[0] === 'expiration')
|
||||
})
|
||||
```
|
||||
|
||||
### Progress Updates
|
||||
```typescript
|
||||
request.emitter.on(DVMEvent.Progress, (url: string, event: TrustedEvent) => {
|
||||
// Handle progress update (kind 7000)
|
||||
const progress = JSON.parse(event.content)
|
||||
console.log(`Progress: ${progress.percentage}%`)
|
||||
})
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```typescript
|
||||
import { makeDvmRequest, DVMEvent } from '@welshman/dvm'
|
||||
import { createEvent, finalizeEvent } from '@welshman/util'
|
||||
|
||||
async function queryDVM() {
|
||||
// Create the request event
|
||||
const event = createEvent(5001, {
|
||||
content: JSON.stringify({
|
||||
query: "search terms"
|
||||
})
|
||||
})
|
||||
|
||||
// Sign the event
|
||||
const signedEvent = finalizeEvent(event, privateKey)
|
||||
|
||||
// Make the request
|
||||
const dvmRequest = makeDvmRequest({
|
||||
event: signedEvent,
|
||||
relays: ["wss://relay.example.com"],
|
||||
timeout: 30000,
|
||||
reportProgress: true
|
||||
})
|
||||
|
||||
// Handle progress updates
|
||||
dvmRequest.emitter.on(DVMEvent.Progress, (url, event) => {
|
||||
console.log('Progress:', event.content)
|
||||
})
|
||||
|
||||
// Return a promise that resolves with the result
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
dvmRequest.sub.close()
|
||||
reject(new Error('DVM request timeout'))
|
||||
}, 30000)
|
||||
|
||||
dvmRequest.emitter.on(DVMEvent.Result, (url, event) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(event)
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
This module simplifies the process of making requests to DVMs while providing flexibility in handling responses and progress updates.
|
||||
@@ -0,0 +1,154 @@
|
||||
# @welshman/editor
|
||||
|
||||
`@welshman/editor` provides a comprehensive Nostr-ready text editor, built on top of [nostr-editor](https://github.com/cesardeazevedo/nostr-editor).
|
||||
|
||||
This package powers the editors of [Coracle](https://coracle.social) and [Flotilla](https://flotilla.social).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @welshman/editor
|
||||
```
|
||||
|
||||
## WelshmanExtension
|
||||
|
||||
The `WelshmanExtension` is the main entry point of the package, providing a pre-configured collection of extensions optimized for Nostr content creation.
|
||||
|
||||
### Configuration
|
||||
|
||||
```typescript
|
||||
interface WelshmanOptions {
|
||||
// Required: Function to sign events
|
||||
sign: (event: StampedEvent) => Promise<SignedEvent>
|
||||
|
||||
// Required: Handler for submit action
|
||||
submit: () => void
|
||||
|
||||
// File upload configuration
|
||||
defaultUploadUrl?: string // Default: "https://nostr.build"
|
||||
defaultUploadType?: "nip96" | "blossom" // Default: "nip96"
|
||||
|
||||
// Extension configuration
|
||||
extensions?: WelshmanExtensionOptions
|
||||
}
|
||||
```
|
||||
|
||||
### Included Extensions
|
||||
|
||||
The extension bundles and configures multiple TipTap and nostr-editor extensions:
|
||||
|
||||
#### Core TipTap Extensions
|
||||
- Document
|
||||
- Text
|
||||
- Paragraph
|
||||
- History
|
||||
- CodeBlock
|
||||
- CodeInline
|
||||
- Dropcursor
|
||||
- Gapcursor
|
||||
- Placeholder
|
||||
|
||||
#### Nostr-specific Extensions
|
||||
- NostrExtension (base)
|
||||
- Bolt11Extension (Lightning invoices)
|
||||
- FileUploadExtension
|
||||
- ImageExtension
|
||||
- LinkExtension
|
||||
- NAddrExtension (Nostr addresses)
|
||||
- NEventExtension (Nostr events)
|
||||
- NProfileExtension (Nostr profiles)
|
||||
- TagExtension
|
||||
- VideoExtension
|
||||
- NSecRejectExtension
|
||||
|
||||
#### Custom Extensions
|
||||
- BreakOrSubmit (Enter key handling)
|
||||
- WordCount
|
||||
|
||||
### Usage
|
||||
|
||||
```typescript
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { WelshmanExtension } from '@welshman/editor'
|
||||
|
||||
const editor = new Editor({
|
||||
extensions: [
|
||||
WelshmanExtension.configure({
|
||||
// Required: Event signing function
|
||||
sign: async (event) => {
|
||||
return signEvent(event)
|
||||
},
|
||||
|
||||
// Required: Submit handler
|
||||
submit: () => {
|
||||
handleSubmit(editor.getText())
|
||||
},
|
||||
|
||||
// Optional: Custom upload configuration
|
||||
defaultUploadUrl: "https://nostr.build",
|
||||
defaultUploadType: "nip96",
|
||||
|
||||
// Optional: Extension configuration
|
||||
extensions: {
|
||||
// Disable specific extensions
|
||||
wordCount: false,
|
||||
tag: false,
|
||||
|
||||
// Configure extensions
|
||||
placeholder: {
|
||||
config: {
|
||||
placeholder: 'What\'s on your mind?'
|
||||
}
|
||||
},
|
||||
|
||||
// Extend existing extensions
|
||||
codeBlock: {
|
||||
extend: {
|
||||
renderText: (props) => '```' + props.node.textContent + '```'
|
||||
}
|
||||
},
|
||||
fileUpload: {
|
||||
config: {
|
||||
immediateUpload: true,
|
||||
allowedMimeTypes: [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"video/mp4"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
### Extension Configuration
|
||||
|
||||
Each extension can be configured using the `WelshmanExtensionOptions`:
|
||||
|
||||
```typescript
|
||||
type WelshmanExtensionOptions = {
|
||||
[ExtensionName: string]: {
|
||||
// Disable the extension
|
||||
false |
|
||||
|
||||
// Configure the extension
|
||||
{
|
||||
// Extension-specific configuration
|
||||
config?: Partial<ExtensionConfig>
|
||||
|
||||
// Extend the extension's functionality
|
||||
extend?: Partial<ExtensionAPI>
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Components
|
||||
|
||||
The extension includes Svelte components for rendering various Nostr entities in the editor:
|
||||
- EditBolt11: Lightning invoice
|
||||
- EditMedia: Image and video
|
||||
- EditEvent: Nostr event
|
||||
- EditMention: Nostr profile mention
|
||||
@@ -0,0 +1,101 @@
|
||||
# Feed Compiler
|
||||
|
||||
The `FeedCompiler` class is responsible for transforming feed definitions into executable relay requests. It handles the complex task of converting various feed types into optimized filters and relay selections.
|
||||
|
||||
## Overview
|
||||
|
||||
```typescript
|
||||
class FeedCompiler {
|
||||
constructor(readonly options: FeedOptions)
|
||||
|
||||
canCompile(feed: Feed): boolean
|
||||
compile(feed: Feed): Promise<RequestItem[]>
|
||||
}
|
||||
```
|
||||
|
||||
## Feed Compilation Process
|
||||
|
||||
The compiler transforms feed definitions into `RequestItem[]`, where each item contains:
|
||||
```typescript
|
||||
type RequestItem = {
|
||||
relays?: string[] // Specific relays to query
|
||||
filters?: Filter[] // Nostr filters to apply
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Feed Compilation
|
||||
```typescript
|
||||
const compiler = new FeedCompiler(options)
|
||||
|
||||
// Simple author feed
|
||||
const feed = [FeedType.Author, "pubkey1", "pubkey2"]
|
||||
const requests = await compiler.compile(feed)
|
||||
// => [{ filters: [{ authors: ["pubkey1", "pubkey2"] }] }]
|
||||
```
|
||||
|
||||
### Complex Feed Compilation
|
||||
```typescript
|
||||
// Complex feed with multiple operations
|
||||
const feed = [
|
||||
FeedType.Intersection,
|
||||
[FeedType.Kind, 1],
|
||||
[
|
||||
FeedType.Union,
|
||||
[FeedType.Scope, Scope.Follows],
|
||||
[FeedType.List, { addresses: ["trending"] }]
|
||||
]
|
||||
]
|
||||
|
||||
const requests = await compiler.compile(feed)
|
||||
// Compiles to optimized filters for relay queries
|
||||
```
|
||||
|
||||
### DVM Integration
|
||||
```typescript
|
||||
const feed = [
|
||||
FeedType.DVM,
|
||||
{
|
||||
kind: 5300,
|
||||
mappings: [
|
||||
["p", [FeedType.Author]],
|
||||
["t", [FeedType.Tag, "#t"]]
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const requests = await compiler.compile(feed)
|
||||
// Queries DVM and compiles resulting tags into feeds
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Optimization Strategies
|
||||
|
||||
1. **Filter Merging**: Similar filters are combined when possible
|
||||
```typescript
|
||||
// Before: [{ authors: ["a"] }, { authors: ["b"] }]
|
||||
// After: [{ authors: ["a", "b"] }]
|
||||
```
|
||||
|
||||
2. **Relay Grouping**: Requests are grouped by relay where possible
|
||||
```typescript
|
||||
// Filters are organized by relay to minimize connections
|
||||
filtersByRelay: Map<string, Filter[]>
|
||||
```
|
||||
|
||||
3. **Deduplication**: Duplicate values are removed using `uniq`
|
||||
```typescript
|
||||
uniq(scopes.flatMap(this.options.getPubkeysForScope))
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
The compiler includes various safety checks:
|
||||
```typescript
|
||||
canCompile(feed: Feed): boolean {
|
||||
// Checks if feed type is supported
|
||||
// Recursively checks sub-feeds
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,132 @@
|
||||
# Feed Controller
|
||||
|
||||
The `FeedController` class is responsible for managing and executing feed queries in a performant and organized manner. It handles the compilation of feed definitions into executable queries and manages the loading of events based on those queries.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { FeedController } from '@welshman/feeds'
|
||||
|
||||
const controller = new FeedController({
|
||||
feed: yourFeedDefinition,
|
||||
request: async ({ filters, relays, onEvent }) => {
|
||||
// Your implementation for fetching events
|
||||
},
|
||||
requestDVM: async ({ kind, tags, relays, onEvent }) => {
|
||||
// Your implementation for DVM requests
|
||||
},
|
||||
getPubkeysForScope: (scope) => {
|
||||
// Return pubkeys for given scope
|
||||
return ['pubkey1', 'pubkey2']
|
||||
},
|
||||
getPubkeysForWOTRange: (min, max) => {
|
||||
// Return pubkeys within WOT range
|
||||
return ['pubkey1', 'pubkey2']
|
||||
},
|
||||
onEvent: (event) => {
|
||||
// Handle received events
|
||||
},
|
||||
onExhausted: () => {
|
||||
// Called when no more events are available
|
||||
},
|
||||
useWindowing: true, // Optional: enable time-window based loading
|
||||
})
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
```typescript
|
||||
constructor(options: FeedOptions)
|
||||
```
|
||||
|
||||
Creates a new feed controller with the given options:
|
||||
- `feed`: The feed definition to execute
|
||||
- `request`: Function to fetch events from relays
|
||||
- `requestDVM`: Function to fetch events from DVMs
|
||||
- `getPubkeysForScope`: Function to get pubkeys for a scope
|
||||
- `getPubkeysForWOTRange`: Function to get pubkeys within a WOT range
|
||||
- `onEvent`: Optional callback for received events
|
||||
- `onExhausted`: Optional callback when feed is exhausted
|
||||
- `useWindowing`: Optional flag to enable time-window based loading
|
||||
|
||||
### Methods
|
||||
|
||||
#### `load(limit: number): Promise<void>`
|
||||
```typescript
|
||||
const controller = new FeedController(options)
|
||||
await controller.load(10) // Load 10 events
|
||||
```
|
||||
Loads events from the feed up to the specified limit.
|
||||
|
||||
#### `getLoader(): Promise<(limit: number) => Promise<void>>`
|
||||
Gets the loader function for this feed. Usually called internally by `load()`.
|
||||
|
||||
#### `getRequestItems(): Promise<RequestItem[] | undefined>`
|
||||
Gets the compiled request items for this feed. Usually called internally.
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Time Windowing
|
||||
|
||||
When `useWindowing` is enabled, the controller uses a time-based window approach to load events:
|
||||
|
||||
```typescript
|
||||
const controller = new FeedController({
|
||||
...options,
|
||||
useWindowing: true
|
||||
})
|
||||
```
|
||||
|
||||
This is useful for:
|
||||
- Loading recent events first
|
||||
- Handling large datasets efficiently
|
||||
- Progressive loading of historical data
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Loading
|
||||
|
||||
```typescript
|
||||
const controller = new FeedController(options)
|
||||
await controller.load(20) // Load 20 events
|
||||
```
|
||||
|
||||
### Custom Loading Strategy
|
||||
|
||||
```typescript
|
||||
const controller = new FeedController({
|
||||
...options,
|
||||
useWindowing: true,
|
||||
onEvent: (event) => {
|
||||
console.log('Received event:', event.id)
|
||||
},
|
||||
onExhausted: () => {
|
||||
console.log('No more events available')
|
||||
}
|
||||
})
|
||||
|
||||
// Load events in batches
|
||||
async function loadAllEvents() {
|
||||
while (!exhausted) {
|
||||
await controller.load(10)
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await controller.load(10)
|
||||
} catch (error) {
|
||||
if (error.message.includes('relay')) {
|
||||
// Handle relay errors
|
||||
} else {
|
||||
// Handle other errors
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,341 @@
|
||||
# Feed Types and Core Definitions
|
||||
|
||||
This module defines the core types and structures used to build Nostr feeds.
|
||||
It provides a type-safe way to define complex feed compositions using various filtering mechanisms and set operations.
|
||||
|
||||
## Feed Types
|
||||
|
||||
```typescript
|
||||
enum FeedType {
|
||||
Address = "address", // Filter by event addresses
|
||||
Author = "author", // Filter by author pubkeys
|
||||
CreatedAt = "created_at", // Filter by timestamp
|
||||
DVM = "dvm", // Data Vending Machine based feed
|
||||
Difference = "difference", // Set difference operation
|
||||
ID = "id", // Filter by event IDs
|
||||
Intersection = "intersection", // Set intersection operation
|
||||
Global = "global", // Global feed (no filters)
|
||||
Kind = "kind", // Filter by event kinds
|
||||
List = "list", // List-based feed
|
||||
Label = "label", // Label-based feed
|
||||
WOT = "wot", // Web of Trust based feed
|
||||
Relay = "relay", // Relay-specific feed
|
||||
Scope = "scope", // Scoped feed (followers, network)
|
||||
Search = "search", // Search-based feed
|
||||
Tag = "tag", // Filter by specific tags
|
||||
Union = "union" // Set union operation
|
||||
}
|
||||
```
|
||||
|
||||
## Scope Types
|
||||
|
||||
```typescript
|
||||
enum Scope {
|
||||
Followers = "followers", // People who follow the user
|
||||
Follows = "follows", // People the user follows
|
||||
Network = "network", // Extended network
|
||||
Self = "self" // The signed in user
|
||||
}
|
||||
```
|
||||
|
||||
## Feed Definitions
|
||||
|
||||
Each feed type has its own structure:
|
||||
|
||||
### Basic Filter Feeds
|
||||
|
||||
```typescript
|
||||
type AddressFeed = [type: FeedType.Address, ...addresses: string[]]
|
||||
type AuthorFeed = [type: FeedType.Author, ...pubkeys: string[]]
|
||||
type IDFeed = [type: FeedType.ID, ...ids: string[]]
|
||||
type KindFeed = [type: FeedType.Kind, ...kinds: number[]]
|
||||
type TagFeed = [type: FeedType.Tag, key: string, ...values: string[]]
|
||||
```
|
||||
|
||||
### Time-based Feeds
|
||||
|
||||
```typescript
|
||||
type CreatedAtItem = {
|
||||
since?: number
|
||||
until?: number
|
||||
relative?: string[] // For relative time references
|
||||
}
|
||||
type CreatedAtFeed = [type: FeedType.CreatedAt, ...items: CreatedAtItem[]]
|
||||
```
|
||||
|
||||
### Advanced Filter Feeds
|
||||
|
||||
```typescript
|
||||
// DVM-based feed
|
||||
type DVMItem = {
|
||||
kind: number
|
||||
tags?: string[][]
|
||||
relays?: string[]
|
||||
mappings?: TagFeedMapping[]
|
||||
}
|
||||
type DVMFeed = [type: FeedType.DVM, ...items: DVMItem[]]
|
||||
|
||||
// List-based feed
|
||||
type ListItem = {
|
||||
addresses: string[]
|
||||
mappings?: TagFeedMapping[]
|
||||
}
|
||||
type ListFeed = [type: FeedType.List, ...items: ListItem[]]
|
||||
|
||||
// Label-based feed
|
||||
type LabelItem = {
|
||||
relays?: string[]
|
||||
authors?: string[]
|
||||
[key: `#${string}`]: string[]
|
||||
mappings?: TagFeedMapping[]
|
||||
}
|
||||
type LabelFeed = [type: FeedType.Label, ...items: LabelItem[]]
|
||||
|
||||
// Web of Trust feed
|
||||
type WOTItem = {
|
||||
min?: number
|
||||
max?: number
|
||||
}
|
||||
type WOTFeed = [type: FeedType.WOT, ...items: WOTItem[]]
|
||||
```
|
||||
|
||||
## Tag Feed Mapping
|
||||
|
||||
`TagFeedMapping` is a mechanism to convert event tags into feed definitions. It's particularly useful when working with DVMs, Lists, and Labels where you want to interpret tags in a specific way.
|
||||
|
||||
```typescript
|
||||
type TagFeedMapping = [string, Feed]
|
||||
```
|
||||
|
||||
### Usage
|
||||
```typescript
|
||||
// Example mappings
|
||||
const mappings: TagFeedMapping[] = [
|
||||
// Convert 'p' tags into author feeds
|
||||
["p", [FeedType.Author]],
|
||||
|
||||
// Convert 't' tags into hashtag filters
|
||||
["t", [FeedType.Tag, "#t"]],
|
||||
|
||||
// Convert 'e' tags into event ID feeds
|
||||
["e", [FeedType.ID]],
|
||||
|
||||
// Convert 'r' tags into relay feeds
|
||||
["r", [FeedType.Relay]]
|
||||
]
|
||||
|
||||
// Using mappings in a DVM feed
|
||||
const dvmFeed: Feed = [
|
||||
FeedType.DVM,
|
||||
{
|
||||
kind: 5300,
|
||||
mappings: mappings
|
||||
}
|
||||
]
|
||||
|
||||
// Using mappings in a List feed
|
||||
const listFeed: Feed = [
|
||||
FeedType.List,
|
||||
{
|
||||
addresses: ["list_id"],
|
||||
mappings: mappings
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Default Mappings
|
||||
The system comes with default mappings for common tags:
|
||||
```typescript
|
||||
const defaultTagFeedMappings: TagFeedMapping[] = [
|
||||
["a", [FeedType.Address]], // Address references
|
||||
["e", [FeedType.ID]], // Event references
|
||||
["p", [FeedType.Author]], // Person/Pubkey references
|
||||
["r", [FeedType.Relay]], // Relay references
|
||||
["t", [FeedType.Tag, "#t"]], // Hashtags
|
||||
]
|
||||
```
|
||||
|
||||
## Set Operation Feeds
|
||||
|
||||
### Union Feed
|
||||
A Union feed combines multiple feeds with an OR operation. Events matching any of the constituent feeds will be included.
|
||||
|
||||
```typescript
|
||||
type UnionFeed = [type: FeedType.Union, ...feeds: Feed[]]
|
||||
|
||||
// Example: Events from either Alice OR Bob
|
||||
const unionFeed: UnionFeed = [
|
||||
FeedType.Union,
|
||||
[FeedType.Author, "alice_pubkey"],
|
||||
[FeedType.Author, "bob_pubkey"]
|
||||
]
|
||||
|
||||
// Example: Events from a list OR matching a search term
|
||||
const complexUnion: UnionFeed = [
|
||||
FeedType.Union,
|
||||
[FeedType.List, { addresses: ["trending_list"] }],
|
||||
[FeedType.Search, "bitcoin"]
|
||||
]
|
||||
```
|
||||
|
||||
### Intersection Feed
|
||||
An Intersection feed combines multiple feeds with an AND operation. Only events that match all constituent feeds will be included.
|
||||
|
||||
```typescript
|
||||
type IntersectionFeed = [type: FeedType.Intersection, ...feeds: Feed[]]
|
||||
|
||||
// Example: Text notes (kind 1) from trusted authors
|
||||
const intersectionFeed: IntersectionFeed = [
|
||||
FeedType.Intersection,
|
||||
[FeedType.Kind, 1],
|
||||
[FeedType.WOT, { min: 0.5 }]
|
||||
]
|
||||
|
||||
// Example: Recent posts from followed users
|
||||
const timeAndScope: IntersectionFeed = [
|
||||
FeedType.Intersection,
|
||||
[FeedType.CreatedAt, { since: Date.now() - 86400000 }], // Last 24h
|
||||
[FeedType.Scope, Scope.Follows]
|
||||
]
|
||||
```
|
||||
|
||||
### Difference Feed
|
||||
A Difference feed excludes events from the second feed from the first feed (NOT operation).
|
||||
|
||||
```typescript
|
||||
type DifferenceFeed = [type: FeedType.Difference, ...feeds: Feed[]]
|
||||
|
||||
// Example: Posts from everyone except blocked users
|
||||
const differenceFeed: DifferenceFeed = [
|
||||
FeedType.Difference,
|
||||
[FeedType.Global], // All events
|
||||
[FeedType.List, { addresses: ["blocked_users"] }] // Except from blocked users
|
||||
]
|
||||
|
||||
// Example: Posts from follows except reposts
|
||||
const noReposts: DifferenceFeed = [
|
||||
FeedType.Difference,
|
||||
[FeedType.Scope, Scope.Follows],
|
||||
[FeedType.Kind, 6] // Kind 6 is repost
|
||||
]
|
||||
```
|
||||
|
||||
### Complex Combinations
|
||||
|
||||
You can nest set operations to create sophisticated feed definitions:
|
||||
|
||||
```typescript
|
||||
// Posts that are either:
|
||||
// - from trusted authors AND about bitcoin
|
||||
// - OR from a curated list
|
||||
const complexFeed: Feed = [
|
||||
FeedType.Union,
|
||||
[
|
||||
FeedType.Intersection,
|
||||
[FeedType.WOT, { min: 0.7 }],
|
||||
[FeedType.Search, "bitcoin"]
|
||||
],
|
||||
[FeedType.List, { addresses: ["curated_content"] }]
|
||||
]
|
||||
|
||||
// Posts that are:
|
||||
// - from follows
|
||||
// - AND (from the last 24h OR highly rated by DVMs)
|
||||
// - AND NOT marked as sensitive content
|
||||
const advancedFeed: Feed = [
|
||||
FeedType.Difference,
|
||||
[
|
||||
FeedType.Intersection,
|
||||
[FeedType.Scope, Scope.Follows],
|
||||
[
|
||||
FeedType.Union,
|
||||
[FeedType.CreatedAt, { since: Date.now() - 86400000 }],
|
||||
[FeedType.DVM, { kind: 5300, pubkey: "rating_dvm" }]
|
||||
]
|
||||
],
|
||||
[FeedType.Label, { authors: ["content_warning_dvm"] }]
|
||||
]
|
||||
```
|
||||
|
||||
## Feed Controller Options
|
||||
|
||||
The `FeedOptions` interface defines the configuration required to execute a feed:
|
||||
|
||||
```typescript
|
||||
interface FeedOptions {
|
||||
// The feed definition to execute
|
||||
feed: Feed
|
||||
|
||||
// Function to request events from relays
|
||||
request: (opts: RequestOpts) => Promise<void>
|
||||
|
||||
// Function to request events from DVMs
|
||||
requestDVM: (opts: DVMOpts) => Promise<void>
|
||||
|
||||
// Function to get pubkeys for a given scope
|
||||
getPubkeysForScope: (scope: Scope) => string[]
|
||||
|
||||
// Function to get pubkeys within a WOT range
|
||||
getPubkeysForWOTRange: (minWOT: number, maxWOT: number) => string[]
|
||||
|
||||
// Event handler
|
||||
onEvent?: (event: TrustedEvent) => void
|
||||
|
||||
// Called when feed is exhausted
|
||||
onExhausted?: () => void
|
||||
|
||||
// Enable time-window based loading
|
||||
useWindowing?: boolean
|
||||
|
||||
// Optional abort controller
|
||||
abortController?: AbortController
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Simple Author Feed
|
||||
```typescript
|
||||
const authorFeed: Feed = [FeedType.Author, "pubkey1", "pubkey2"]
|
||||
```
|
||||
|
||||
### Time-filtered Feed
|
||||
```typescript
|
||||
const recentFeed: Feed = [
|
||||
FeedType.CreatedAt,
|
||||
{
|
||||
since: Date.now() - 24 * 60 * 60 * 1000, // Last 24 hours
|
||||
relative: ["since"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Complex Feed Composition
|
||||
```typescript
|
||||
const complexFeed: Feed = [
|
||||
FeedType.Intersection,
|
||||
[FeedType.Kind, 1], // Text notes
|
||||
[FeedType.WOT, { min: 0.5 }], // Trusted authors
|
||||
[
|
||||
FeedType.Union,
|
||||
[FeedType.Scope, Scope.Follows], // From follows
|
||||
[FeedType.List, { addresses: ["list_id"] }] // Or from list
|
||||
]
|
||||
]
|
||||
```
|
||||
|
||||
### DVM Feed with Mappings
|
||||
```typescript
|
||||
const dvmFeed: Feed = [
|
||||
FeedType.DVM,
|
||||
{
|
||||
kind: 5300,
|
||||
mappings: [
|
||||
["p", [FeedType.Author]], // Map 'p' tags to authors
|
||||
["t", [FeedType.Tag, "#t"]] // Map 't' tags to hashtags
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
This core module provides the foundation for building complex, type-safe feed definitions that can be executed by the [feed controller](/feeds/controller).
|
||||
@@ -0,0 +1,19 @@
|
||||
# @welshman/feeds
|
||||
|
||||
A powerful package for building and executing dynamic Nostr feeds.
|
||||
It provides a declarative way to define complex feed compositions using set operations (union, intersection, difference) and various filtering mechanisms.
|
||||
|
||||
## What's Included
|
||||
|
||||
- **Feed Core** - Declarative feed definition with composable operations
|
||||
- **Feed Compiler** - Transforms feed definitions into optimized relay requests
|
||||
- **Feed Controller** - Manages feed execution and event loading
|
||||
- **Feed Utils** - Helper functions for creating and manipulating feeds
|
||||
- **Feed Types** - Supports authors, kinds, tags, DVMs, lists, WOT, and more
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @welshman/feeds
|
||||
```
|
||||
@@ -0,0 +1,175 @@
|
||||
# Feed Utilities
|
||||
|
||||
The utils module provides helper functions for creating, type-checking, and manipulating feed definitions. It includes factory functions, type guards, feed transformation utilities, and feed traversal tools.
|
||||
|
||||
## Feed Factory Functions
|
||||
|
||||
Create strongly-typed feed definitions:
|
||||
|
||||
```typescript
|
||||
// Basic Feeds
|
||||
const authors = makeAuthorFeed("pubkey1", "pubkey2")
|
||||
const kinds = makeKindFeed(1, 6)
|
||||
const search = makeSearchFeed("bitcoin", "nostr")
|
||||
const global = makeGlobalFeed()
|
||||
|
||||
// Time-based Feeds
|
||||
const recent = makeCreatedAtFeed({
|
||||
since: Date.now() - 86400000,
|
||||
relative: ["since"]
|
||||
})
|
||||
|
||||
// Advanced Feeds
|
||||
const dvm = makeDVMFeed({
|
||||
kind: 5300,
|
||||
mappings: [["p", [FeedType.Author]]]
|
||||
})
|
||||
|
||||
const list = makeListFeed({
|
||||
addresses: ["list_id"],
|
||||
mappings: [["t", [FeedType.Tag, "#t"]]]
|
||||
})
|
||||
|
||||
// Set Operations
|
||||
const union = makeUnionFeed(authors, kinds)
|
||||
const intersection = makeIntersectionFeed(authors, recent)
|
||||
const difference = makeDifferenceFeed(global, authors)
|
||||
```
|
||||
|
||||
## Type Guards
|
||||
|
||||
Check feed types safely:
|
||||
|
||||
```typescript
|
||||
const feed: Feed = makeDVMFeed({ kind: 5300 })
|
||||
|
||||
if (isDVMFeed(feed)) {
|
||||
// feed is now typed as DVMFeed
|
||||
const [kind] = feed.slice(1)
|
||||
}
|
||||
|
||||
if (hasSubFeeds(feed)) {
|
||||
// feed is now typed as UnionFeed | IntersectionFeed | DifferenceFeed
|
||||
const subFeeds = getFeedArgs(feed)
|
||||
}
|
||||
```
|
||||
|
||||
## Feed Transformations
|
||||
|
||||
### Tag to Feed Conversion
|
||||
|
||||
```typescript
|
||||
// Default tag mappings
|
||||
const defaultTagFeedMappings: TagFeedMapping[] = [
|
||||
["a", [FeedType.Address]], // address tags
|
||||
["e", [FeedType.ID]], // event references
|
||||
["p", [FeedType.Author]], // people/pubkeys
|
||||
["r", [FeedType.Relay]], // relay URLs
|
||||
["t", [FeedType.Tag, "#t"]], // hashtags
|
||||
]
|
||||
|
||||
// Convert event tags to feeds
|
||||
const tags = [["p", "pubkey1"], ["t", "bitcoin"]]
|
||||
const feeds = feedsFromTags(tags)
|
||||
// => [[FeedType.Author, "pubkey1"], [FeedType.Tag, "#t", "bitcoin"]]
|
||||
|
||||
// Convert tags to a single intersection feed
|
||||
const feed = feedFromTags(tags)
|
||||
// => [FeedType.Intersection, [FeedType.Author, "pubkey1"], [FeedType.Tag, "#t", "bitcoin"]]
|
||||
```
|
||||
|
||||
### Filter to Feed Conversion
|
||||
|
||||
```typescript
|
||||
// Convert a single filter to feeds
|
||||
const filter = {
|
||||
kinds: [1],
|
||||
authors: ["pubkey1"],
|
||||
"#t": ["bitcoin"],
|
||||
since: 1234567890
|
||||
}
|
||||
|
||||
const feeds = feedsFromFilter(filter)
|
||||
// => [
|
||||
// [FeedType.CreatedAt, { since: 1234567890 }],
|
||||
// [FeedType.Kind, 1],
|
||||
// [FeedType.Author, "pubkey1"],
|
||||
// [FeedType.Tag, "#t", "bitcoin"]
|
||||
// ]
|
||||
|
||||
// Convert a filter to an intersection feed
|
||||
const feed = feedFromFilter(filter)
|
||||
|
||||
// Convert multiple filters to a union feed
|
||||
const feeds = feedFromFilters([filter1, filter2])
|
||||
```
|
||||
|
||||
## Feed Traversal
|
||||
|
||||
Walk through a feed tree and visit each node:
|
||||
|
||||
```typescript
|
||||
const feed = makeIntersectionFeed(
|
||||
makeAuthorFeed("pubkey1"),
|
||||
makeUnionFeed(
|
||||
makeKindFeed(1),
|
||||
makeTagFeed("#t", "bitcoin")
|
||||
)
|
||||
)
|
||||
|
||||
walkFeed(feed, (node) => {
|
||||
console.log(`Visiting feed of type: ${node[0]}`)
|
||||
})
|
||||
```
|
||||
|
||||
## Type Extraction
|
||||
|
||||
Get typed arguments from feeds:
|
||||
|
||||
```typescript
|
||||
function getFeedArgs(feed: IntersectionFeed): Feed[]
|
||||
function getFeedArgs(feed: AuthorFeed): string[]
|
||||
function getFeedArgs(feed: CreatedAtFeed): CreatedAtItem[]
|
||||
function getFeedArgs(feed: WOTFeed): WOTItem[]
|
||||
// ... and so on for each feed type
|
||||
|
||||
const feed = makeAuthorFeed("pubkey1", "pubkey2")
|
||||
const pubkeys = getFeedArgs(feed) // => ["pubkey1", "pubkey2"]
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Use factory functions instead of raw arrays:
|
||||
```typescript
|
||||
// Good
|
||||
const feed = makeAuthorFeed("pubkey1")
|
||||
|
||||
// Avoid
|
||||
const feed = [FeedType.Author, "pubkey1"]
|
||||
```
|
||||
|
||||
2. Use type guards for safe type narrowing:
|
||||
```typescript
|
||||
if (isAuthorFeed(feed)) {
|
||||
const pubkeys = getFeedArgs(feed) // Properly typed
|
||||
}
|
||||
```
|
||||
|
||||
3. Use feed transformations for dynamic feed creation:
|
||||
```typescript
|
||||
// Convert event tags to feeds
|
||||
const feeds = feedsFromTags(event.tags)
|
||||
|
||||
// Convert filters to feeds
|
||||
const feed = feedFromFilter(filter)
|
||||
```
|
||||
|
||||
4. Use feed traversal for analysis or transformation:
|
||||
```typescript
|
||||
const kinds = new Set<number>()
|
||||
walkFeed(feed, (node) => {
|
||||
if (isKindFeed(node)) {
|
||||
getFeedArgs(node).forEach(k => kinds.add(k))
|
||||
}
|
||||
})
|
||||
```
|
||||
@@ -0,0 +1,57 @@
|
||||
# Getting Started
|
||||
|
||||
Welshman is modular - install only what you need:
|
||||
|
||||
|
||||
```bash
|
||||
# Core nostr utilities (events, filters, tags)
|
||||
npm i @welshman/util
|
||||
|
||||
# Networking and relay management
|
||||
npm i @welshman/net
|
||||
|
||||
# Content parsing and rendering
|
||||
npm i @welshman/content
|
||||
|
||||
# Event signing and encryption
|
||||
npm i @welshman/signer
|
||||
|
||||
# Dynamic feed compilation
|
||||
npm i @welshman/feeds
|
||||
```
|
||||
|
||||
For Svelte applications, additional packages provide reactive state management:
|
||||
|
||||
```bash
|
||||
# Svelte stores and state management
|
||||
npm i @welshman/store
|
||||
|
||||
# Full application framework (requires Svelte)
|
||||
npm i @welshman/app
|
||||
|
||||
# Rich text editor component (requires Svelte)
|
||||
npm i @welshman/editor
|
||||
```
|
||||
|
||||
Choose packages based on your needs:
|
||||
|
||||
- Building a framework-agnostic client? Start with:
|
||||
```bash
|
||||
npm i @welshman/util @welshman/net @welshman/signer @welshman/feeds
|
||||
```
|
||||
|
||||
- Building a Svelte client? Add state management:
|
||||
```bash
|
||||
npm i @welshman/store @welshman/app
|
||||
```
|
||||
|
||||
- Need content features? Include:
|
||||
```bash
|
||||
npm i @welshman/content
|
||||
```
|
||||
|
||||
- Want the full Svelte stack used by Coracle.social and Flotilla?
|
||||
```bash
|
||||
npm i @welshman/util @welshman/net @welshman/signer @welshman/feeds @welshman/store @welshman/app @welshman/content @welshman/editor
|
||||
```
|
||||
Each package is independent but integrates seamlessly. The core packages (`util`, `net`, `signer`, `feeds`, `content`) work with any framework, while `store`, `app` and `editor` are built for Svelte applications.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
# https://vitepress.dev/reference/default-theme-home-page
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: "Welshman"
|
||||
text: Nostr building blocks for the web
|
||||
tagline: A series of independent libraries for managing every aspect of your Nostr application.
|
||||
actions:
|
||||
- theme: brand
|
||||
text: What is Welshman?
|
||||
link: /what-is-welshman
|
||||
- theme: alt
|
||||
text: Quickstart
|
||||
link: /getting-started
|
||||
- theme: alt
|
||||
text: Github
|
||||
link: https://github.com/coracle-social/welshman
|
||||
|
||||
features:
|
||||
- title: "@welshman/content"
|
||||
details: Parser and renderer for nostr note with customizable formatting options.
|
||||
link: "/content"
|
||||
- title: "@welshman/dvm"
|
||||
details: Tools for building and interacting with nostr Data Vending Machines (DVMs)
|
||||
link: "/dvm"
|
||||
- title: "@welshman/editor"
|
||||
details: Rich text editor component with support for mentions and embeds.
|
||||
link: "/editor"
|
||||
- title: "@welshman/feeds"
|
||||
details: Dynamic feed compiler and loader with filtering and composition.
|
||||
link: "/feeds"
|
||||
- title: "@welshman/util"
|
||||
details: Core Nostr utilities for events, filters, and data structures.
|
||||
link: "/util"
|
||||
- title: "@welshman/net"
|
||||
details: Networking layer for Nostr with relay connection management and message status handling.
|
||||
link: "/net"
|
||||
- title: "@welshman/signer"
|
||||
details: Implementations of various nostr signing methods (NIP-01, NIP-07, NIP-46, NIP-55).
|
||||
link: "/signer"
|
||||
- title: "@welshman/store"
|
||||
details: Svelte store utilities optimized for nostr state management.
|
||||
link: "/store"
|
||||
---
|
||||
@@ -0,0 +1,141 @@
|
||||
# Deferred Promises
|
||||
|
||||
The Deferred module provides utilities for creating promises with exposed resolve/reject functions and typed error handling. This is particularly useful for managing asynchronous operations where you need external control over promise resolution.
|
||||
|
||||
## Types
|
||||
|
||||
### CustomPromise
|
||||
```typescript
|
||||
type CustomPromise<T, E> = Promise<T> & {
|
||||
__errorType: E
|
||||
}
|
||||
```
|
||||
A Promise type with strongly typed error information.
|
||||
|
||||
### Deferred
|
||||
```typescript
|
||||
type Deferred<T, E = T> = CustomPromise<T, E> & {
|
||||
resolve: (arg: T) => void
|
||||
reject: (arg: E) => void
|
||||
}
|
||||
```
|
||||
A Promise with exposed resolve/reject functions and typed error handling.
|
||||
|
||||
## Core Functions
|
||||
|
||||
### makePromise
|
||||
```typescript
|
||||
function makePromise<T, E>(
|
||||
executor: (
|
||||
resolve: (value: T | PromiseLike<T>) => void,
|
||||
reject: (reason: E) => void
|
||||
) => void
|
||||
): CustomPromise<T, E>
|
||||
```
|
||||
|
||||
Creates a Promise with strongly typed error information.
|
||||
|
||||
### defer
|
||||
```typescript
|
||||
function defer<T, E = T>(): Deferred<T, E>
|
||||
```
|
||||
|
||||
Creates a Deferred promise with resolve/reject methods exposed.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
// Create a deferred promise
|
||||
const deferred = defer<string, Error>()
|
||||
|
||||
// Resolve later
|
||||
setTimeout(() => {
|
||||
deferred.resolve('Success!')
|
||||
}, 1000)
|
||||
|
||||
// Use like a regular promise
|
||||
await deferred // => 'Success!'
|
||||
```
|
||||
|
||||
### With Typed Errors
|
||||
|
||||
```typescript
|
||||
interface ApiError {
|
||||
code: number
|
||||
message: string
|
||||
}
|
||||
|
||||
const request = defer<Response, ApiError>()
|
||||
|
||||
try {
|
||||
const response = await fetch('/api')
|
||||
request.resolve(response)
|
||||
} catch (error) {
|
||||
request.reject({
|
||||
code: 500,
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### External Promise Control
|
||||
|
||||
```typescript
|
||||
class AsyncOperation {
|
||||
private ready = defer<boolean>()
|
||||
|
||||
initialize() {
|
||||
// Setup async operation
|
||||
this.ready.resolve(true)
|
||||
}
|
||||
|
||||
async waitUntilReady() {
|
||||
return this.ready
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With Timeout
|
||||
|
||||
```typescript
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
const timeout = defer<T>()
|
||||
|
||||
setTimeout(() => {
|
||||
timeout.reject(new Error('Timeout'))
|
||||
}, ms)
|
||||
|
||||
return Promise.race([promise, timeout])
|
||||
}
|
||||
|
||||
// Usage
|
||||
try {
|
||||
const result = await withTimeout(slowOperation(), 5000)
|
||||
} catch (error) {
|
||||
console.log('Operation timed out')
|
||||
}
|
||||
```
|
||||
|
||||
### Event to Promise
|
||||
|
||||
```typescript
|
||||
function eventToPromise<T>(
|
||||
emitter: EventEmitter,
|
||||
successEvent: string,
|
||||
errorEvent: string
|
||||
): Deferred<T, Error> {
|
||||
const deferred = defer<T, Error>()
|
||||
|
||||
emitter.once(successEvent, (data: T) => {
|
||||
deferred.resolve(data)
|
||||
})
|
||||
|
||||
emitter.once(errorEvent, (error: Error) => {
|
||||
deferred.reject(error)
|
||||
})
|
||||
|
||||
return deferred
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# @welshman/lib
|
||||
|
||||
A lightweight TypeScript utility library with zero dependencies, providing essential tools for modern JavaScript development.
|
||||
|
||||
## What's Included
|
||||
|
||||
- **Deferred Promises** - Create promises with exposed resolve/reject methods
|
||||
- **LRU Cache** - Efficient caching with automatic eviction policies
|
||||
- **Utility Functions** - Helpers for arrays, objects, strings, and more
|
||||
- **Worker Queue** - Process tasks asynchronously with batching and throttling
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @welshman/lib
|
||||
```
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
# LRU Cache
|
||||
|
||||
The LRU (Least Recently Used) Cache implementation provides efficient caching with automatic eviction of least recently used items when the cache reaches its maximum size.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
// Create cache with max size
|
||||
const cache = new LRUCache<string, number>(3)
|
||||
|
||||
// Add items
|
||||
cache.set('a', 1)
|
||||
cache.set('b', 2)
|
||||
cache.set('c', 3)
|
||||
|
||||
// Access items
|
||||
cache.get('a') // => 1
|
||||
|
||||
// Check if key exists
|
||||
cache.has('b') // => true
|
||||
|
||||
// Adding beyond max size evicts least recently used
|
||||
cache.set('d', 4) // Evicts oldest item
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
```typescript
|
||||
constructor(maxSize: number = Infinity)
|
||||
```
|
||||
|
||||
Creates a new LRU cache with specified maximum size.
|
||||
|
||||
### Methods
|
||||
|
||||
#### set(key: T, value: U)
|
||||
```typescript
|
||||
set(key: T, value: U): void
|
||||
```
|
||||
Adds or updates an item in the cache. If cache is at maximum size, evicts least recently used item.
|
||||
|
||||
#### get(key: T)
|
||||
```typescript
|
||||
get(key: T): U | undefined
|
||||
```
|
||||
Retrieves item from cache. Also marks item as recently used.
|
||||
|
||||
#### has(key: T)
|
||||
```typescript
|
||||
has(key: T): boolean
|
||||
```
|
||||
Checks if key exists in cache without affecting usage tracking.
|
||||
|
||||
## Cache Decorator
|
||||
|
||||
The package also provides a convenient decorator function for creating memoized functions with LRU caching:
|
||||
|
||||
```typescript
|
||||
function cached<T, V, Args extends any[]>({
|
||||
maxSize,
|
||||
getKey,
|
||||
getValue,
|
||||
}: {
|
||||
maxSize: number
|
||||
getKey: (args: Args) => T
|
||||
getValue: (args: Args) => V
|
||||
}): (...args: Args) => V
|
||||
```
|
||||
|
||||
### Usage Example
|
||||
|
||||
```typescript
|
||||
// Create cached function
|
||||
const getUser = cached({
|
||||
maxSize: 1000,
|
||||
getKey: (args) => args[0], // Use first argument as cache key
|
||||
getValue: async (args) => {
|
||||
const [id] = args
|
||||
return await fetchUser(id)
|
||||
}
|
||||
})
|
||||
|
||||
// Use cached function
|
||||
const user1 = await getUser(123)
|
||||
const user2 = await getUser(123) // Returns cached result
|
||||
```
|
||||
|
||||
### Simple Cache Helper
|
||||
|
||||
For basic caching needs, there's also a simplified cache creator:
|
||||
|
||||
```typescript
|
||||
function simpleCache<V, Args extends any[]>(
|
||||
getValue: (args: Args) => V
|
||||
) {
|
||||
return cached({
|
||||
maxSize: 100000,
|
||||
getKey: xs => xs.join(':'),
|
||||
getValue
|
||||
})
|
||||
}
|
||||
|
||||
// Usage
|
||||
const cachedFn = simpleCache(async (id: string) => {
|
||||
return await expensiveOperation(id)
|
||||
})
|
||||
```
|
||||
@@ -0,0 +1,256 @@
|
||||
# Utility Functions
|
||||
|
||||
The `Tools.ts` module provides a comprehensive collection of utility functions for common programming tasks. It includes functions for array manipulation, object handling, type checking, math operations, and more.
|
||||
|
||||
## Types
|
||||
|
||||
```typescript
|
||||
type Nil = null | undefined
|
||||
type Maybe<T> = T | undefined
|
||||
type Obj<T = any> = Record<string, T>
|
||||
```
|
||||
|
||||
## Categories
|
||||
|
||||
### Type Checking & Basic Operations
|
||||
|
||||
```typescript
|
||||
// Check if value is null or undefined
|
||||
isNil(x: any): boolean
|
||||
|
||||
// Execute function if value exists
|
||||
ifLet<T>(x: T | undefined, f: (x: T) => void)
|
||||
|
||||
// Return value unchanged
|
||||
identity<T>(x: T): T
|
||||
|
||||
// Create function that always returns same value
|
||||
always<T>(x: T): () => T
|
||||
|
||||
// Logical NOT
|
||||
not(x: any): boolean
|
||||
|
||||
// Create complement of a predicate function
|
||||
complement<T extends unknown[]>(f: (...args: T) => any): (...args: T) => boolean
|
||||
```
|
||||
|
||||
### Array Operations
|
||||
|
||||
```typescript
|
||||
// Get first element
|
||||
first<T>(xs: T[]): T | undefined
|
||||
|
||||
// Get first element of first array
|
||||
ffirst<T>(xs: T[][]): T | undefined
|
||||
|
||||
// Get last element
|
||||
last<T>(xs: T[]): T | undefined
|
||||
|
||||
// Drop first n elements
|
||||
drop<T>(n: number, xs: T[]): T[]
|
||||
|
||||
// Take first n elements
|
||||
take<T>(n: number, xs: T[]): T[]
|
||||
|
||||
// Remove duplicates
|
||||
uniq<T>(xs: T[]): T[]
|
||||
|
||||
// Remove duplicates by key function
|
||||
uniqBy<T>(f: (x: T) => any, xs: T[]): T[]
|
||||
|
||||
// Create array of n items using generator function
|
||||
initArray<T>(n: number, f: () => T): T[]
|
||||
|
||||
// Split array into chunks
|
||||
chunk<T>(chunkLength: number, xs: T[]): T[][]
|
||||
|
||||
// Split array into n chunks
|
||||
chunks<T>(n: number, xs: T[]): T[][]
|
||||
```
|
||||
|
||||
### Object Operations
|
||||
|
||||
```typescript
|
||||
// Create object excluding specified keys
|
||||
omit<T extends Obj>(ks: string[], x: T): T
|
||||
|
||||
// Create object excluding entries with specified values
|
||||
omitVals<T extends Obj>(xs: any[], x: T): T
|
||||
|
||||
// Create object with only specified keys
|
||||
pick<T extends Obj>(ks: string[], x: T): T
|
||||
|
||||
// Transform object keys
|
||||
mapKeys<T extends Obj>(f: (v: string) => string, x: T): T
|
||||
|
||||
// Transform object values
|
||||
mapVals<V, U>(f: (v: V) => U, x: Record<string, V>): Record<string, U>
|
||||
|
||||
// Merge objects (left priority)
|
||||
mergeLeft<T extends Obj>(a: T, b: T): T
|
||||
|
||||
// Merge objects (right priority)
|
||||
mergeRight<T extends Obj>(a: T, b: T): T
|
||||
|
||||
// Deep merge objects
|
||||
deepMergeLeft(a: Obj, b: Obj): Obj
|
||||
deepMergeRight(a: Obj, b: Obj): Obj
|
||||
```
|
||||
|
||||
### Number Operations
|
||||
|
||||
```typescript
|
||||
// Convert Maybe<number> to number
|
||||
num(x: Maybe<number>): number
|
||||
|
||||
// Basic arithmetic with Maybe<number>
|
||||
add(x: Maybe<number>, y: Maybe<number>): number
|
||||
sub(x: Maybe<number>, y: Maybe<number>): number
|
||||
mul(x: Maybe<number>, y: Maybe<number>): number
|
||||
div(x: Maybe<number>, y: number): number
|
||||
|
||||
// Increment/Decrement
|
||||
inc(x: Maybe<number>): number
|
||||
dec(x: Maybe<number>): number
|
||||
|
||||
// Comparisons
|
||||
lt(x: Maybe<number>, y: Maybe<number>): boolean
|
||||
lte(x: Maybe<number>, y: Maybe<number>): boolean
|
||||
gt(x: Maybe<number>, y: Maybe<number>): boolean
|
||||
gte(x: Maybe<number>, y: Maybe<number>): boolean
|
||||
|
||||
// Array number operations
|
||||
max(xs: Maybe<number>[]): number
|
||||
min(xs: Maybe<number>[]): number
|
||||
sum(xs: Maybe<number>[]): number
|
||||
avg(xs: Maybe<number>[]): number
|
||||
```
|
||||
|
||||
### String Operations
|
||||
|
||||
```typescript
|
||||
// Truncate string with ellipsis
|
||||
ellipsize(s: string, l: number, suffix = "..."): string
|
||||
|
||||
// URL operations
|
||||
stripProtocol(url: string): string
|
||||
displayUrl(url: string): string
|
||||
displayDomain(url: string): string
|
||||
|
||||
// Bech32 encoding/decoding
|
||||
hexToBech32(prefix: string, hex: string): string
|
||||
bech32ToHex(b32: string): string
|
||||
```
|
||||
|
||||
### Collection Operations
|
||||
|
||||
```typescript
|
||||
// Create union of arrays
|
||||
union<T>(a: T[], b: T[]): T[]
|
||||
|
||||
// Get intersection of arrays
|
||||
intersection<T>(a: T[], b: T[]): T[]
|
||||
|
||||
// Get difference of arrays
|
||||
difference<T>(a: T[], b: T[]): T[]
|
||||
|
||||
// Remove element from array
|
||||
remove<T>(a: T, xs: T[]): T[]
|
||||
|
||||
// Filter array by another array
|
||||
without<T>(a: T[], b: T[]): T[]
|
||||
|
||||
// Toggle element in array
|
||||
toggle<T>(x: T, xs: T[]): T[]
|
||||
|
||||
// Group array by key function
|
||||
groupBy<T, K>(f: (x: T) => K, xs: T[]): Map<K, T[]>
|
||||
|
||||
// Create map from array
|
||||
indexBy<T, K>(f: (x: T) => K, xs: T[]): Map<K, T>
|
||||
```
|
||||
|
||||
### Time Constants
|
||||
|
||||
```typescript
|
||||
const MINUTE = 60
|
||||
const HOUR = 60 * MINUTE
|
||||
const DAY = 24 * HOUR
|
||||
const WEEK = 7 * DAY
|
||||
const MONTH = 30 * DAY
|
||||
const QUARTER = 90 * DAY
|
||||
const YEAR = 365 * DAY
|
||||
|
||||
// Get current timestamp in seconds
|
||||
now(): number
|
||||
|
||||
// Get timestamp from ago in seconds
|
||||
ago(unit: number, count = 1): number
|
||||
|
||||
// Convert seconds to milliseconds
|
||||
ms(seconds: number): number
|
||||
```
|
||||
|
||||
### Function Utilities
|
||||
|
||||
```typescript
|
||||
// Create function that executes once
|
||||
once(f: (...args: any) => void): (...args: any) => void
|
||||
|
||||
// Memoize function results
|
||||
memoize<T>(f: (...args: any[]) => T): (...args: any[]) => T
|
||||
|
||||
// Create throttled function
|
||||
throttle<F extends (...args: any[]) => any>(
|
||||
ms: number,
|
||||
f: F
|
||||
): F
|
||||
|
||||
// Create batching function
|
||||
batch<T>(
|
||||
t: number,
|
||||
f: (xs: T[]) => void
|
||||
): (x: T) => void
|
||||
```
|
||||
|
||||
### Network Utilities
|
||||
|
||||
```typescript
|
||||
// Fetch JSON with options
|
||||
fetchJson(url: string, opts?: FetchOpts): Promise<any>
|
||||
|
||||
// Post JSON data
|
||||
postJson<T>(url: string, data: T, opts?: FetchOpts): Promise<any>
|
||||
|
||||
// Upload file
|
||||
uploadFile(url: string, file: File): Promise<any>
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```typescript
|
||||
// Array operations
|
||||
const nums = [1, 2, 2, 3, 3, 3]
|
||||
uniq(nums) // => [1, 2, 3]
|
||||
|
||||
// Object operations
|
||||
const obj = {a: 1, b: 2, c: 3}
|
||||
omit(['a', 'b'], obj) // => {c: 3}
|
||||
|
||||
// Number operations
|
||||
add(5, undefined) // => 5
|
||||
inc(undefined) // => 1
|
||||
|
||||
// Time operations
|
||||
ago(DAY, 2) // => timestamp from 2 days ago
|
||||
|
||||
// URL operations
|
||||
displayUrl('https://www.example.com/') // => 'example.com'
|
||||
|
||||
// Collection operations
|
||||
const users = [{id: 1, name: 'Alice'}, {id: 2, name: 'Bob'}]
|
||||
indexBy(u => u.id, users) // => Map(1 => {id: 1, name: 'Alice'}, ...)
|
||||
|
||||
// Function utilities
|
||||
const throttledFn = throttle(1000, () => console.log('throttled'))
|
||||
```
|
||||
@@ -0,0 +1,117 @@
|
||||
# Worker
|
||||
|
||||
The Worker class provides a robust queue processing system with batched operations, throttling, and message routing capabilities. It's designed to handle asynchronous operations efficiently while maintaining control over processing rates and resource usage.
|
||||
|
||||
## Overview
|
||||
|
||||
```typescript
|
||||
class Worker<T> {
|
||||
constructor(readonly opts: WorkerOpts<T> = {})
|
||||
}
|
||||
```
|
||||
|
||||
The Worker class accepts messages of type `T` and processes them according to configured options and handlers.
|
||||
|
||||
## Configuration
|
||||
|
||||
```typescript
|
||||
type WorkerOpts<T> = {
|
||||
// Function to determine routing key for messages
|
||||
getKey?: (x: T) => any
|
||||
|
||||
// Function to check if message should be deferred
|
||||
shouldDefer?: (x: T) => boolean
|
||||
|
||||
// Maximum messages to process in one batch
|
||||
chunkSize?: number // default: 50
|
||||
|
||||
// Milliseconds between processing batches
|
||||
delay?: number // default: 50
|
||||
}
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
// Create worker for processing messages
|
||||
const worker = new Worker<Message>({
|
||||
chunkSize: 10,
|
||||
delay: 100,
|
||||
getKey: msg => msg.type
|
||||
})
|
||||
|
||||
// Add message handlers
|
||||
worker.addHandler('email', async (msg) => {
|
||||
await sendEmail(msg)
|
||||
})
|
||||
|
||||
worker.addHandler('notification', async (msg) => {
|
||||
await sendNotification(msg)
|
||||
})
|
||||
|
||||
// Add messages to queue
|
||||
worker.push({
|
||||
type: 'email',
|
||||
content: 'Hello'
|
||||
})
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Message Routing
|
||||
|
||||
Messages can be routed to specific handlers based on a key:
|
||||
|
||||
```typescript
|
||||
const worker = new Worker<Task>({
|
||||
getKey: task => task.priority
|
||||
})
|
||||
|
||||
// Handle high priority tasks
|
||||
worker.addHandler('high', async (task) => {
|
||||
await processUrgent(task)
|
||||
})
|
||||
|
||||
// Handle normal priority tasks
|
||||
worker.addHandler('normal', async (task) => {
|
||||
await processNormal(task)
|
||||
})
|
||||
```
|
||||
|
||||
### Global Handlers
|
||||
|
||||
Handle all messages regardless of routing key:
|
||||
|
||||
```typescript
|
||||
worker.addGlobalHandler(async (message) => {
|
||||
console.log('Processing:', message)
|
||||
})
|
||||
```
|
||||
|
||||
### Message Deferral
|
||||
|
||||
Defer processing of messages that aren't ready:
|
||||
|
||||
```typescript
|
||||
const worker = new Worker<Task>({
|
||||
shouldDefer: (task) => !task.isReady(),
|
||||
delay: 1000
|
||||
})
|
||||
|
||||
worker.push(task) // Will retry until task.isReady()
|
||||
```
|
||||
|
||||
### Flow Control
|
||||
|
||||
Control message processing:
|
||||
|
||||
```typescript
|
||||
// Pause processing
|
||||
worker.pause()
|
||||
|
||||
// Resume processing
|
||||
worker.resume()
|
||||
|
||||
// Clear queue
|
||||
worker.clear()
|
||||
```
|
||||
@@ -0,0 +1,78 @@
|
||||
# Connection
|
||||
|
||||
The `Connection` class is the core building block for relay communication in `@welshman/net`. It manages the complete lifecycle of a relay connection, including socket handling, message queuing, authentication, and statistics tracking.
|
||||
|
||||
## Overview
|
||||
|
||||
A Connection handles:
|
||||
- WebSocket lifecycle
|
||||
- Message queuing and throttling
|
||||
- Connection state tracking
|
||||
- Relay authentication
|
||||
- Connection statistics
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import {Connection} from '@welshman/net'
|
||||
|
||||
// Create connection
|
||||
const connection = new Connection("wss://relay.example.com")
|
||||
|
||||
// Listen for events
|
||||
connection.on('event', (conn, subId, event) => {
|
||||
console.log(`Got event from ${conn.url}`)
|
||||
})
|
||||
|
||||
// Send a subscription
|
||||
connection.send(["REQ", "my-sub", {kinds: [1], limit: 10}])
|
||||
|
||||
// Clean up when done
|
||||
connection.cleanup()
|
||||
```
|
||||
|
||||
## Handling Authentication
|
||||
|
||||
The `connection.open()` promise resolves when the WebSocket connection is fully established and ready for communication.
|
||||
However, it's important to understand the authentication flow:
|
||||
|
||||
```typescript
|
||||
import {Connection} from '@welshman/net'
|
||||
|
||||
const connection = new Connection("wss://relay.example.com")
|
||||
|
||||
// Basic open
|
||||
await connection.open()
|
||||
// Promise resolves when WebSocket is connected
|
||||
// BUT might not be auth-ready yet!
|
||||
|
||||
// Complete open with auth handling
|
||||
const openRelay = async (url: string) => {
|
||||
const connection = new Connection(url)
|
||||
|
||||
// Open socket
|
||||
await connection.open()
|
||||
|
||||
// Check if relay requires auth
|
||||
if (connection.auth.status === 'requested') {
|
||||
try {
|
||||
// Handle auth challenge
|
||||
await connection.auth.attempt(3000) // 3s timeout
|
||||
} catch (e) {
|
||||
console.error('Auth failed:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// NOW connection is fully ready
|
||||
return connection
|
||||
}
|
||||
```
|
||||
|
||||
The key states after `open()` resolves:
|
||||
- Socket is connected
|
||||
- Messages can be queued
|
||||
- BUT relay might request authentication
|
||||
- AND authentication might fail
|
||||
|
||||
Always check `connection.auth.status` if you need to ensure the connection is fully authenticated before use.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Context
|
||||
|
||||
The Context system is the backbone of `@welshman/net`, providing global configuration and shared services that are essential for the package's operation. It defines how events are handled, validated, and routed throughout the application.
|
||||
|
||||
## Overview
|
||||
|
||||
- Global connection pool (`ctx.net.pool`)
|
||||
- Event validation (`ctx.net.isValid`)
|
||||
- Event handling (`ctx.net.onEvent`)
|
||||
- Deletion tracking (`ctx.net.isDeleted`)
|
||||
- Event signing (`ctx.net.signEvent`)
|
||||
- Subscription optimization (`ctx.net.optimizeSubscriptions`)
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import {ctx, setContext} from '@welshman/lib'
|
||||
import {
|
||||
getDefaultNetContext,
|
||||
Pool,
|
||||
hasValidSignature
|
||||
} from '@welshman/net'
|
||||
|
||||
// Setup networking context
|
||||
setContext({
|
||||
net: getDefaultNetContext({
|
||||
// Use shared pool
|
||||
pool: new Pool(),
|
||||
|
||||
// Track events
|
||||
onEvent: (url, event) => {
|
||||
tracker.track(event.id, url)
|
||||
repository.publish(event)
|
||||
},
|
||||
|
||||
// Validate based on source
|
||||
isValid: (url, event) => {
|
||||
// Trust local relay
|
||||
if (url === LOCAL_RELAY_URL) return true
|
||||
|
||||
// Validate signature for remote events
|
||||
return hasValidSignature(event)
|
||||
},
|
||||
|
||||
// Check deletion status
|
||||
isDeleted: (url, event) =>
|
||||
repository.isDeleted(event),
|
||||
|
||||
// Sign with current user
|
||||
signEvent: async (event) =>
|
||||
signer.get().sign(event)
|
||||
})
|
||||
})
|
||||
|
||||
// Now all package features will use these settings
|
||||
subscribe(/*...*/) // Uses pool, validates events
|
||||
publish(/*...*/) // Uses signEvent
|
||||
```
|
||||
|
||||
The Context is used internally by most features in the package.
|
||||
Without proper context configuration, core features like subscription, publishing, and event validation won't work correctly.
|
||||
|
||||
Think of it as the central configuration that defines how your nostr networking behaves.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Executor
|
||||
|
||||
The Executor class orchestrates event delivery and subscription management across one or more [targets](/net/targets.md). It abstracts the complexity of handling multiple connections into a single interface.
|
||||
|
||||
## Overview
|
||||
|
||||
The Executor:
|
||||
- Manages subscriptions
|
||||
- Handles event publishing
|
||||
- Supports NIP-77 (negentropy)
|
||||
- Routes messages to appropriate targets
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import {Executor, Relays} from '@welshman/net'
|
||||
|
||||
// Create executor with relay target
|
||||
const executor = new Executor(
|
||||
new Relays([
|
||||
connection1,
|
||||
connection2
|
||||
])
|
||||
)
|
||||
|
||||
// Subscribe to events
|
||||
const sub = executor.subscribe(
|
||||
[{kinds: [1], limit: 10}],
|
||||
{
|
||||
onEvent: (url, event) => {
|
||||
console.log(`Got event from ${url}`, event)
|
||||
},
|
||||
onEose: (url) => {
|
||||
console.log(`EOSE from ${url}`)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Publish event
|
||||
const pub = executor.publish(
|
||||
signedEvent,
|
||||
{
|
||||
onOk: (url, id, success, message) => {
|
||||
console.log(`Published to ${url}: ${success ? 'OK' : message}`)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Clean up
|
||||
sub.unsubscribe()
|
||||
executor.target.cleanup()
|
||||
```
|
||||
|
||||
The Executor is used internally by higher-level APIs but can be used directly when you need fine-grained control over event routing and subscription management.
|
||||
|
||||
It's particularly useful when implementing custom targets or handling special relay configurations (like local relays or relay groups).
|
||||
@@ -0,0 +1,19 @@
|
||||
# @welshman/net
|
||||
|
||||
Core networking layer for nostr applications, handling relay connections, message management, and event delivery.
|
||||
|
||||
## What's Included
|
||||
|
||||
- **Connection Management** - WebSocket lifecycle and relay connections
|
||||
- **Subscription System** - Event filtering and subscription handling
|
||||
- **Publishing Tools** - Event broadcasting with status tracking
|
||||
- **Sync Utilities** - NIP-77 (negentropy) event synchronization
|
||||
- **Connection Pool** - Shared relay connection management
|
||||
- **Targets** - Flexible message routing strategies
|
||||
- **Event Tracking** - Monitor which relays have seen events
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @welshman/net
|
||||
```
|
||||
@@ -0,0 +1,37 @@
|
||||
# Pool
|
||||
|
||||
The Pool class manages a collection of relay connections, providing a centralized way to track and reuse connections across your application.
|
||||
|
||||
## Overview
|
||||
|
||||
- Creates and caches connections
|
||||
- Ensures single connection per relay
|
||||
- Handles cleanup of unused connections
|
||||
- Provides connection lookup
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import {Pool} from '@welshman/net'
|
||||
|
||||
// Create pool
|
||||
const pool = new Pool()
|
||||
|
||||
// Get or create connection
|
||||
const connection = pool.get("wss://relay.example.com")
|
||||
|
||||
// Check if relay is in pool
|
||||
if (pool.has("wss://relay.example.com")) {
|
||||
// Use existing connection
|
||||
}
|
||||
|
||||
// Remove connection
|
||||
pool.remove("wss://relay.example.com")
|
||||
|
||||
// Clear all connections
|
||||
pool.clear()
|
||||
```
|
||||
|
||||
|
||||
The Pool is typically used internally by the router and executor, but can be used directly for custom connection management.
|
||||
It ensures efficient connection reuse across your application.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Publish
|
||||
|
||||
The `Publish` class handles event publishing to relays, managing publish status, relay responses, and error handling.
|
||||
|
||||
## Overview
|
||||
|
||||
- Sends events to relays
|
||||
- Tracks publish status per relay
|
||||
- Handles OK/Error responses
|
||||
- Manages timeouts
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import {`Publish`, `Publish`Status} from '@welshman/net'
|
||||
|
||||
const {Pending, Success, Failure, Timeout, Aborted} = `Publish`Status
|
||||
|
||||
// Basic `Publish`
|
||||
const pub = `Publish`({
|
||||
event: signedEvent,
|
||||
relays: ["wss://relay.example.com"],
|
||||
timeout: 3000 // 3s timeout
|
||||
})
|
||||
|
||||
// Track status
|
||||
pub.emitter.on('*', (status: `Publish`Status, url: string, message?: string) => {
|
||||
switch (status) {
|
||||
case Success:
|
||||
console.log(``Publish`ed to ${url}`)
|
||||
break
|
||||
case Failure:
|
||||
console.log(`Failed on ${url}: ${message}`)
|
||||
break
|
||||
case Timeout:
|
||||
console.log(`Timeout on ${url}`)
|
||||
break
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Real World Example
|
||||
|
||||
```typescript
|
||||
const publishWithStatus = async (event: SignedEvent) => {
|
||||
const pub = `Publish`({
|
||||
event,
|
||||
relays: ctx.app.router
|
||||
.FromUser()
|
||||
.getUrls(),
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Track per-relay status
|
||||
const status = new Map<string, string>()
|
||||
|
||||
pub.emitter.on('*', (state: `Publish`Status, url: string) => {
|
||||
status.set(url, state)
|
||||
|
||||
// Log progress
|
||||
const counts = {
|
||||
pending: 0,
|
||||
success: 0,
|
||||
failed: 0
|
||||
}
|
||||
|
||||
for (const s of status.values()) {
|
||||
counts[s] = (counts[s] || 0) + 1
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Progress: ${counts.success}/${status.size}`,
|
||||
`(${counts.failed} failed)`
|
||||
)
|
||||
})
|
||||
|
||||
// Wait for completion
|
||||
return pub.result
|
||||
}
|
||||
```
|
||||
|
||||
Like [Subscribe](/net/subscribe.md), `Publish` uses [Pool](/net/pool.md) for connections and creates appropriate [Targets](/net/targets.md) via an [Executor](/net/executor.md), but focuses on event publishing rather than subscription management.
|
||||
|
||||
Note: The base `@welshman/net` Publish class just handles network publishing.
|
||||
For optimistic updates and repository integration, use Publish from `@welshman/app`.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Socket
|
||||
The Socket class is exclusively used by the `Connection` class as its low-level WebSocket manager. It's not meant to be used directly by other classes.
|
||||
Its sole purpose is to provide a reliable, manageable WebSocket connection with nostr-specific handling.
|
||||
|
||||
## Core Responsibilities
|
||||
|
||||
```typescript
|
||||
export class Socket {
|
||||
// Track connection state
|
||||
status: SocketStatus = "new" | "open" | "opening" | "closing" | "closed" | "error"
|
||||
|
||||
// Handle nostr message queue
|
||||
worker: Worker<Message>
|
||||
|
||||
// Core operations
|
||||
open = async () => {/* Initialize WebSocket */}
|
||||
close = async () => {/* Clean shutdown */}
|
||||
send = async (message: Message) => {/* Send with JSON serialization */}
|
||||
}
|
||||
```
|
||||
|
||||
Key features:
|
||||
- State tracking
|
||||
- Message queuing
|
||||
- JSON serialization
|
||||
- Error recovery
|
||||
- Connection lifecycle
|
||||
|
||||
Think of it as a thin wrapper that turns raw WebSocket connections into something more suitable for nostr:
|
||||
```typescript
|
||||
// Raw WebSocket
|
||||
ws.send(JSON.stringify(["REQ", "sub1", {kinds: [1]}]))
|
||||
|
||||
// With Socket
|
||||
socket.send(["REQ", "sub1", {kinds: [1]}]) // Handles serialization
|
||||
```
|
||||
|
||||
## Usage Chain
|
||||
|
||||
```typescript
|
||||
// Hierarchy
|
||||
Socket // WebSocket management
|
||||
↳ Connection // Uses Socket
|
||||
↳ Relay Target // Uses Connection
|
||||
↳ Executor // Uses Target
|
||||
↳ Subscribe // Uses Executor
|
||||
↳ Publish // Uses Executor
|
||||
|
||||
// In Connection.ts
|
||||
export class Connection extends Emitter {
|
||||
socket: Socket
|
||||
|
||||
constructor(url: string) {
|
||||
this.socket = new Socket(this)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
It's an internal implementation detail that you shouldn't need to use directly - always interact with the `Connection` class instead, which provides a higher-level interface.
|
||||
|
||||
```typescript
|
||||
// DON'T use Socket directly
|
||||
const socket = new Socket(/*...*/) // ❌
|
||||
|
||||
// DO use Connection
|
||||
const connection = new Connection(url) // ✅
|
||||
```
|
||||
|
||||
This encapsulation ensures consistent connection management across the library.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Subscribe
|
||||
|
||||
The Subscribe class manages nostr subscriptions, handling subscription lifecycle, event filtering, and relay responses. It provides a unified interface for subscribing to events across multiple relays.
|
||||
|
||||
## Overview
|
||||
|
||||
The Subscription:
|
||||
- Manages REQ/CLOSE lifecycle
|
||||
- Handles EOSE responses
|
||||
- Emits filtered events
|
||||
- Tracks completion state
|
||||
|
||||
```typescript
|
||||
import {subscribe, SubscriptionEvent} from '@welshman/net'
|
||||
|
||||
// Create subscription
|
||||
const sub = subscribe({
|
||||
filters: [{kinds: [1], limit: 10}],
|
||||
relays: ["wss://relay.example.com"],
|
||||
|
||||
// Optional configurations
|
||||
closeOnEose: true, // Close after all relays send EOSE
|
||||
timeout: 3000, // Max time to wait
|
||||
authTimeout: 300, // Time for auth negotiation
|
||||
delay: 50 // Delay between batched requests
|
||||
})
|
||||
|
||||
// Handle events
|
||||
sub.on(SubscriptionEvent.Event, (url, event) => {
|
||||
console.log(`Got event from ${url}:`, event)
|
||||
})
|
||||
|
||||
sub.on(SubscriptionEvent.Eose, (url) => {
|
||||
console.log(`Got EOSE from ${url}`)
|
||||
})
|
||||
|
||||
sub.on(SubscriptionEvent.Complete, () => {
|
||||
console.log('Subscription complete')
|
||||
})
|
||||
|
||||
// Close when done
|
||||
sub.close()
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```typescript
|
||||
import {subscribe, Pool, Executor, Relays} from '@welshman/net'
|
||||
|
||||
// Under the hood, subscribe:
|
||||
// 1. Gets connections from global pool
|
||||
// 2. Creates a target (usually Relays)
|
||||
// 3. Uses Executor to manage subscription
|
||||
|
||||
// This is roughly equivalent to:
|
||||
const manualSubscribe = (urls: string[]) => {
|
||||
// Get connections from pool
|
||||
const connections = urls.map(url =>
|
||||
ctx.net.pool.get(url)
|
||||
)
|
||||
|
||||
// Create target
|
||||
const target = new Relays(connections)
|
||||
|
||||
// Create executor
|
||||
const executor = new Executor(target)
|
||||
|
||||
// Subscribe via executor
|
||||
return executor.subscribe(
|
||||
[{kinds: [1], limit: 10}],
|
||||
{
|
||||
onEvent: (url, event) => {
|
||||
console.log(`Got event from ${url}`)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Real World Example
|
||||
|
||||
```typescript
|
||||
// Combine local and remote relays
|
||||
const loadProfile = async (pubkey: string) => {
|
||||
// Get optimal relays
|
||||
const relays = ctx.app.router
|
||||
.ForPubkey(pubkey)
|
||||
.getUrls()
|
||||
|
||||
const sub = subscribe({
|
||||
filters: [{
|
||||
kinds: [0],
|
||||
authors: [pubkey],
|
||||
limit: 1
|
||||
}],
|
||||
relays,
|
||||
// This creates internally:
|
||||
// 1. Connections via Pool
|
||||
// 2. Multi target with Local + Relays
|
||||
// 3. Executor to manage subscription
|
||||
})
|
||||
|
||||
return new Promise(resolve => {
|
||||
sub.on('event', (url, event) => {
|
||||
resolve(event)
|
||||
sub.close()
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
The Subscribe class abstracts away:
|
||||
- Connection management (via Pool)
|
||||
- Target creation and setup
|
||||
- Executor orchestration
|
||||
@@ -0,0 +1,70 @@
|
||||
# Sync
|
||||
|
||||
The Sync utilities in `@welshman/net` provide methods for synchronizing events between relays and repositories, primarily using NIP-77 (Negentropy) when available, with fallback to traditional sync methods.
|
||||
|
||||
## Overview
|
||||
|
||||
```typescript
|
||||
import {sync, pull, push} from '@welshman/net'
|
||||
|
||||
// Three main operations:
|
||||
// 1. pull: Get events from relays
|
||||
// 2. push: Send events to relays
|
||||
// 3. sync: Bidirectional sync
|
||||
```
|
||||
|
||||
These utilities are primarily used by:
|
||||
- `Repository` for syncing with relays
|
||||
- `FeedController` for initial feed loading
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import {sync, pull, getFilterSelections} from '@welshman/net'
|
||||
|
||||
// Sync user profile data
|
||||
const syncProfiles = async (pubkeys: string[]) => {
|
||||
await sync({
|
||||
// What to sync
|
||||
filters: [{
|
||||
kinds: [0],
|
||||
authors: pubkeys
|
||||
}],
|
||||
|
||||
// Which relays
|
||||
relays: ctx.app.router
|
||||
.ForPubkeys(pubkeys)
|
||||
.getUrls(),
|
||||
|
||||
// Local events to consider
|
||||
events: repository.query([{
|
||||
kinds: [0],
|
||||
authors: pubkeys
|
||||
}])
|
||||
})
|
||||
}
|
||||
|
||||
// Initial feed load with negentropy
|
||||
const loadFeed = async () => {
|
||||
await pull({
|
||||
filters: [{
|
||||
kinds: [1],
|
||||
limit: 100
|
||||
}],
|
||||
relays: ctx.app.router
|
||||
.ForUser()
|
||||
.getUrls(),
|
||||
events: [], // No local events yet
|
||||
onEvent: (event) => {
|
||||
// Handle new events
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Sync operations:
|
||||
- Use NIP-77 when supported by relay
|
||||
- Fall back to traditional sync
|
||||
- Handle bidirectional sync
|
||||
- Support filtered sync
|
||||
- Track sync progress
|
||||
@@ -0,0 +1,137 @@
|
||||
# Targets
|
||||
|
||||
The targets system provides different strategies for message routing.
|
||||
Each target type implements a common interface for handling nostr messages but with different routing behaviors.
|
||||
|
||||
## Overview
|
||||
|
||||
Targets are used by the [Executor](/net/executor.md) class to:
|
||||
- Route messages to connections
|
||||
- Handle responses
|
||||
- Manage connection lifecycles
|
||||
- Combine multiple routing strategies
|
||||
|
||||
## Available Targets
|
||||
|
||||
### Echo Target
|
||||
Simple target that echoes messages back. Useful for testing.
|
||||
```typescript
|
||||
import {Echo} from '@welshman/net'
|
||||
|
||||
const echo = new Echo()
|
||||
echo.on('EVENT', (url, event) => {
|
||||
console.log('Echo received:', event)
|
||||
})
|
||||
```
|
||||
|
||||
### Local Target
|
||||
Connects to an in-memory relay implementation.
|
||||
```typescript
|
||||
import {Local} from '@welshman/net'
|
||||
import {Repository, Relay} from '@welshman/util'
|
||||
|
||||
// Create local relay
|
||||
const repository = new Repository()
|
||||
const relay = new Relay(repository)
|
||||
const local = new Local(relay)
|
||||
|
||||
// Use like any other target
|
||||
local.send(['REQ', 'sub1', {kinds: [1]}])
|
||||
```
|
||||
|
||||
### Relay Target
|
||||
Single relay connection target.
|
||||
```typescript
|
||||
import {Relay} from '@welshman/net'
|
||||
|
||||
const target = new Relay(connection)
|
||||
target.on('EVENT', (url, event) => {
|
||||
console.log(`Event from ${url}:`, event)
|
||||
})
|
||||
```
|
||||
|
||||
### Relays Target
|
||||
Manages multiple relay connections.
|
||||
```typescript
|
||||
import {Relays} from '@welshman/net'
|
||||
|
||||
const target = new Relays([
|
||||
connection1,
|
||||
connection2,
|
||||
connection3
|
||||
])
|
||||
```
|
||||
|
||||
### Multi Target
|
||||
Combines multiple targets into one.
|
||||
```typescript
|
||||
import {Multi, Local, Relays} from '@welshman/net'
|
||||
|
||||
// Create multi-target with local and remote relays
|
||||
const target = new Multi([
|
||||
new Local(localRelay),
|
||||
new Relays(remoteConnections)
|
||||
])
|
||||
```
|
||||
|
||||
## Real World Example
|
||||
|
||||
Here's how Coracle might set up its relay infrastructure:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Executor,
|
||||
Multi,
|
||||
Local,
|
||||
Relays
|
||||
} from '@welshman/net'
|
||||
import {Repository, Relay} from '@welshman/util'
|
||||
|
||||
// Setup
|
||||
const setupRelayInfrastructure = () => {
|
||||
// Create local repository & relay
|
||||
const repository = new Repository()
|
||||
const localRelay = new Relay(repository)
|
||||
|
||||
// Get remote connections from pool
|
||||
const remoteConnections = [
|
||||
pool.get("wss://relay1.example.com"),
|
||||
pool.get("wss://relay2.example.com")
|
||||
]
|
||||
|
||||
// Create multi-target executor
|
||||
const executor = new Executor(
|
||||
new Multi([
|
||||
// Local relay for immediate responses
|
||||
new Local(localRelay),
|
||||
|
||||
// Remote relays for network queries
|
||||
new Relays(remoteConnections)
|
||||
])
|
||||
)
|
||||
|
||||
// Subscribe using combined target
|
||||
const sub = executor.subscribe(
|
||||
[{kinds: [1], limit: 10}],
|
||||
{
|
||||
onEvent: (url, event) => {
|
||||
if (url === LOCAL_RELAY_URL) {
|
||||
console.log('Got from cache:', event)
|
||||
} else {
|
||||
console.log('Got from network:', url, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {executor, sub}
|
||||
}
|
||||
```
|
||||
|
||||
The target system allows for flexible relay configurations while maintaining a consistent interface for the rest of the application. This is particularly useful for:
|
||||
- Caching with local relays
|
||||
- Load balancing across relays
|
||||
- Fallback strategies
|
||||
- Testing and simulation
|
||||
|
||||
Each target type serves a specific purpose but can be combined using `Multi` for complex routing scenarios.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Tracker
|
||||
|
||||
The Tracker is a simple but crucial class that keeps track of which relays an event was seen on or published to. It's essential for relay selection and event source tracking.
|
||||
|
||||
## Overview
|
||||
|
||||
```typescript
|
||||
import {Tracker} from '@welshman/net'
|
||||
|
||||
const tracker = new Tracker()
|
||||
|
||||
// Track event source
|
||||
tracker.track(eventId, relayUrl)
|
||||
|
||||
// Get relays for event
|
||||
const relays = tracker.getRelays(eventId) // Set<string>
|
||||
|
||||
// Get events from relay
|
||||
const events = tracker.getIds(relayUrl) // Set<string>
|
||||
|
||||
// Check specific relay
|
||||
const seen = tracker.hasRelay(eventId, relayUrl)
|
||||
```
|
||||
|
||||
## Used By
|
||||
|
||||
1. **Repository & Sync**
|
||||
```typescript
|
||||
// In sync operations
|
||||
pull({
|
||||
events,
|
||||
relays,
|
||||
onEvent: (event) => {
|
||||
tracker.track(event.id, relay)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
2. **Subscribe**
|
||||
```typescript
|
||||
// In @welshman/app subscribe
|
||||
sub.on('event', (url, event) => {
|
||||
// Track where we got the event
|
||||
tracker.track(event.id, url)
|
||||
})
|
||||
```
|
||||
|
||||
3. **Publish**
|
||||
```typescript
|
||||
// In publish operations
|
||||
pub.emitter.on('success', (url) => {
|
||||
// Track where we published
|
||||
tracker.track(event.id, url)
|
||||
})
|
||||
```
|
||||
|
||||
4. **Router**
|
||||
```typescript
|
||||
// Used for relay selection
|
||||
const relays = tracker
|
||||
.getRelays(event.id)
|
||||
.filter(url =>
|
||||
isHealthyRelay(url)
|
||||
)
|
||||
```
|
||||
|
||||
The Tracker:
|
||||
- Maps events to their source relays
|
||||
- Maps relays to their known events
|
||||
- Helps optimize relay selection
|
||||
|
||||
Think of it as a memory of where events came from, helping make better decisions about where to find or publish events.
|
||||
@@ -0,0 +1,23 @@
|
||||
# @welshman/signer
|
||||
|
||||
A comprehensive Nostr signing implementation that supports multiple authentication methods and encryption standards.
|
||||
It provides a unified interface for working with different signing mechanisms while maintaining compatibility with various Nostr Implementation Possibilities (NIPs).
|
||||
|
||||
|
||||
## What's Included
|
||||
|
||||
- **ISigner Interface** - Unified API across all authentication methods
|
||||
- **NIP-01 Signer** - Core implementation using key-pair cryptography
|
||||
- **NIP-07 Signer** - Browser extension support (nos2x, Alby, etc.)
|
||||
- **NIP-46 Signer** - Remote signing with Nostr Connect protocol
|
||||
- **NIP-55 Signer** - Native app integration via Capacitor
|
||||
- **NIP-59 Utils** - Gift Wrap protocol for secure event encryption
|
||||
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
```bash
|
||||
npm install @welshman/signer
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
# ISigner Interface
|
||||
|
||||
A basic interface that each signer must implement.
|
||||
It includes methods for signing messages, verifying signatures, and encrypting/decrypting data.
|
||||
|
||||
|
||||
```typescript
|
||||
interface ISigner {
|
||||
// Core signing functionality
|
||||
sign: (event: StampedEvent) => Promise<SignedEvent>
|
||||
getPubkey: () => Promise<string>
|
||||
|
||||
// Encryption capabilities
|
||||
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>
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,41 @@
|
||||
# NIP-01 Signer
|
||||
|
||||
The `Nip01Signer` class implements the `ISigner` interface and extends it with additional static utility methods:
|
||||
|
||||
```typescript
|
||||
class Nip01Signer implements ISigner {
|
||||
// Constructor
|
||||
constructor(private secret: string)
|
||||
|
||||
// ISigner implementation
|
||||
sign: (event: StampedEvent) => Promise<SignedEvent>
|
||||
getPubkey: () => Promise<string>
|
||||
nip04: { encrypt, decrypt }
|
||||
nip44: { encrypt, decrypt }
|
||||
|
||||
// Additional static utility methods
|
||||
static fromSecret(secret: string): Nip01Signer
|
||||
static ephemeral(): Nip01Signer
|
||||
}
|
||||
```
|
||||
|
||||
### Additional Methods
|
||||
|
||||
The NIP-01 implementation extends the base interface with two static utility methods:
|
||||
|
||||
- `static fromSecret(secret: string)`: Alternative constructor for creating a signer from an existing private key
|
||||
- `static ephemeral()`: Creates a new signer with a randomly generated private key
|
||||
|
||||
### Usage Example
|
||||
|
||||
```typescript
|
||||
import { ISigner } from './interfaces'
|
||||
import { Nip01Signer } from './signers/nip01'
|
||||
|
||||
// Using the standard interface
|
||||
const signer: ISigner = new Nip01Signer(mySecret)
|
||||
|
||||
// Using NIP-01 specific utilities
|
||||
const ephemeralSigner = Nip01Signer.ephemeral()
|
||||
const fromExistingKey = Nip01Signer.fromSecret(mySecret)
|
||||
```
|
||||
@@ -0,0 +1,91 @@
|
||||
# NIP-07 Signer
|
||||
|
||||
The `Nip07Signer` implements the `ISigner` interface by delegating signing operations to a NIP-07 compatible browser extension (like nos2x or Alby). It provides a way to interact with user's keys that are securely stored in their browser extension.
|
||||
|
||||
## Browser Detection
|
||||
|
||||
```typescript
|
||||
import { getNip07 } from '@welshman/signer'
|
||||
|
||||
// Check if a NIP-07 provider is available
|
||||
if (getNip07()) {
|
||||
// Browser has a compatible extension installed
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { Nip07Signer } from '@welshman/signer'
|
||||
|
||||
// Create a new signer instance
|
||||
const signer = new Nip07Signer()
|
||||
|
||||
// The extension will prompt the user for permission
|
||||
// when operations are performed
|
||||
```
|
||||
|
||||
|
||||
## Complete Example
|
||||
|
||||
```typescript
|
||||
import { Nip07Signer, getNip07 } from '@welshman/signer'
|
||||
import { createEvent, NOTE } from '@welshman/util'
|
||||
|
||||
async function example() {
|
||||
// Check for NIP-07 provider
|
||||
if (!getNip07()) {
|
||||
throw new Error('No NIP-07 provider found. Please install a Nostr browser extension.')
|
||||
}
|
||||
|
||||
// Create signer
|
||||
const signer = new Nip07Signer()
|
||||
|
||||
try {
|
||||
// Get public key (will prompt user)
|
||||
const pubkey = await signer.getPubkey()
|
||||
console.log('Public key:', pubkey)
|
||||
|
||||
// Create and sign an event (will prompt user)
|
||||
const event = createEvent(NOTE, {
|
||||
content: "Hello via browser extension!",
|
||||
tags: [["t", "test"]]
|
||||
})
|
||||
const signedEvent = await signer.sign(event)
|
||||
console.log('Signed event:', signedEvent)
|
||||
|
||||
// Encrypt a message (will prompt user)
|
||||
const recipientPubkey = "..."
|
||||
const encrypted = await signer.nip44.encrypt(recipientPubkey, "Secret message")
|
||||
console.log('Encrypted message:', encrypted)
|
||||
} catch (error) {
|
||||
// Handle user rejection or other errors
|
||||
console.error('Operation failed:', error)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Request Serialization
|
||||
The signer implements a lock mechanism to prevent concurrent calls to the extension:
|
||||
|
||||
```typescript
|
||||
class Nip07Signer implements ISigner {
|
||||
#lock = Promise.resolve()
|
||||
|
||||
#then = async <T>(f: (ext: Nip07) => T | Promise<T>) => {
|
||||
const promise = this.#lock.then(() => {
|
||||
const ext = getNip07()
|
||||
if (!ext) throw new Error("Nip07 is not enabled")
|
||||
return f(ext)
|
||||
})
|
||||
|
||||
// Reset lock after completion or error
|
||||
this.#lock = promise.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
|
||||
return promise
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,153 @@
|
||||
# NIP-46 (Nostr Connect) Signer
|
||||
|
||||
The `Nip46Signer` implements remote signing capabilities through the Nostr Connect protocol (NIP-46). It allows applications to delegate signing operations to a remote signer (like a Nostr Bunker), providing enhanced security by keeping private keys separate from the application.
|
||||
|
||||
## Architecture
|
||||
|
||||
The implementation consists of two main classes:
|
||||
- `Nip46Broker`: Handles the communication with the remote signer
|
||||
- `Nip46Signer`: Implements the `ISigner` interface using the broker
|
||||
|
||||
## Getting Started
|
||||
|
||||
```typescript
|
||||
import {
|
||||
makeSecret,
|
||||
Nip46Broker,
|
||||
Nip46Signer
|
||||
} from '@welshman/signer'
|
||||
import { createEvent, NOTE } from '@welshman/util'
|
||||
|
||||
async function connectToRemoteSigner() {
|
||||
// Initial setup
|
||||
const clientSecret = makeSecret()
|
||||
const relays = ['wss://relay.example.com']
|
||||
const broker = Nip46Broker.get({ relays, clientSecret })
|
||||
const signer = new Nip46Signer(broker)
|
||||
|
||||
// Generate connection URL
|
||||
const ncUrl = await broker.makeNostrconnectUrl({
|
||||
name: "My App",
|
||||
description: "Testing remote signing"
|
||||
})
|
||||
|
||||
// Show URL to user (e.g., as QR code)
|
||||
displayQRCode(ncUrl)
|
||||
|
||||
try {
|
||||
// Wait for connection
|
||||
const response = await broker.waitForNostrconnect(
|
||||
ncUrl,
|
||||
new AbortController()
|
||||
)
|
||||
|
||||
// Store signer info for later
|
||||
const bunkerUrl = broker.getBunkerUrl()
|
||||
localStorage.setItem('bunkerUrl', bunkerUrl)
|
||||
|
||||
// Use the signer
|
||||
const event = createEvent(NOTE, {
|
||||
content: "Signed with remote signer!",
|
||||
tags: [["t", "test"]]
|
||||
})
|
||||
const signed = await signer.sign(event)
|
||||
|
||||
return signed
|
||||
} catch (error) {
|
||||
if (error?.error) {
|
||||
console.warn(`Signer error: ${error.error}`)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Reconnecting with saved bunker URL
|
||||
async function reconnect() {
|
||||
const bunkerUrl = localStorage.getItem('bunkerUrl')
|
||||
if (!bunkerUrl) return null
|
||||
|
||||
const {
|
||||
signerPubkey,
|
||||
connectSecret,
|
||||
relays
|
||||
} = Nip46Broker.parseBunkerUrl(bunkerUrl)
|
||||
|
||||
const broker = Nip46Broker.get({
|
||||
relays,
|
||||
clientSecret: makeSecret(),
|
||||
signerPubkey,
|
||||
connectSecret
|
||||
})
|
||||
|
||||
return new Nip46Signer(broker)
|
||||
}
|
||||
```
|
||||
|
||||
## Nip46Broker API
|
||||
|
||||
### Constructor and Factory
|
||||
|
||||
```typescript
|
||||
// Recommended: use the singleton factory
|
||||
const broker = Nip46Broker.get({
|
||||
relays: string[],
|
||||
clientSecret: string,
|
||||
connectSecret?: string,
|
||||
signerPubkey?: string,
|
||||
algorithm?: "nip04" | "nip44"
|
||||
})
|
||||
|
||||
// Direct instantiation (not recommended)
|
||||
new Nip46Broker(params)
|
||||
```
|
||||
|
||||
### Connection Methods
|
||||
|
||||
```typescript
|
||||
// Generate a nostrconnect:// URL for the remote signer
|
||||
broker.makeNostrconnectUrl(metadata: Record<string, string>): Promise<string>
|
||||
|
||||
// Wait for connection approval
|
||||
broker.waitForNostrconnect(
|
||||
url: string,
|
||||
abort?: AbortController
|
||||
): Promise<Nip46ResponseWithResult>
|
||||
|
||||
// Get bunker URL for later reconnection
|
||||
broker.getBunkerUrl(): string
|
||||
|
||||
// Parse a bunker URL
|
||||
Nip46Broker.parseBunkerUrl(url: string): {
|
||||
signerPubkey: string,
|
||||
connectSecret: string,
|
||||
relays: string[]
|
||||
}
|
||||
```
|
||||
|
||||
### Remote Operations
|
||||
|
||||
```typescript
|
||||
// Basic operations
|
||||
broker.ping(): Promise<string>
|
||||
broker.getPublicKey(): Promise<string>
|
||||
broker.connect(connectSecret?: string, perms?: string): Promise<string>
|
||||
|
||||
// Signing and encryption
|
||||
broker.signEvent(event: StampedEvent): Promise<SignedEvent>
|
||||
broker.nip04Encrypt(pk: string, message: string): Promise<string>
|
||||
broker.nip04Decrypt(pk: string, message: string): Promise<string>
|
||||
broker.nip44Encrypt(pk: string, message: string): Promise<string>
|
||||
broker.nip44Decrypt(pk: string, message: string): Promise<string>
|
||||
```
|
||||
|
||||
## Nip46Signer Usage
|
||||
|
||||
```typescript
|
||||
const signer = new Nip46Signer(broker)
|
||||
|
||||
// All ISigner operations are available
|
||||
const pubkey = await signer.getPubkey()
|
||||
const signed = await signer.sign(event)
|
||||
const encrypted = await signer.nip44.encrypt(pubkey, "message")
|
||||
const decrypted = await signer.nip44.decrypt(pubkey, encrypted)
|
||||
```
|
||||
@@ -0,0 +1,165 @@
|
||||
# NIP-55 (Native App) Signer
|
||||
|
||||
The `Nip55Signer` implements the `ISigner` interface by communicating with native mobile signing applications through the Capacitor plugin system. This implementation is particularly useful for mobile applications that want to leverage native Nostr signing capabilities.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The signer requires the Capacitor plugin to be installed:
|
||||
|
||||
```bash
|
||||
npm install nostr-signer-capacitor-plugin
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
```typescript
|
||||
import { Nip55Signer, getNip55 } from '@welshman/signer'
|
||||
|
||||
// Check for available signing apps
|
||||
const apps = await getNip55()
|
||||
if (apps.length > 0) {
|
||||
const signer = new Nip55Signer(apps[0].packageName)
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Detecting Available Signers
|
||||
|
||||
```typescript
|
||||
// Returns information about installed signing apps
|
||||
getNip55(): Promise<AppInfo[]>
|
||||
|
||||
interface AppInfo {
|
||||
name: string
|
||||
packageName: string
|
||||
// Other app-specific information
|
||||
}
|
||||
```
|
||||
|
||||
### Constructor
|
||||
|
||||
```typescript
|
||||
constructor(packageName: string)
|
||||
```
|
||||
Creates a new signer instance that will communicate with the specified native app.
|
||||
- `packageName`: The package identifier of the native signing app
|
||||
|
||||
### ISigner implementation
|
||||
|
||||
The `Nip55Signer` class implements the [`ISigner`](/signer/) interface
|
||||
|
||||
```typescript
|
||||
class Nip55Signer implements ISigner {
|
||||
// Constructor
|
||||
constructor(private secret: string)
|
||||
|
||||
// ISigner implementation
|
||||
sign: (event: StampedEvent) => Promise<SignedEvent>
|
||||
getPubkey: () => Promise<string>
|
||||
nip04: { encrypt, decrypt }
|
||||
nip44: { encrypt, decrypt }
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Complete Example
|
||||
|
||||
```typescript
|
||||
import { Nip55Signer, getNip55 } from '@welshman/signer'
|
||||
import { createEvent, NOTE } from '@welshman/util'
|
||||
|
||||
async function example() {
|
||||
try {
|
||||
// Get available signing apps
|
||||
const apps = await getNip55()
|
||||
if (apps.length === 0) {
|
||||
throw new Error('No native signing apps available')
|
||||
}
|
||||
|
||||
// Create signer with first available app
|
||||
const signer = new Nip55Signer(apps[0].packageName)
|
||||
|
||||
// Get public key
|
||||
const pubkey = await signer.getPubkey()
|
||||
console.log('Public key:', pubkey)
|
||||
|
||||
// Sign an event
|
||||
const event = createEvent(NOTE, {
|
||||
content: "Hello from native app!",
|
||||
tags: [["t", "test"]]
|
||||
})
|
||||
const signedEvent = await signer.sign(event)
|
||||
console.log('Signed event:', signedEvent)
|
||||
|
||||
// Encrypt a message
|
||||
const encrypted = await signer.nip44.encrypt(
|
||||
recipientPubkey,
|
||||
"Secret message"
|
||||
)
|
||||
console.log('Encrypted:', encrypted)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Native signer error:', error)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Request Serialization
|
||||
|
||||
The signer implements a lock mechanism to prevent concurrent requests:
|
||||
|
||||
```typescript
|
||||
class Nip55Signer implements ISigner {
|
||||
#lock = Promise.resolve()
|
||||
#plugin = NostrSignerPlugin
|
||||
#packageName: string
|
||||
#packageNameSet = false
|
||||
|
||||
#then = async <T>(f: (signer: typeof NostrSignerPlugin) => Promise<T>) => {
|
||||
const promise = this.#lock.then(async () => {
|
||||
if (!this.#packageNameSet) {
|
||||
await this.#initialize()
|
||||
}
|
||||
return f(this.#plugin)
|
||||
})
|
||||
|
||||
this.#lock = promise.then(() => Promise.resolve())
|
||||
|
||||
return promise
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Public Key Caching
|
||||
|
||||
The signer caches the public key to minimize native app interactions:
|
||||
|
||||
```typescript
|
||||
class Nip55Signer {
|
||||
#npub?: string
|
||||
#publicKey?: string
|
||||
|
||||
getPubkey = async (): Promise<string> => {
|
||||
return this.#then(async signer => {
|
||||
if (!this.#publicKey || !this.#npub) {
|
||||
const {npub} = await signer.getPublicKey()
|
||||
this.#npub = npub
|
||||
const {data} = decode(npub)
|
||||
this.#publicKey = data as string
|
||||
}
|
||||
return this.#publicKey
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Platform Support
|
||||
|
||||
- iOS: Requires compatible signing app
|
||||
- Android: Requires compatible signing app
|
||||
- Operations availability depends on native app implementation
|
||||
- Some features might be platform-specific
|
||||
@@ -0,0 +1,151 @@
|
||||
# NIP-59 (Gift Wrap) Implementation
|
||||
|
||||
The `Nip59` class provides utilities for implementing the Gift Wrap protocol (NIP-59), allowing secure event wrapping and unwrapping. This implementation works with any signer that supports encryption, making it versatile for different authentication methods.
|
||||
|
||||
## Key Features
|
||||
|
||||
- Event wrapping (encryption) for specific recipients
|
||||
- Event unwrapping (decryption) of received wrapped events
|
||||
- Automatic ephemeral wrapper generation
|
||||
- Caching of previously unwrapped events
|
||||
- Compatible with all signer implementations
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { Nip59 } from '@welshman/signer'
|
||||
import { createEvent, DIRECT_MESSAGE } from '@welshman/util'
|
||||
|
||||
// Create a NIP-59 instance from any signer
|
||||
const nip59 = Nip59.fromSigner(mySigner)
|
||||
|
||||
// Wrap an event
|
||||
const rumor = await nip59.wrap(
|
||||
recipientPubkey,
|
||||
createEvent(DIRECT_MESSAGE, {
|
||||
content: "Secret message",
|
||||
tags: [["p", recipientPubkey]]
|
||||
})
|
||||
)
|
||||
|
||||
// The wrapped event to publish
|
||||
const wrappedEvent = rumor.wrap
|
||||
|
||||
// Unwrap a received event
|
||||
const unwrapped = await nip59.unwrap(receivedWrappedEvent)
|
||||
```
|
||||
|
||||
### Wrapping Process
|
||||
|
||||
The wrapping process involves multiple steps:
|
||||
|
||||
1. Create the rumor (original event)
|
||||
2. Create the seal (encrypted rumor)
|
||||
3. Create the wrap (encrypted seal)
|
||||
|
||||
```typescript
|
||||
export const wrap = async (
|
||||
signer: ISigner,
|
||||
wrapper: ISigner,
|
||||
pubkey: string,
|
||||
template: StampedEvent,
|
||||
tags: string[][] = []
|
||||
) => {
|
||||
const rumor = await getRumor(signer, template)
|
||||
const seal = await getSeal(signer, pubkey, rumor)
|
||||
const wrap = await getWrap(wrapper, pubkey, seal, tags)
|
||||
|
||||
return Object.assign(rumor, {wrap})
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor & Factory Methods
|
||||
|
||||
```typescript
|
||||
class Nip59 {
|
||||
// Constructor
|
||||
constructor(signer: ISigner, wrapper?: ISigner)
|
||||
|
||||
// Factory Methods
|
||||
static fromSigner(signer: ISigner): Nip59
|
||||
static fromSecret(secret: string): Nip59
|
||||
|
||||
// Instance Methods
|
||||
|
||||
/**
|
||||
* Wraps an event for a specific recipient
|
||||
* @param pubkey Recipient's public key
|
||||
* @param template The event to wrap
|
||||
* @param tags Additional tags for the wrap event (optional)
|
||||
* @returns Promise<UnwrappedEvent> Original event and its wrapped version
|
||||
*/
|
||||
wrap(
|
||||
pubkey: string,
|
||||
template: StampedEvent,
|
||||
tags?: string[][]
|
||||
): Promise<UnwrappedEvent>
|
||||
|
||||
/**
|
||||
* Unwraps a received wrapped event
|
||||
* @param event The wrapped event to decrypt
|
||||
* @returns Promise<UnwrappedEvent> The original unwrapped event
|
||||
*/
|
||||
unwrap(event: SignedEvent): Promise<UnwrappedEvent>
|
||||
|
||||
/**
|
||||
* Creates a new instance with a specific wrapper signer
|
||||
* @param wrapper Signer to use for wrapping events
|
||||
* @returns Nip59 New instance with the specified wrapper
|
||||
*/
|
||||
withWrapper(wrapper: ISigner): Nip59
|
||||
}
|
||||
```
|
||||
|
||||
## Detailed Examples
|
||||
|
||||
### Basic Wrapping & Unwrapping
|
||||
|
||||
```typescript
|
||||
import { Nip59, Nip01Signer } from '@welshman/signer'
|
||||
import { createEvent, DIRECT_MESSAGE } from '@welshman/util'
|
||||
|
||||
async function example() {
|
||||
// Create NIP-59 instance
|
||||
const signer = new Nip01Signer(mySecret)
|
||||
const nip59 = Nip59.fromSigner(signer)
|
||||
|
||||
// Create and wrap an event
|
||||
const event = createEvent(DIRECT_MESSAGE, {
|
||||
content: "Secret message",
|
||||
tags: [["p", recipientPubkey]]
|
||||
})
|
||||
|
||||
const rumor = await nip59.wrap(recipientPubkey, event)
|
||||
|
||||
// rumor contains:
|
||||
// - The original event (rumor)
|
||||
// - The wrapped version to publish (rumor.wrap)
|
||||
|
||||
// Later, unwrap a received event
|
||||
const unwrapped = await nip59.unwrap(receivedEvent)
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Wrapper Signer
|
||||
|
||||
```typescript
|
||||
import { Nip59, Nip01Signer } from '@welshman/signer'
|
||||
|
||||
// Create with specific wrapper
|
||||
const nip59 = new Nip59(
|
||||
mainSigner,
|
||||
Nip01Signer.ephemeral() // Custom wrapper
|
||||
)
|
||||
|
||||
// Or add wrapper to existing instance
|
||||
const nip59WithWrapper = nip59.withWrapper(
|
||||
Nip01Signer.ephemeral()
|
||||
)
|
||||
```
|
||||
@@ -0,0 +1,60 @@
|
||||
# Basic Utilities
|
||||
|
||||
## synced
|
||||
Creates a writable store that automatically synchronizes its value with localStorage.
|
||||
|
||||
```typescript
|
||||
const myStore = synced('storage-key', 'default value');
|
||||
```
|
||||
|
||||
## getter
|
||||
Creates a function that returns the current value of a store without subscribing to it.
|
||||
|
||||
```typescript
|
||||
const myStore = writable('value');
|
||||
const getValue = getter(myStore);
|
||||
|
||||
```
|
||||
|
||||
## withGetter
|
||||
Enhances a store by adding a getter method to access its current value.
|
||||
|
||||
```typescript
|
||||
const myStore = withGetter(writable('value'));
|
||||
console.log(myStore.get()); // 'value'
|
||||
```
|
||||
|
||||
## throttled
|
||||
Creates a store that limits how often subscribers receive updates.
|
||||
|
||||
```typescript
|
||||
const throttledStore = throttled(1000, myStore); // Updates at most once per second
|
||||
```
|
||||
|
||||
## custom
|
||||
Creates a custom store with optional throttling and custom set behavior.
|
||||
|
||||
```typescript
|
||||
const customStore = custom(
|
||||
set => {
|
||||
// Setup logic
|
||||
return () => {
|
||||
// Cleanup logic
|
||||
};
|
||||
},
|
||||
{ throttle: 1000 }
|
||||
);
|
||||
```
|
||||
|
||||
## adapter
|
||||
Creates a derived store that can transform values between two types while maintaining two-way binding.
|
||||
|
||||
```typescript
|
||||
const adaptedStore = adapter({
|
||||
store: originalStore,
|
||||
forward: (source) => /* transform to target */,
|
||||
backward: (target) => /* transform back to source */
|
||||
});
|
||||
```
|
||||
|
||||
This is particularly useful when you need to transform data structures while maintaining the ability to update the original store.
|
||||
@@ -0,0 +1,122 @@
|
||||
# Event-Based Stores
|
||||
|
||||
## deriveEventsMapped
|
||||
Creates a store that maintains a mapped collection of events from a repository.
|
||||
Useful when you want to transform events into a different data structure while maintaining reactivity.
|
||||
|
||||
```typescript
|
||||
import {Repository, NAMED_PEOPLE, type TrustedEvent} from '@welshman/util'
|
||||
import {deriveEventsMapped} from '@welshman/store'
|
||||
|
||||
interface UserProfile {
|
||||
name: string;
|
||||
about: string;
|
||||
pubkey: string;
|
||||
}
|
||||
|
||||
const repository = new Repository()
|
||||
|
||||
const profiles = deriveEventsMapped<UserProfile>(repository, {
|
||||
filters: [{kinds: [PROFILE]}],
|
||||
eventToItem: (event: TrustedEvent) => ({
|
||||
name: event.content.name,
|
||||
about: event.content.about,
|
||||
pubkey: event.pubkey,
|
||||
}),
|
||||
itemToEvent: (profile: UserProfile) => ({
|
||||
// Convert profile back to event format
|
||||
kind: PROFILE,
|
||||
pubkey: profile.pubkey,
|
||||
content: {
|
||||
name: profile.name,
|
||||
about: profile.about,
|
||||
}
|
||||
}),
|
||||
throttle: 1000, // Optional: throttle updates
|
||||
includeDeleted: false // Optional: exclude deleted events
|
||||
})
|
||||
```
|
||||
|
||||
## deriveEvents
|
||||
Creates a store that maintains a collection of raw events from a repository.
|
||||
Useful when you want to work directly with events without transformation.
|
||||
|
||||
```typescript
|
||||
import {Repository} from '@welshman/util'
|
||||
import {deriveEvents} from '@welshman/store'
|
||||
|
||||
const repository = new Repository()
|
||||
|
||||
const textNotes = deriveEvents(repository, {
|
||||
filters: [{kinds: [NOTE], // kind 1 = text note
|
||||
authors: ['pubkey1', 'pubkey2']}],
|
||||
throttle: 500,
|
||||
includeDeleted: false
|
||||
})
|
||||
|
||||
// Subscribe to changes
|
||||
textNotes.subscribe(events => {
|
||||
console.log('New text notes:', events)
|
||||
})
|
||||
```
|
||||
|
||||
## deriveEvent
|
||||
Creates a store that tracks a single event by its ID or address.
|
||||
Returns a derived store containing the event or undefined.
|
||||
|
||||
```typescript
|
||||
import {Repository} from '@welshman/util'
|
||||
import {deriveEvent} from '@welshman/store'
|
||||
|
||||
const repository = new Repository()
|
||||
|
||||
const specificEvent = deriveEvent(repository, 'event_id_or_address')
|
||||
|
||||
// Subscribe to changes of the specific event
|
||||
specificEvent.subscribe(event => {
|
||||
if (event) {
|
||||
console.log('Event updated:', event)
|
||||
} else {
|
||||
console.log('Event not found')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## deriveIsDeleted
|
||||
Creates a store that tracks whether an event has been deleted. Returns a boolean store.
|
||||
|
||||
```typescript
|
||||
import {Repository} from '@welshman/util'
|
||||
import {deriveIsDeleted} from '@welshman/store'
|
||||
|
||||
const repository = new Repository()
|
||||
const event = /* your event */
|
||||
|
||||
const isDeleted = deriveIsDeleted(repository, event)
|
||||
|
||||
// Subscribe to deletion status changes
|
||||
isDeleted.subscribe(deleted => {
|
||||
console.log('Event deleted status:', deleted)
|
||||
})
|
||||
```
|
||||
|
||||
## deriveIsDeletedByAddress
|
||||
Creates a store that tracks whether an event has been deleted by address.
|
||||
Similar to deriveIsDeleted but checks deletion by address instead of event ID.
|
||||
|
||||
```typescript
|
||||
import {Repository} from '@welshman/util'
|
||||
import {deriveIsDeletedByAddress} from '@welshman/store'
|
||||
|
||||
const repository = new Repository()
|
||||
const event = /* your event */
|
||||
|
||||
const isDeletedByAddress = deriveIsDeletedByAddress(repository, event)
|
||||
|
||||
// Subscribe to address-based deletion status changes
|
||||
isDeletedByAddress.subscribe(deleted => {
|
||||
if (deleted) {
|
||||
console.log('Event has been deleted by address')
|
||||
}
|
||||
})
|
||||
```
|
||||
@@ -0,0 +1,18 @@
|
||||
# @welshman/store
|
||||
|
||||
A utility package designed specifically for Svelte applications, providing enhanced store functionality and utilities for managing state. While it's primarily built for use with Svelte's store system, the concepts could be valuable for developers familiar with reactive programming patterns like RxJS.
|
||||
|
||||
## What's Included
|
||||
|
||||
- **Basic Utilities** - Enhanced stores with persistence, throttling, and getter methods
|
||||
- **Event-Based Stores** - Specialized stores for working with nostr events and repositories
|
||||
- **Custom Adapters** - Two-way data transformation with maintained reactivity
|
||||
- **Persistence Layer** - Automatic localStorage synchronization
|
||||
- **Performance Optimizations** - Throttled updates and efficient subscription management
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @welshman/store
|
||||
```
|
||||
@@ -0,0 +1,110 @@
|
||||
# Nostr Address
|
||||
|
||||
The Address module provides utilities for working with Nostr Addresses (NIP-19 naddr format) and handles the conversion between different address formats.
|
||||
|
||||
## Address Class
|
||||
|
||||
```typescript
|
||||
class Address {
|
||||
constructor(
|
||||
readonly kind: number, // Event kind
|
||||
readonly pubkey: string, // Author's public key
|
||||
readonly identifier: string, // Unique identifier (d-tag)
|
||||
readonly relays?: string[], // Optional relay hints
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Creating Addresses
|
||||
|
||||
### From Components
|
||||
```typescript
|
||||
const address = new Address(
|
||||
30023, // kind (e.g., long-form article)
|
||||
'ab82...123', // pubkey
|
||||
'my-article-title', // identifier
|
||||
['wss://relay.example.com'] // relays
|
||||
)
|
||||
```
|
||||
|
||||
### From String Format
|
||||
```typescript
|
||||
// Parse "kind:pubkey:identifier" format
|
||||
const address = Address.from('30023:ab82...123:my-article-title')
|
||||
|
||||
// With optional relays
|
||||
const address = Address.from(
|
||||
'30023:ab82...123:my-article-title',
|
||||
['wss://relay.example.com']
|
||||
)
|
||||
```
|
||||
|
||||
### From Naddr
|
||||
```typescript
|
||||
// Parse naddr format
|
||||
const address = Address.fromNaddr('naddr1...')
|
||||
```
|
||||
|
||||
### From Event
|
||||
```typescript
|
||||
const address = Address.fromEvent(event, relays)
|
||||
```
|
||||
|
||||
## Converting Addresses
|
||||
|
||||
### To String
|
||||
```typescript
|
||||
const address = new Address(kind, pubkey, identifier)
|
||||
address.toString() // => "kind:pubkey:identifier"
|
||||
```
|
||||
|
||||
### To Naddr
|
||||
```typescript
|
||||
const address = new Address(kind, pubkey, identifier, relays)
|
||||
address.toNaddr() // => "naddr1..."
|
||||
```
|
||||
|
||||
## Utility Functions
|
||||
|
||||
### Check Address Format
|
||||
```typescript
|
||||
// Check if string is valid address format
|
||||
Address.isAddress('30023:abc...123:title') // => true
|
||||
Address.isAddress('not-an-address') // => false
|
||||
```
|
||||
|
||||
### Get Address from Event
|
||||
```typescript
|
||||
import { getAddress } from '@welshman/util'
|
||||
|
||||
// Extract address from event
|
||||
const address = getAddress(event)
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Working with Long-form Content
|
||||
```typescript
|
||||
// Create address for article
|
||||
const articleAddress = new Address(
|
||||
30023, // Long-form content kind
|
||||
authorPubkey,
|
||||
'my-article-slug',
|
||||
['wss://relay.example.com']
|
||||
)
|
||||
|
||||
// Convert to string format for storage
|
||||
const addressString = articleAddress.toString()
|
||||
|
||||
// Convert to naddr for sharing
|
||||
const shareableAddress = articleAddress.toNaddr()
|
||||
```
|
||||
|
||||
### Handling Replaceable Events
|
||||
```typescript
|
||||
// Create address from replaceable event
|
||||
const address = Address.fromEvent(event)
|
||||
|
||||
// Store latest version using address as key
|
||||
storage.set(address.toString(), event)
|
||||
```
|
||||
@@ -0,0 +1,162 @@
|
||||
# Encryptable
|
||||
|
||||
The Encryptable module provides a system for handling encrypted Nostr events, particularly useful for private content like muted lists, bookmarks, or other encrypted user data.
|
||||
|
||||
## Core Types
|
||||
|
||||
### Encrypt Function
|
||||
```typescript
|
||||
type Encrypt = (x: string) => Promise<string>
|
||||
```
|
||||
|
||||
### Encryptable Updates
|
||||
```typescript
|
||||
type EncryptableUpdates = {
|
||||
content?: string
|
||||
tags?: string[][]
|
||||
}
|
||||
```
|
||||
|
||||
### Decrypted Event
|
||||
```typescript
|
||||
type DecryptedEvent = TrustedEvent & {
|
||||
plaintext: EncryptableUpdates
|
||||
}
|
||||
```
|
||||
|
||||
## Encryptable Class
|
||||
|
||||
```typescript
|
||||
class Encryptable<T extends EventTemplate> {
|
||||
constructor(
|
||||
readonly event: Partial<T>, // Base event template
|
||||
readonly updates: EncryptableUpdates // Plaintext updates
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Encryption
|
||||
```typescript
|
||||
// Create encryptable event
|
||||
const encryptable = new Encryptable(
|
||||
{ kind: 10000 }, // Base event
|
||||
{ content: "secret content" } // Plaintext updates
|
||||
)
|
||||
|
||||
// Encrypt and get final event
|
||||
const event = await encryptable.reconcile(encryptFn)
|
||||
```
|
||||
|
||||
### Private Lists
|
||||
```typescript
|
||||
// Create private mute list
|
||||
const muteList = new Encryptable(
|
||||
{
|
||||
kind: 10000, // Mute list kind
|
||||
tags: [] // Public tags
|
||||
},
|
||||
{
|
||||
content: JSON.stringify(['pubkey1', 'pubkey2']), // Private content
|
||||
tags: [['p', 'pubkey1'], ['p', 'pubkey2']] // Private tags
|
||||
}
|
||||
)
|
||||
|
||||
// Encrypt for publishing
|
||||
const encrypted = await muteList.reconcile(async (content) => {
|
||||
return await nip04.encrypt(pubkey, content)
|
||||
})
|
||||
```
|
||||
|
||||
### Updating Encrypted Content
|
||||
```typescript
|
||||
// Create encryptable from existing event
|
||||
const existing = {
|
||||
kind: 10000,
|
||||
content: encryptedContent,
|
||||
tags: publicTags
|
||||
}
|
||||
|
||||
// Add new encrypted content
|
||||
const updated = new Encryptable(
|
||||
existing,
|
||||
{
|
||||
content: JSON.stringify(newContent),
|
||||
tags: newPrivateTags
|
||||
}
|
||||
)
|
||||
|
||||
const final = await updated.reconcile(encrypt)
|
||||
```
|
||||
|
||||
## Helper Functions
|
||||
|
||||
### Create Decrypted Event
|
||||
```typescript
|
||||
import { asDecryptedEvent } from '@welshman/util'
|
||||
|
||||
// Add plaintext content to event
|
||||
const decrypted = asDecryptedEvent(
|
||||
event,
|
||||
{
|
||||
content: decryptedContent,
|
||||
tags: decryptedTags
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Private Bookmarks
|
||||
```typescript
|
||||
// Create private bookmark list
|
||||
const bookmarks = new Encryptable(
|
||||
{
|
||||
kind: 10003,
|
||||
tags: [['d', 'bookmarks']] // Public identifier
|
||||
},
|
||||
{
|
||||
content: JSON.stringify([
|
||||
{ id: 'note1', title: 'Secret Note' }
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
// Encrypt for publishing
|
||||
const event = await bookmarks.reconcile(async (content) => {
|
||||
return await myEncryptionFunction(content)
|
||||
})
|
||||
```
|
||||
|
||||
### Encrypted Group Membership
|
||||
```typescript
|
||||
// Create private group member list
|
||||
const members = new Encryptable(
|
||||
{
|
||||
kind: 30000,
|
||||
tags: [['d', 'group-members']]
|
||||
},
|
||||
{
|
||||
tags: members.map(m => ['p', m.pubkey, m.role])
|
||||
}
|
||||
)
|
||||
|
||||
const encrypted = await members.reconcile(encrypt)
|
||||
```
|
||||
|
||||
### Updating Private Content
|
||||
```typescript
|
||||
function updatePrivateList(event: DecryptedEvent, newItems: string[]) {
|
||||
return new Encryptable(
|
||||
event,
|
||||
{
|
||||
content: JSON.stringify(newItems)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Usage
|
||||
const updated = updatePrivateList(existingEvent, newItems)
|
||||
const final = await updated.reconcile(encrypt)
|
||||
```
|
||||
@@ -0,0 +1,185 @@
|
||||
# Nostr Events
|
||||
|
||||
The Events module provides comprehensive type definitions and utilities for working with Nostr events, including helper functions for event creation, validation, and manipulation.
|
||||
|
||||
## Event Types Hierarchy
|
||||
|
||||
```typescript
|
||||
// Base event with content and tags
|
||||
interface EventContent {
|
||||
tags: string[][]
|
||||
content: string
|
||||
}
|
||||
|
||||
// Base event with kind
|
||||
interface EventTemplate extends EventContent {
|
||||
kind: number
|
||||
}
|
||||
|
||||
// Event with timestamp
|
||||
interface StampedEvent extends EventTemplate {
|
||||
created_at: number
|
||||
}
|
||||
|
||||
// Event with author
|
||||
interface OwnedEvent extends StampedEvent {
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
// Event with ID
|
||||
interface HashedEvent extends OwnedEvent {
|
||||
id: string
|
||||
}
|
||||
|
||||
// Event with signature
|
||||
interface SignedEvent extends HashedEvent {
|
||||
sig: string
|
||||
[verifiedSymbol]?: boolean
|
||||
}
|
||||
|
||||
// Event with wrapped content
|
||||
interface UnwrappedEvent extends HashedEvent {
|
||||
wrap: SignedEvent
|
||||
}
|
||||
|
||||
// Event that can be either signed or wrapped
|
||||
type TrustedEvent = HashedEvent & {
|
||||
sig?: string
|
||||
wrap?: SignedEvent
|
||||
[verifiedSymbol]?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
## Event Creation
|
||||
|
||||
### Create Basic Event
|
||||
```typescript
|
||||
import { createEvent } from '@welshman/util'
|
||||
|
||||
const event = createEvent(
|
||||
1, // kind
|
||||
{
|
||||
content: "Hello Nostr!",
|
||||
tags: [["t", "nostr"]],
|
||||
created_at: now() // Optional, defaults to current time
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Type Guards
|
||||
|
||||
```typescript
|
||||
// Check event types
|
||||
isEventTemplate(event): boolean
|
||||
isStampedEvent(event): boolean
|
||||
isOwnedEvent(event): boolean
|
||||
isHashedEvent(event): boolean
|
||||
isSignedEvent(event): boolean
|
||||
isUnwrappedEvent(event): boolean
|
||||
isTrustedEvent(event): boolean
|
||||
```
|
||||
|
||||
## Event Type Conversion
|
||||
|
||||
```typescript
|
||||
// Convert to specific event types
|
||||
asEventTemplate(event): EventTemplate
|
||||
asStampedEvent(event): StampedEvent
|
||||
asOwnedEvent(event): OwnedEvent
|
||||
asHashedEvent(event): HashedEvent
|
||||
asSignedEvent(event): SignedEvent
|
||||
asUnwrappedEvent(event): UnwrappedEvent
|
||||
asTrustedEvent(event): TrustedEvent
|
||||
```
|
||||
|
||||
## Event Utilities
|
||||
|
||||
### Event Validation
|
||||
```typescript
|
||||
// Check if event has valid signature
|
||||
hasValidSignature(event: SignedEvent): boolean
|
||||
|
||||
// Get event identifier (d tag)
|
||||
getIdentifier(event: EventTemplate): string | undefined
|
||||
```
|
||||
|
||||
### Event References
|
||||
```typescript
|
||||
// Get event ID or address
|
||||
getIdOrAddress(event: HashedEvent): string
|
||||
|
||||
// Get both ID and address (if replaceable)
|
||||
getIdAndAddress(event: HashedEvent): string[]
|
||||
```
|
||||
|
||||
### Event Type Checking
|
||||
```typescript
|
||||
// Check event properties
|
||||
isEphemeral(event: EventTemplate): boolean
|
||||
isReplaceable(event: EventTemplate): boolean
|
||||
isPlainReplaceable(event: EventTemplate): boolean
|
||||
isParameterizedReplaceable(event: EventTemplate): boolean
|
||||
```
|
||||
|
||||
### Thread & Reply Handling
|
||||
```typescript
|
||||
// Get thread information
|
||||
getAncestors(event: EventTemplate): { roots: string[], replies: string[] }
|
||||
|
||||
// Get parent references
|
||||
getParentIdsAndAddrs(event: EventTemplate): string[]
|
||||
getParentIdOrAddr(event: EventTemplate): string | undefined
|
||||
getParentId(event: EventTemplate): string | undefined
|
||||
getParentAddr(event: EventTemplate): string | undefined
|
||||
|
||||
// Check reply relationship
|
||||
isChildOf(child: EventTemplate, parent: HashedEvent): boolean
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Creating and Processing Events
|
||||
|
||||
```typescript
|
||||
// Create new event
|
||||
const event = createEvent(1, {
|
||||
content: "Hello world!",
|
||||
tags: [["t", "greeting"]]
|
||||
})
|
||||
|
||||
// Process based on type
|
||||
if (isSignedEvent(event)) {
|
||||
// Handle signed event
|
||||
if (hasValidSignature(event)) {
|
||||
processValidEvent(event)
|
||||
}
|
||||
} else if (isUnwrappedEvent(event)) {
|
||||
// Handle wrapped event
|
||||
processWrappedEvent(event)
|
||||
}
|
||||
```
|
||||
|
||||
### Working with Threads
|
||||
|
||||
```typescript
|
||||
// Get thread context
|
||||
const ancestors = getAncestors(event)
|
||||
const rootId = ancestors.roots[0]
|
||||
const replyTo = ancestors.replies[0]
|
||||
|
||||
// Check threading
|
||||
if (isChildOf(event, parentEvent)) {
|
||||
// Handle reply
|
||||
}
|
||||
```
|
||||
|
||||
### Type Conversion
|
||||
|
||||
```typescript
|
||||
// Convert to needed type
|
||||
const template = asEventTemplate(event)
|
||||
const stamped = asStampedEvent(event)
|
||||
const owned = asOwnedEvent(event)
|
||||
const hashed = asHashedEvent(event)
|
||||
const signed = asSignedEvent(event)
|
||||
```
|
||||
@@ -0,0 +1,165 @@
|
||||
# Filters
|
||||
|
||||
The Filters module provides utilities for creating, manipulating, and matching Nostr event filters.
|
||||
It includes support for filter operations, optimization, and time-based filtering.
|
||||
|
||||
## Core Types
|
||||
|
||||
```typescript
|
||||
interface Filter {
|
||||
ids?: string[] // Match specific event IDs
|
||||
kinds?: number[] // Match event kinds
|
||||
authors?: string[] // Match author pubkeys
|
||||
since?: number // Match events since timestamp
|
||||
until?: number // Match events until timestamp
|
||||
limit?: number // Limit number of results
|
||||
search?: string // Text search
|
||||
[key: `#${string}`]: string[] // Tag filters
|
||||
}
|
||||
```
|
||||
|
||||
## Filter Operations
|
||||
|
||||
### Match Events
|
||||
```typescript
|
||||
// Match single filter
|
||||
matchFilter(filter: Filter, event: HashedEvent): boolean
|
||||
|
||||
// Match multiple filters
|
||||
matchFilters(filters: Filter[], event: HashedEvent): boolean
|
||||
```
|
||||
|
||||
### Combine Filters
|
||||
```typescript
|
||||
// Combine filters with OR operation
|
||||
unionFilters(filters: Filter[]): Filter[]
|
||||
|
||||
// Combine filters with AND operation
|
||||
intersectFilters(groups: Filter[][]): Filter[]
|
||||
```
|
||||
|
||||
### Filter Utilities
|
||||
```typescript
|
||||
// Get unique filter ID
|
||||
getFilterId(filter: Filter): string
|
||||
|
||||
// Calculate filter group
|
||||
calculateFilterGroup(filter: Filter): string
|
||||
|
||||
// Get filters for event IDs or addresses
|
||||
getIdFilters(idsOrAddresses: string[]): Filter[]
|
||||
|
||||
// Get filters for reply events
|
||||
getReplyFilters(events: TrustedEvent[], filter?: Filter): Filter[]
|
||||
|
||||
// Add repost filters
|
||||
addRepostFilters(filters: Filter[]): Filter[]
|
||||
```
|
||||
|
||||
## Time Constants
|
||||
|
||||
```typescript
|
||||
// Unix epoch for Nostr (2021-01-01)
|
||||
export const EPOCH = 1609459200
|
||||
|
||||
// One day in seconds
|
||||
export const DAY = 86400
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Filtering
|
||||
|
||||
```typescript
|
||||
// Create basic filter
|
||||
const filter: Filter = {
|
||||
kinds: [1], // Text notes
|
||||
authors: ['pubkey1', 'pubkey2'],
|
||||
since: now() - 24 * 60 * 60, // Last 24 hours
|
||||
limit: 100
|
||||
}
|
||||
|
||||
// Match event against filter
|
||||
if (matchFilter(filter, event)) {
|
||||
processEvent(event)
|
||||
}
|
||||
```
|
||||
|
||||
### Combining Filters
|
||||
|
||||
```typescript
|
||||
// Union of filters (OR)
|
||||
const combinedFilters = unionFilters([
|
||||
{ kinds: [1], authors: ['pub1'] },
|
||||
{ kinds: [1], authors: ['pub2'] }
|
||||
])
|
||||
|
||||
// Intersection of filters (AND)
|
||||
const intersectedFilters = intersectFilters([
|
||||
[{ kinds: [1] }],
|
||||
[{ authors: ['pub1'] }]
|
||||
])
|
||||
```
|
||||
|
||||
### Time-based Filtering
|
||||
|
||||
```typescript
|
||||
// Filter events from specific time range
|
||||
const timeFilter: Filter = {
|
||||
since: now() - 7 * DAY, // Last week
|
||||
until: now(),
|
||||
limit: 100
|
||||
}
|
||||
|
||||
// Guess appropriate time window
|
||||
const delta = guessFilterDelta([timeFilter])
|
||||
```
|
||||
|
||||
### Tag Filtering
|
||||
|
||||
```typescript
|
||||
// Filter by tags
|
||||
const tagFilter: Filter = {
|
||||
'#t': ['nostr', 'bitcoin'], // Match hashtags
|
||||
'#p': ['pubkey1'], // Match mentions
|
||||
limit: 50
|
||||
}
|
||||
```
|
||||
|
||||
## Filter Optimization
|
||||
|
||||
### Trim Filters
|
||||
```typescript
|
||||
// Trim large filters to reasonable size
|
||||
const trimmedFilter = trimFilter(filter)
|
||||
const trimmedFilters = trimFilters(filters)
|
||||
```
|
||||
|
||||
### Filter Analysis
|
||||
```typescript
|
||||
// Get filter generality score
|
||||
const score = getFilterGenerality(filter)
|
||||
|
||||
// Get expected result count
|
||||
const count = getFilterResultCardinality(filter)
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Reply Chain Filters
|
||||
```typescript
|
||||
// Get filters for replies
|
||||
const replyFilters = getReplyFilters(events, {
|
||||
kinds: [1],
|
||||
limit: 100
|
||||
})
|
||||
```
|
||||
|
||||
### Repost Handling
|
||||
```typescript
|
||||
// Add filters for reposts
|
||||
const withReposts = addRepostFilters([
|
||||
{ kinds: [1] } // Original filter
|
||||
])
|
||||
// Results in filters for kinds 1, 6, and 16
|
||||
```
|
||||
@@ -0,0 +1,133 @@
|
||||
# Handlers (NIP-89)
|
||||
|
||||
The Handlers module provides functionality for working with handler recommendations and information (NIP-89).
|
||||
Handlers are events that describe which kinds a given application can display.
|
||||
|
||||
This module provides utilities for transforming these events into structured handler objects that applications can easily process.
|
||||
|
||||
|
||||
## Types
|
||||
|
||||
### Handler Definition
|
||||
|
||||
```typescript
|
||||
type Handler = {
|
||||
kind: number // Event kind this handler can process
|
||||
name: string // Display name of the handler
|
||||
about: string // Description
|
||||
image: string // Icon or image URL
|
||||
identifier: string // Unique identifier (d-tag)
|
||||
event: TrustedEvent // Original handler event
|
||||
website?: string // Optional website URL
|
||||
lud16?: string // Optional Lightning address
|
||||
nip05?: string // Optional NIP-05 identifier
|
||||
}
|
||||
```
|
||||
|
||||
## Core Functions
|
||||
|
||||
### Reading Handlers
|
||||
```typescript
|
||||
function readHandlers(event: TrustedEvent): Handler[]
|
||||
|
||||
// Example
|
||||
const handlers = readHandlers(handlerEvent)
|
||||
handlers.forEach(handler => {
|
||||
console.log(`Handler for kind ${handler.kind}: ${handler.name}`)
|
||||
})
|
||||
```
|
||||
|
||||
### Handler Identification
|
||||
```typescript
|
||||
function getHandlerKey(handler: Handler): string
|
||||
// Returns "kind:address" format
|
||||
|
||||
function getHandlerAddress(event: TrustedEvent): string | undefined
|
||||
// Gets handler address from event tags
|
||||
```
|
||||
|
||||
### Display Formatting
|
||||
```typescript
|
||||
function displayHandler(
|
||||
handler?: Handler,
|
||||
fallback = ""
|
||||
): string
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Reading Handler Information
|
||||
```typescript
|
||||
const event = {
|
||||
kind: 31990, // Handler Information kind
|
||||
content: JSON.stringify({
|
||||
name: "Note Viewer",
|
||||
about: "Displays text notes with formatting",
|
||||
image: "https://example.com/icon.png"
|
||||
}),
|
||||
tags: [
|
||||
['k', '1'], // Handles kind 1 (text notes)
|
||||
['d', 'note-viewer']
|
||||
]
|
||||
}
|
||||
|
||||
const handlers = readHandlers(event)
|
||||
// Returns array of handlers defined in the event
|
||||
```
|
||||
|
||||
### Working with Handlers
|
||||
```typescript
|
||||
// Get unique handler identifier
|
||||
const key = getHandlerKey(handler)
|
||||
// => "1:30023:note-viewer" (kind:pubkey:identifier)
|
||||
|
||||
// Display handler name
|
||||
const name = displayHandler(handler, "Unknown Handler")
|
||||
// => "Note Viewer" or fallback if handler undefined
|
||||
|
||||
// Get handler address
|
||||
const address = getHandlerAddress(event)
|
||||
// Returns address from tags with 'web' marker or first address
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```typescript
|
||||
// Process handler information event
|
||||
function processHandlerEvent(event: TrustedEvent) {
|
||||
// Read all handlers from event
|
||||
const handlers = readHandlers(event)
|
||||
|
||||
// Process each handler
|
||||
handlers.forEach(handler => {
|
||||
// Generate unique key
|
||||
const key = getHandlerKey(handler)
|
||||
|
||||
// Store handler information
|
||||
handlerRegistry.set(key, {
|
||||
name: handler.name,
|
||||
kind: handler.kind,
|
||||
about: handler.about,
|
||||
image: handler.image,
|
||||
website: handler.website,
|
||||
address: getHandlerAddress(handler.event)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Find handler for event kind
|
||||
function findHandler(kind: number): Handler | undefined {
|
||||
return Array.from(handlerRegistry.values())
|
||||
.find(h => h.kind === kind)
|
||||
}
|
||||
|
||||
// Display handler information
|
||||
function renderHandler(handler: Handler) {
|
||||
return {
|
||||
title: displayHandler(handler, "Unknown"),
|
||||
description: handler.about,
|
||||
icon: handler.image,
|
||||
website: handler.website || null
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
# @welshman/util
|
||||
|
||||
A comprehensive utility package for Nostr application development, providing essential tools and types for working with Nostr events, addresses, profiles, and more.
|
||||
|
||||
## What's Included
|
||||
|
||||
- **Event Management**: Create, validate, and process Nostr events
|
||||
- **Repository**: In-memory event storage with querying and indexing
|
||||
- **Filters**: Advanced event filtering and subscription management
|
||||
- **Profiles**: User profile handling and formatting
|
||||
- **Lists**: Public and private list management
|
||||
- **Zaps**: Lightning Network payment integration
|
||||
- **Tags**: Comprehensive tag parsing and manipulation
|
||||
- **Addresses**: NIP-19 address handling
|
||||
- **Relays**: Relay URL handling, event dispatching and in-memory storage
|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
npm install @welshman/util
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
# Event Kinds
|
||||
|
||||
This module provides a comprehensive collection of Nostr event kind definitions and utilities.
|
||||
It includes standard NIP event kinds as well as commonly used application-specific kinds.
|
||||
|
||||
|
||||
## Kind Type Checkers
|
||||
|
||||
```typescript
|
||||
// Check if kind is ephemeral (should not be stored)
|
||||
export const isEphemeralKind = (kind: number): boolean
|
||||
|
||||
// Check if kind is replaceable (only latest event matters)
|
||||
export const isReplaceableKind = (kind: number): boolean
|
||||
|
||||
// Check if kind is plain replaceable (no parameters)
|
||||
export const isPlainReplaceableKind = (kind: number): boolean
|
||||
|
||||
// Check if kind is parameterized replaceable
|
||||
export const isParameterizedReplaceableKind = (kind: number): boolean
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Checking Event Types
|
||||
```typescript
|
||||
import { isReplaceableKind, PROFILE, NOTE } from '@welshman/util'
|
||||
|
||||
// Profile events are replaceable
|
||||
isReplaceableKind(PROFILE) // => true
|
||||
|
||||
// Notes are not replaceable
|
||||
isReplaceableKind(NOTE) // => false
|
||||
```
|
||||
|
||||
### Working with DVMs
|
||||
```typescript
|
||||
import {
|
||||
DVM_REQUEST_TEXT_SUMMARY,
|
||||
DVM_RESPONSE_TEXT_SUMMARY,
|
||||
isDVMKind
|
||||
} from '@welshman/util'
|
||||
|
||||
// Create DVM request
|
||||
const request = {
|
||||
kind: DVM_REQUEST_TEXT_SUMMARY,
|
||||
content: "Text to summarize"
|
||||
}
|
||||
|
||||
// Check for DVM events
|
||||
isDVMKind(event.kind) // => true for kinds 5000-7000
|
||||
```
|
||||
|
||||
### Handling Replaceable Events
|
||||
```typescript
|
||||
import {
|
||||
isReplaceableKind,
|
||||
PROFILE,
|
||||
LONG_FORM
|
||||
} from '@welshman/util'
|
||||
|
||||
function handleEvent(event) {
|
||||
if (isReplaceableKind(event.kind)) {
|
||||
// Only keep latest version
|
||||
replaceExistingEvent(event)
|
||||
} else {
|
||||
// Keep all versions
|
||||
storeNewEvent(event)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
# Links
|
||||
|
||||
A small module for handling Nostr URI manipulation.
|
||||
|
||||
## Core Functions
|
||||
|
||||
### fromNostrURI
|
||||
```typescript
|
||||
function fromNostrURI(s: string): string
|
||||
|
||||
// Examples
|
||||
fromNostrURI('nostr:npub1...') // => 'npub1...'
|
||||
fromNostrURI('nostr://npub1...') // => 'npub1...'
|
||||
fromNostrURI('note1...') // => 'note1...'
|
||||
```
|
||||
Removes the `nostr:` or `nostr://` protocol prefix from a Nostr URI.
|
||||
|
||||
### toNostrURI
|
||||
```typescript
|
||||
function toNostrURI(s: string): string
|
||||
|
||||
// Examples
|
||||
toNostrURI('npub1...') // => 'nostr:npub1...'
|
||||
toNostrURI('nostr:npub1...') // => 'nostr:npub1...' (unchanged)
|
||||
```
|
||||
Ensures a string has the `nostr:` protocol prefix.
|
||||
@@ -0,0 +1,175 @@
|
||||
# Lists
|
||||
|
||||
The Lists module provides utilities for working with Nostr lists, including both public and private lists (like bookmarks, mute lists, etc.). It handles list creation, encryption, and manipulation.
|
||||
|
||||
## Core Types
|
||||
|
||||
### List Parameters
|
||||
```typescript
|
||||
interface ListParams {
|
||||
kind: number // List kind (e.g., 10000 for mutes)
|
||||
}
|
||||
```
|
||||
|
||||
### List Structure
|
||||
```typescript
|
||||
interface List extends ListParams {
|
||||
publicTags: string[][] // Publicly visible tags
|
||||
privateTags: string[][] // Encrypted tags
|
||||
event?: DecryptedEvent // Original event if list exists
|
||||
}
|
||||
```
|
||||
|
||||
### Published List
|
||||
```typescript
|
||||
interface PublishedList extends List {
|
||||
event: DecryptedEvent // Required event for published lists
|
||||
}
|
||||
```
|
||||
|
||||
## List Creation
|
||||
|
||||
### Create New List
|
||||
```typescript
|
||||
function makeList(list: ListParams & Partial<List>): List
|
||||
|
||||
// Example
|
||||
const muteList = makeList({
|
||||
kind: 10000,
|
||||
publicTags: [['d', 'mutes']],
|
||||
privateTags: [['p', 'pubkey1'], ['p', 'pubkey2']]
|
||||
})
|
||||
```
|
||||
|
||||
### Read Existing List
|
||||
```typescript
|
||||
function readList(event: DecryptedEvent): PublishedList
|
||||
|
||||
// Example
|
||||
const list = readList(decryptedEvent)
|
||||
```
|
||||
|
||||
## List Operations
|
||||
|
||||
### Get All Tags
|
||||
```typescript
|
||||
function getListTags(list: List | undefined): string[][]
|
||||
|
||||
// Example
|
||||
const allTags = getListTags(list) // Combines public and private tags
|
||||
```
|
||||
|
||||
### Remove Items
|
||||
```typescript
|
||||
// Remove by predicate
|
||||
function removeFromListByPredicate(
|
||||
list: List,
|
||||
pred: (t: string[]) => boolean
|
||||
): Encryptable
|
||||
|
||||
// Remove by value
|
||||
function removeFromList(
|
||||
list: List,
|
||||
value: string
|
||||
): Encryptable
|
||||
```
|
||||
|
||||
### Add Items
|
||||
```typescript
|
||||
// Add public items
|
||||
function addToListPublicly(
|
||||
list: List,
|
||||
...tags: string[][]
|
||||
): Encryptable
|
||||
|
||||
// Add private items
|
||||
function addToListPrivately(
|
||||
list: List,
|
||||
...tags: string[][]
|
||||
): Encryptable
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Creating a Private List
|
||||
```typescript
|
||||
// Create new mute list
|
||||
const muteList = makeList({
|
||||
kind: 10000,
|
||||
publicTags: [
|
||||
['d', 'mutes'],
|
||||
['name', 'My Mute List']
|
||||
]
|
||||
})
|
||||
|
||||
// Add items privately
|
||||
const updated = addToListPrivately(
|
||||
muteList,
|
||||
['p', 'pubkey1'],
|
||||
['p', 'pubkey2']
|
||||
)
|
||||
|
||||
// Encrypt and publish
|
||||
const encrypted = await updated.reconcile(encrypt)
|
||||
```
|
||||
|
||||
### Reading and Updating Lists
|
||||
```typescript
|
||||
// Read existing list
|
||||
const list = readList(decryptedEvent)
|
||||
|
||||
// Remove item
|
||||
const removeItem = removeFromList(list, 'pubkey1')
|
||||
|
||||
// Add new items publicly
|
||||
const addItems = addToListPublicly(
|
||||
list,
|
||||
['p', 'pubkey3'],
|
||||
['p', 'pubkey4']
|
||||
)
|
||||
```
|
||||
|
||||
### Working with Tags
|
||||
```typescript
|
||||
// Get all list tags
|
||||
const tags = getListTags(list)
|
||||
|
||||
// Remove by predicate
|
||||
const noMentions = removeFromListByPredicate(
|
||||
list,
|
||||
tag => tag[0] === 'p'
|
||||
)
|
||||
```
|
||||
|
||||
## Common List Types
|
||||
|
||||
### Mute List
|
||||
```typescript
|
||||
const muteList = makeList({
|
||||
kind: 10000,
|
||||
publicTags: [['d', 'mutes']],
|
||||
privateTags: [] // Keep muted users private
|
||||
})
|
||||
```
|
||||
|
||||
### Bookmark List
|
||||
```typescript
|
||||
const bookmarks = makeList({
|
||||
kind: 10003,
|
||||
privateTags: [
|
||||
['e', 'id1'],
|
||||
['e', 'id2']
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
### Relay List
|
||||
```typescript
|
||||
const relays = makeList({
|
||||
kind: 10002,
|
||||
publicTags: [[
|
||||
['r', 'wss://relay1.com'],
|
||||
['r', 'wss://relay2.com', 'write']
|
||||
]
|
||||
})
|
||||
```
|
||||
@@ -0,0 +1,115 @@
|
||||
# Profile
|
||||
|
||||
The Profile module provides utilities for handling Nostr user profiles (kind 0 events), including profile creation, reading, and display formatting.
|
||||
|
||||
## Core Types
|
||||
|
||||
### Profile Structure
|
||||
```typescript
|
||||
interface Profile {
|
||||
name?: string // Display name
|
||||
nip05?: string // NIP-05 verification
|
||||
lud06?: string // Legacy Lightning address
|
||||
lud16?: string // Lightning address
|
||||
lnurl?: string // Lightning URL
|
||||
about?: string // Bio/description
|
||||
banner?: string // Banner image URL
|
||||
picture?: string // Profile picture URL
|
||||
website?: string // Website URL
|
||||
display_name?: string // Alternative display name
|
||||
event?: TrustedEvent // Original profile event
|
||||
}
|
||||
```
|
||||
|
||||
### Published Profile
|
||||
```typescript
|
||||
interface PublishedProfile extends Omit<Profile, "event"> {
|
||||
event: TrustedEvent // Required event for published profiles
|
||||
}
|
||||
```
|
||||
|
||||
## Core Functions
|
||||
|
||||
### Profile Creation & Reading
|
||||
```typescript
|
||||
// Create new profile
|
||||
function makeProfile(profile: Partial<Profile>): Profile
|
||||
|
||||
// Read profile from event
|
||||
function readProfile(event: TrustedEvent): PublishedProfile
|
||||
|
||||
// Create profile event
|
||||
function createProfile(profile: Profile): EventTemplate
|
||||
|
||||
// Edit existing profile
|
||||
function editProfile(profile: PublishedProfile): EventTemplate
|
||||
```
|
||||
|
||||
### Display Formatting
|
||||
```typescript
|
||||
// Format pubkey for display
|
||||
function displayPubkey(pubkey: string): string
|
||||
|
||||
// Format profile name for display
|
||||
function displayProfile(
|
||||
profile?: Profile,
|
||||
fallback = ""
|
||||
): string
|
||||
|
||||
// Check if profile has name
|
||||
function profileHasName(profile?: Profile): boolean
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Creating New Profile
|
||||
```typescript
|
||||
// Create basic profile
|
||||
const profile = makeProfile({
|
||||
name: "Alice",
|
||||
about: "Nostr user",
|
||||
picture: "https://example.com/avatar.jpg",
|
||||
lud16: "alice@getalby.com"
|
||||
})
|
||||
|
||||
// Create profile event
|
||||
const event = createProfile(profile)
|
||||
```
|
||||
|
||||
### Reading Profile
|
||||
```typescript
|
||||
// Read profile from event
|
||||
const profile = readProfile(profileEvent)
|
||||
|
||||
// Access profile data
|
||||
console.log(profile.name)
|
||||
console.log(profile.about)
|
||||
console.log(profile.lnurl) // Auto-generated from lud16/lud06
|
||||
```
|
||||
|
||||
### Displaying Profile
|
||||
```typescript
|
||||
// Display profile name
|
||||
const name = displayProfile(profile, "Anonymous")
|
||||
|
||||
// Display pubkey
|
||||
const shortPubkey = displayPubkey(profile.event.pubkey)
|
||||
// => "npub1abc...xyz"
|
||||
|
||||
// Check for name
|
||||
if (profileHasName(profile)) {
|
||||
showName(profile)
|
||||
} else {
|
||||
showPubkey(profile)
|
||||
}
|
||||
```
|
||||
|
||||
### Updating Profile
|
||||
```typescript
|
||||
// Edit existing profile
|
||||
const updated = editProfile({
|
||||
...existingProfile,
|
||||
name: "New Name",
|
||||
about: "Updated bio"
|
||||
})
|
||||
```
|
||||
@@ -0,0 +1,147 @@
|
||||
# Relay
|
||||
|
||||
The `Relay` module provides utilities for working with Nostr relays, including a local in-memory relay implementation that integrates with [Repository](/util/repository) for event storage.
|
||||
The Relay class extends EventEmitter to provide event-based communication.
|
||||
|
||||
## Core Components
|
||||
|
||||
### Relay Class
|
||||
```typescript
|
||||
class Relay<E extends HashedEvent = TrustedEvent> extends Emitter {
|
||||
constructor(readonly repository: Repository<E>)
|
||||
|
||||
// Emit events: 'EVENT', 'EOSE', 'OK'
|
||||
emit(type: string, ...args: any[]): boolean
|
||||
|
||||
// Handle relay messages
|
||||
send(type: string, ...message: any[]): void
|
||||
}
|
||||
```
|
||||
|
||||
### Relay Profile
|
||||
```typescript
|
||||
interface RelayProfile {
|
||||
url: string // Relay URL
|
||||
name?: string // Display name
|
||||
description?: string // Description
|
||||
pubkey?: string // Operator's pubkey
|
||||
contact?: string // Contact information
|
||||
software?: string // Software name
|
||||
version?: string // Software version
|
||||
supported_nips?: number[] // Supported NIPs
|
||||
limitation?: {
|
||||
min_pow_difficulty?: number
|
||||
payment_required?: boolean
|
||||
auth_required?: boolean
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Finding Relay Information
|
||||
|
||||
```typescript
|
||||
// Fetch relay information document
|
||||
async function getRelayProfile(url: string): Promise<RelayProfile | null> {
|
||||
try {
|
||||
const normalized = normalizeRelayUrl(url)
|
||||
// Convert ws/wss to http/https
|
||||
const httpUrl = normalized.replace(/^ws(s)?:\/\//, 'http$1://')
|
||||
|
||||
// Fetch relay information document
|
||||
const response = await fetch(`${httpUrl}`)
|
||||
const info = await response.json()
|
||||
|
||||
return {
|
||||
url: normalized,
|
||||
name: info.name,
|
||||
description: info.description,
|
||||
pubkey: info.pubkey,
|
||||
contact: info.contact,
|
||||
software: info.software,
|
||||
version: info.version,
|
||||
supported_nips: info.supported_nips,
|
||||
limitation: info.limitation
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch relay info for ${url}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## URL Utilities
|
||||
|
||||
### URL Validation
|
||||
```typescript
|
||||
// Check if URL is valid relay URL
|
||||
isRelayUrl(url: string): boolean
|
||||
|
||||
// Check if URL is .onion address
|
||||
isOnionUrl(url: string): boolean
|
||||
|
||||
// Check if URL is local
|
||||
isLocalUrl(url: string): boolean
|
||||
|
||||
// Check if URL is IP address
|
||||
isIPAddress(url: string): boolean
|
||||
|
||||
// Check if URL can be shared
|
||||
isShareableRelayUrl(url: string): boolean
|
||||
```
|
||||
|
||||
### URL Formatting
|
||||
```typescript
|
||||
// Normalize relay URL
|
||||
normalizeRelayUrl(url: string): string
|
||||
|
||||
// Format URL for display
|
||||
displayRelayUrl(url: string): string
|
||||
|
||||
// Format relay profile for display
|
||||
displayRelayProfile(profile?: RelayProfile, fallback = ""): string
|
||||
```
|
||||
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### URL Processing
|
||||
```typescript
|
||||
// Validate relay URL
|
||||
if (isRelayUrl(url)) {
|
||||
// Normalize for consistency
|
||||
const normalized = normalizeRelayUrl(url)
|
||||
|
||||
// Check if shareable
|
||||
if (isShareableRelayUrl(normalized)) {
|
||||
// Format for display
|
||||
const display = displayRelayUrl(normalized)
|
||||
showRelay(display)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Relay usage with Repository
|
||||
|
||||
```typescript
|
||||
// Create storage and relay interface
|
||||
const repository = new Repository()
|
||||
const relay = new Relay(repository)
|
||||
|
||||
// Subscribe to events
|
||||
relay.send("REQ", "sub_id", {
|
||||
kinds: [1],
|
||||
limit: 100
|
||||
})
|
||||
|
||||
// Listen for events
|
||||
relay.on("EVENT", (subId, event) => {
|
||||
console.log(`Received event for ${subId}:`, event)
|
||||
})
|
||||
|
||||
// Publish event
|
||||
// Will be stored in repository and sent to matching subscribers
|
||||
relay.send("EVENT", signedEvent)
|
||||
|
||||
// Close subscription
|
||||
relay.send("CLOSE", "sub_id")
|
||||
```
|
||||
@@ -0,0 +1,115 @@
|
||||
# Repository
|
||||
|
||||
The Repository module provides a robust in-memory event storage system with indexing, querying, and event replacement capabilities.
|
||||
|
||||
## Core Features
|
||||
|
||||
- Event storage and indexing
|
||||
- Query support with multiple filters
|
||||
- Event replacement and deletion tracking
|
||||
- Event update notifications
|
||||
- Optimized indexes for common queries
|
||||
|
||||
## Class Definition
|
||||
|
||||
```typescript
|
||||
class Repository<E extends HashedEvent = TrustedEvent> extends Emitter {
|
||||
// Storage indexes
|
||||
eventsById = new Map<string, E>()
|
||||
eventsByWrap = new Map<string, E>()
|
||||
eventsByAddress = new Map<string, E>()
|
||||
eventsByTag = new Map<string, E[]>()
|
||||
eventsByDay = new Map<number, E[]>()
|
||||
eventsByAuthor = new Map<string, E[]>()
|
||||
eventsByKind = new Map<number, E[]>()
|
||||
deletes = new Map<string, number>()
|
||||
}
|
||||
```
|
||||
|
||||
## Core Methods
|
||||
|
||||
### Event Management
|
||||
```typescript
|
||||
// Store or update event
|
||||
publish(event: E, opts = { shouldNotify: true }): boolean
|
||||
|
||||
// Get event by ID or address
|
||||
getEvent(idOrAddress: string): E | undefined
|
||||
|
||||
// Check if event exists
|
||||
hasEvent(event: E): boolean
|
||||
|
||||
// Remove event
|
||||
removeEvent(idOrAddress: string): void
|
||||
|
||||
// Check deletion status
|
||||
isDeleted(event: E): boolean
|
||||
isDeletedByAddress(event: E): boolean
|
||||
isDeletedById(event: E): boolean
|
||||
```
|
||||
|
||||
### Querying
|
||||
```typescript
|
||||
// Query events with filters
|
||||
query(
|
||||
filters: Filter[],
|
||||
opts = {
|
||||
includeDeleted: false,
|
||||
shouldSort: true
|
||||
}
|
||||
): E[]
|
||||
|
||||
// Dump all events
|
||||
dump(): E[]
|
||||
|
||||
// Load events in bulk
|
||||
load(events: E[], chunkSize = 1000): void
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Repository Operations
|
||||
```typescript
|
||||
// Create repository
|
||||
const repo = new Repository<TrustedEvent>()
|
||||
|
||||
// Add events
|
||||
repo.publish(event)
|
||||
|
||||
// Query events
|
||||
const events = repo.query([
|
||||
{ kinds: [1], limit: 100 }
|
||||
])
|
||||
|
||||
// Check event status
|
||||
if (!repo.isDeleted(event)) {
|
||||
processEvent(event)
|
||||
}
|
||||
```
|
||||
|
||||
### Bulk Operations
|
||||
```typescript
|
||||
// Load multiple events
|
||||
repo.load(events, 500) // Process in chunks of 500
|
||||
|
||||
// Get all events
|
||||
const allEvents = repo.dump()
|
||||
```
|
||||
|
||||
### Query Examples
|
||||
```typescript
|
||||
// Query with multiple filters
|
||||
const events = repo.query([
|
||||
// Recent events from specific authors
|
||||
{
|
||||
kinds: [1],
|
||||
authors: ['pub1', 'pub2'],
|
||||
since: now() - 24 * 60 * 60
|
||||
},
|
||||
// Events with specific tags
|
||||
{
|
||||
'#t': ['bitcoin', 'nostr'],
|
||||
limit: 50
|
||||
}
|
||||
])
|
||||
```
|
||||
@@ -0,0 +1,149 @@
|
||||
# Tags
|
||||
|
||||
The Tags module provides comprehensive utilities for working with Nostr event tags, including helpers for extracting, validating, and manipulating different types of tags.
|
||||
|
||||
## Core Functions
|
||||
|
||||
### Basic Tag Operations
|
||||
```typescript
|
||||
// Get tags by type(s)
|
||||
getTags(types: string | string[], tags: string[][]): string[][]
|
||||
|
||||
// Get single tag by type(s)
|
||||
getTag(types: string | string[], tags: string[][]): string[] | undefined
|
||||
|
||||
// Get tag values
|
||||
getTagValues(types: string | string[], tags: string[][]): string[]
|
||||
|
||||
// Get single tag value
|
||||
getTagValue(types: string | string[], tags: string[][]): string | undefined
|
||||
```
|
||||
|
||||
## Tag Type Extractors
|
||||
|
||||
### Event References
|
||||
```typescript
|
||||
// Get 'e' tags (event references)
|
||||
getEventTags(tags: string[][]): string[][]
|
||||
getEventTagValues(tags: string[][]): string[]
|
||||
|
||||
// Get 'a' tags (event addresses)
|
||||
getAddressTags(tags: string[][]): string[][]
|
||||
getAddressTagValues(tags: string[][]): string[]
|
||||
```
|
||||
|
||||
### Profile References
|
||||
```typescript
|
||||
// Get 'p' tags (pubkey references)
|
||||
getPubkeyTags(tags: string[][]): string[][]
|
||||
getPubkeyTagValues(tags: string[][]): string[]
|
||||
```
|
||||
|
||||
### Topics and Relays
|
||||
```typescript
|
||||
// Get 't' tags (topics/hashtags)
|
||||
getTopicTags(tags: string[][]): string[][]
|
||||
getTopicTagValues(tags: string[][]): string[]
|
||||
|
||||
// Get 'r' and 'relay' tags
|
||||
getRelayTags(tags: string[][]): string[][]
|
||||
getRelayTagValues(tags: string[][]): string[]
|
||||
```
|
||||
|
||||
### Groups and Kinds
|
||||
```typescript
|
||||
// Get group tags
|
||||
getGroupTags(tags: string[][]): string[][]
|
||||
getGroupTagValues(tags: string[][]): string[]
|
||||
|
||||
// Get 'k' tags (kind references)
|
||||
getKindTags(tags: string[][]): string[][]
|
||||
getKindTagValues(tags: string[][]): number[]
|
||||
```
|
||||
|
||||
## Thread Management
|
||||
|
||||
### Comment Tags
|
||||
```typescript
|
||||
// Get root and reply references
|
||||
getCommentTags(tags: string[][]): {
|
||||
roots: string[][],
|
||||
replies: string[][]
|
||||
}
|
||||
|
||||
getCommentTagValues(tags: string[][]): {
|
||||
roots: string[],
|
||||
replies: string[]
|
||||
}
|
||||
```
|
||||
|
||||
### Reply Tags
|
||||
```typescript
|
||||
// Get detailed reply structure
|
||||
getReplyTags(tags: string[][]): {
|
||||
roots: string[][], // Thread roots
|
||||
replies: string[][], // Direct replies
|
||||
mentions: string[][] // Mentions
|
||||
}
|
||||
|
||||
getReplyTagValues(tags: string[][]): {
|
||||
roots: string[],
|
||||
replies: string[],
|
||||
mentions: string[]
|
||||
}
|
||||
```
|
||||
|
||||
## Utility Functions
|
||||
|
||||
```typescript
|
||||
// Remove duplicate tags
|
||||
uniqTags(tags: string[][]): string[][]
|
||||
|
||||
// Parse imeta tags into array of tag arrays
|
||||
tagsFromIMeta(imeta: string[]): string[][]
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Tag Handling
|
||||
```typescript
|
||||
// Get specific tag types
|
||||
const pubkeys = getPubkeyTagValues(event.tags)
|
||||
const topics = getTopicTagValues(event.tags)
|
||||
const relays = getRelayTagValues(event.tags)
|
||||
|
||||
// Get multiple tag types
|
||||
const refs = getTags(['p', 'e'], event.tags)
|
||||
|
||||
// Get single tag
|
||||
const topic = getTagValue('t', event.tags)
|
||||
```
|
||||
|
||||
### Thread Processing
|
||||
```typescript
|
||||
// Get thread context
|
||||
const {roots, replies} = getReplyTags(event.tags)
|
||||
|
||||
// Process thread structure
|
||||
function processThread(tags: string[][]) {
|
||||
const thread = getReplyTags(tags)
|
||||
|
||||
return {
|
||||
rootEvents: thread.roots.map(t => t[1]),
|
||||
replyTo: thread.replies.map(t => t[1]),
|
||||
mentions: thread.mentions.map(t => t[1])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tag Collection
|
||||
```typescript
|
||||
// Collect all references
|
||||
function collectReferences(tags: string[][]) {
|
||||
return {
|
||||
events: getEventTagValues(tags),
|
||||
profiles: getPubkeyTagValues(tags),
|
||||
addresses: getAddressTagValues(tags)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,192 @@
|
||||
# Zaps
|
||||
|
||||
The Zaps module provides utilities for working with Lightning Network payments (zaps) in Nostr, including LNURL handling, invoice amount parsing, and zap validation.
|
||||
|
||||
## Zapper Interface
|
||||
The Zapper interface represents a Lightning Network payment provider that can process zaps:
|
||||
|
||||
```typescript
|
||||
interface Zapper {
|
||||
// LNURL for payment processing
|
||||
lnurl: string
|
||||
|
||||
// User's pubkey on the payment service
|
||||
pubkey?: string
|
||||
|
||||
// LNURL callback endpoint
|
||||
callback?: string
|
||||
|
||||
// Minimum payment amount in millisatoshis
|
||||
minSendable?: number
|
||||
|
||||
// Maximum payment amount in millisatoshis
|
||||
maxSendable?: number
|
||||
|
||||
// Pubkey used to sign zap receipts
|
||||
nostrPubkey?: string
|
||||
|
||||
// Whether provider supports Nostr zaps
|
||||
allowsNostr?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
### Finding Nostr Zappers
|
||||
|
||||
#### Getting Lightning Info
|
||||
|
||||
First, check the user's profile for Lightning addresses:
|
||||
|
||||
```typescript
|
||||
function getLightningInfo(profile: Profile) {
|
||||
// Check for Lightning Address (NIP-57)
|
||||
if (profile.lud16) {
|
||||
return {
|
||||
type: 'lud16',
|
||||
address: profile.lud16
|
||||
}
|
||||
}
|
||||
|
||||
// Check for LNURL
|
||||
if (profile.lud06) {
|
||||
return {
|
||||
type: 'lud06',
|
||||
url: profile.lud06
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
#### Fetching LNURL Metadata
|
||||
|
||||
Once you have the Lightning address or LNURL, fetch the metadata:
|
||||
|
||||
```typescript
|
||||
async function fetchZapper(address: string): Promise<Zapper | null> {
|
||||
// Convert Lightning address to LNURL if needed
|
||||
const lnurl = getLnUrl(address)
|
||||
if (!lnurl) return null
|
||||
|
||||
try {
|
||||
// Decode and fetch LNURL metadata
|
||||
const url = new URL(bech32.decode(lnurl).data)
|
||||
const response = await fetch(url.toString())
|
||||
const metadata = await response.json()
|
||||
|
||||
// Extract zapper details
|
||||
return {
|
||||
lnurl,
|
||||
callback: metadata.callback,
|
||||
minSendable: metadata.minSendable,
|
||||
maxSendable: metadata.maxSendable,
|
||||
nostrPubkey: metadata.nostrPubkey,
|
||||
allowsNostr: Boolean(metadata.allowsNostr),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch zapper:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Example Alby zapper configuration
|
||||
const albyZapper: Zapper = {
|
||||
lnurl: "lnurl1...",
|
||||
pubkey: "alby_user_pubkey",
|
||||
nostrPubkey: "alby_signing_key",
|
||||
allowsNostr: true,
|
||||
minSendable: 1000, // 1 sat minimum
|
||||
maxSendable: 100000000 // 100k sats maximum
|
||||
}
|
||||
|
||||
// Example LNbits zapper
|
||||
const lnbitsZapper: Zapper = {
|
||||
lnurl: "lnurl1...",
|
||||
callback: "https://lnbits.com/callback",
|
||||
nostrPubkey: "lnbits_signing_key",
|
||||
allowsNostr: true
|
||||
}
|
||||
```
|
||||
|
||||
### Zap Structure
|
||||
```typescript
|
||||
interface Zap {
|
||||
request: TrustedEvent // Zap request event kind 9734
|
||||
response: TrustedEvent // Zap receipt/response event kind 9735 sent by the zapper
|
||||
invoiceAmount: number // Amount in millisats
|
||||
}
|
||||
```
|
||||
|
||||
## Core Functions
|
||||
|
||||
### Lightning Address Handling
|
||||
```typescript
|
||||
// Convert address to LNURL
|
||||
function getLnUrl(address: string): string | null
|
||||
|
||||
// Examples:
|
||||
getLnUrl("user@domain.com") // => lnurl1...
|
||||
getLnUrl("https://domain.com/.well-known/lnurlp/user") // => lnurl1...
|
||||
getLnUrl("lnurl1...") // => returns unchanged
|
||||
```
|
||||
|
||||
### Invoice Processing
|
||||
```typescript
|
||||
// Parse amount from BOLT11 invoice
|
||||
function getInvoiceAmount(bolt11: string): number
|
||||
|
||||
// Convert human readable amount to millisats
|
||||
function hrpToMillisat(hrpString: string): bigint
|
||||
```
|
||||
|
||||
### Zap Validation
|
||||
|
||||
The `zapFromEvent` function validates a zap receipt event, against an expected zapper.
|
||||
|
||||
It returns a `Zap` object if the zap is valid, or `null` if not.
|
||||
|
||||
```typescript
|
||||
function zapFromEvent(
|
||||
response: TrustedEvent,
|
||||
zapper: Zapper | undefined
|
||||
): Zap | null
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Processing Lightning Addresses
|
||||
```typescript
|
||||
// Get LNURL from various formats
|
||||
const lnurl1 = getLnUrl("user@getalby.com")
|
||||
const lnurl2 = getLnUrl("https://getalby.com/.well-known/lnurlp/user")
|
||||
const lnurl3 = getLnUrl("lnurl1...")
|
||||
|
||||
// Check if conversion was successful
|
||||
if (lnurl1) {
|
||||
// Process LNURL
|
||||
processLnurl(lnurl1)
|
||||
}
|
||||
```
|
||||
|
||||
### Invoice Amount Handling
|
||||
```typescript
|
||||
// Get invoice amount in millisats
|
||||
const amount = getInvoiceAmount(bolt11Invoice)
|
||||
|
||||
// Convert string amount to millisats
|
||||
const millisats = hrpToMillisat("1000") // 1000 sats
|
||||
const millisats = hrpToMillisat("1m") // 1 million sats
|
||||
```
|
||||
|
||||
### Zap Validation
|
||||
```typescript
|
||||
// Validate zap event
|
||||
const zap = zapFromEvent(zapResponse, albyZapper)
|
||||
|
||||
if (zap) {
|
||||
// Process valid zap
|
||||
processZap(zap)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# What is Welshman?
|
||||
|
||||
Welshman is a production-grade nostr toolkit powering [Coracle](https://coracle.social) and [Flotilla](https://flotilla.social).
|
||||
Built as independent, opt-in packages, it lets you choose exactly what you need - from basic event handling to complete feed management.
|
||||
|
||||
Need just a content parser? Grab @welshman/content. Building a complex client? Start with @welshman/app and add more packages as you grow.
|
||||
|
||||
Each module is battle-tested in production, designed to work together but never dependent on each other.
|
||||
|
||||
Build your next Nostr client with the same tools that power today's leading Nostr applications.
|
||||
|
||||
<!-- ## Installation
|
||||
|
||||
```sh
|
||||
npm install @welshman
|
||||
``` -->
|
||||
Generated
+1794
-134
File diff suppressed because it is too large
Load Diff
+12
-3
@@ -9,7 +9,16 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"gts": "^6.0.2",
|
||||
"typedoc": "^0.27.4",
|
||||
"typescript": "^5.6.3"
|
||||
"typedoc": "^0.27.9",
|
||||
"typedoc-plugin-markdown": "^4.4.2",
|
||||
"typedoc-vitepress-theme": "^1.1.2",
|
||||
"typescript": "^5.6.3",
|
||||
"vitepress": "^1.6.3"
|
||||
},
|
||||
"scripts": {
|
||||
"predocs": "typedoc",
|
||||
"docs:dev": "vitepress dev docs",
|
||||
"docs:build": "npx typedoc && vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"name": "Welshman Docs",
|
||||
"out": "./docs/reference",
|
||||
"docsRoot": "./docs",
|
||||
"entryPoints": ["packages/*"],
|
||||
"entryPointStrategy": "packages",
|
||||
"exclude": ["**/normalize-url/*"],
|
||||
"plugin": ["typedoc-plugin-markdown", "typedoc-vitepress-theme"],
|
||||
"hideGenerator": true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user