Compare commits

..

13 Commits

Author SHA1 Message Date
84b7c0e11c bump to v0.27.0
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-11-09 10:42:50 +00:00
d0dbd2e2dc implemented and tested NIP-43 invite based ACL 2025-11-09 10:41:58 +00:00
f0beb83ceb fix utf8 handling bug, bump to v0.26.4
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-11-08 10:29:24 +00:00
5d04193bb7 implement messages and operations for FIND 2025-11-08 09:02:32 +00:00
b4760c49b6 implement messages and operations for FIND 2025-11-08 08:54:58 +00:00
587116afa8 add noise protocol security and site certificate third party signing 2025-11-08 00:13:23 +00:00
960bfe7dda add noise protocol security and site certificate third party signing 2025-11-08 00:01:06 +00:00
f5cfcff6c9 draft name registry proposal 2025-11-07 22:52:22 +00:00
2e690f5b83 draft name registry proposal 2025-11-07 22:37:52 +00:00
c79cd2ffee Remove deprecated files and enhance subscription stability
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Deleted obsolete files including ALL_FIXES.md, MESSAGE_QUEUE_FIX.md, PUBLISHER_FIX.md, and others to streamline the codebase.
- Implemented critical fixes for subscription stability, ensuring receiver channels are consumed and preventing drops.
- Introduced per-subscription consumer goroutines to enhance event delivery and prevent message queue overflow.
- Updated documentation to reflect changes and provide clear testing guidelines for subscription stability.
- Bumped version to v0.26.3 to signify these important updates.
2025-11-06 20:10:08 +00:00
581e0ec588 Implement comprehensive WebSocket subscription stability fixes
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Resolved critical issues causing subscriptions to drop after 30-60 seconds due to unconsumed receiver channels.
- Introduced per-subscription consumer goroutines to ensure continuous event delivery and prevent channel overflow.
- Enhanced REQ parsing to handle both wrapped and unwrapped filter arrays, eliminating EOF errors.
- Updated publisher logic to correctly send events to receiver channels, ensuring proper event delivery to subscribers.
- Added extensive documentation and testing tools to verify subscription stability and performance.
- Bumped version to v0.26.2 to reflect these significant improvements.
2025-11-06 18:21:00 +00:00
d604341a27 Add comprehensive documentation for CLAUDE and Nostr WebSocket skills
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Introduced CLAUDE.md to provide guidance for working with the Claude Code repository, including project overview, build commands, testing guidelines, and performance considerations.
- Added INDEX.md for a structured overview of the strfry WebSocket implementation analysis, detailing document contents and usage.
- Created SKILL.md for the nostr-websocket skill, covering WebSocket protocol fundamentals, connection management, and performance optimization techniques.
- Included multiple reference documents for Go, C++, and Rust implementations of WebSocket patterns, enhancing the knowledge base for developers.
- Updated deployment and build documentation to reflect new multi-platform capabilities and pure Go build processes.
- Bumped version to reflect the addition of extensive documentation and resources for developers working with Nostr relays and WebSocket connections.
2025-11-06 16:18:09 +00:00
27f92336ae Add NDK skill documentation and examples
- Introduced comprehensive documentation for the Nostr Development Kit (NDK) including an overview, quick reference, and troubleshooting guide.
- Added detailed examples covering initialization, authentication, event publishing, querying, and user profile management.
- Structured the documentation to facilitate quick lookups and deep learning, based on real-world usage patterns from the Plebeian Market application.
- Created an index for examples to enhance usability and navigation.
- Bumped version to 1.0.0 to reflect the addition of this new skill set.
2025-11-06 14:34:06 +00:00
96 changed files with 29945 additions and 1215 deletions

View File

@@ -0,0 +1,37 @@
{
"permissions": {
"allow": [
"Skill(skill-creator)",
"Bash(cat:*)",
"Bash(python3:*)",
"Bash(find:*)",
"Skill(nostr-websocket)",
"Bash(go build:*)",
"Bash(chmod:*)",
"Bash(journalctl:*)",
"Bash(timeout 5 bash -c 'echo [\"\"REQ\"\",\"\"test123\"\",{\"\"kinds\"\":[1],\"\"limit\"\":1}] | websocat ws://localhost:3334':*)",
"Bash(pkill:*)",
"Bash(timeout 5 bash:*)",
"Bash(md5sum:*)",
"Bash(timeout 3 bash -c 'echo [\\\"\"REQ\\\"\",\\\"\"test456\\\"\",{\\\"\"kinds\\\"\":[1],\\\"\"limit\\\"\":10}] | websocat ws://localhost:3334')",
"Bash(printf:*)",
"Bash(websocat:*)",
"Bash(go test:*)",
"Bash(timeout 180 go test:*)",
"WebFetch(domain:github.com)",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(/tmp/find help)",
"Bash(/tmp/find verify-name example.com)",
"Skill(golang)",
"Bash(/tmp/find verify-name Bitcoin.Nostr)",
"Bash(/tmp/find generate-key)",
"Bash(git ls-tree:*)",
"Bash(CGO_ENABLED=0 go build:*)",
"Bash(CGO_ENABLED=0 go test:*)",
"Bash(app/web/dist/index.html)",
"Bash(export CGO_ENABLED=0)"
],
"deny": [],
"ask": []
}
}

286
.claude/skills/ndk/INDEX.md Normal file
View File

@@ -0,0 +1,286 @@
# NDK (Nostr Development Kit) Claude Skill
> **Comprehensive knowledge base for working with NDK in production applications**
This Claude skill provides deep expertise in the Nostr Development Kit based on real-world usage patterns from the Plebeian Market application.
## 📚 Documentation Structure
```
.claude/skills/ndk/
├── README.md # This file - Overview and getting started
├── ndk-skill.md # Complete reference guide (18KB)
├── quick-reference.md # Fast lookup for common tasks (7KB)
├── troubleshooting.md # Common problems and solutions
└── examples/ # Production code examples
├── README.md
├── 01-initialization.ts # NDK setup and connection
├── 02-authentication.ts # NIP-07, NIP-46, private keys
├── 03-publishing-events.ts # Creating and publishing events
├── 04-querying-subscribing.ts # Fetching and real-time subs
└── 05-users-profiles.ts # User and profile management
```
## 🚀 Quick Start
### For Quick Lookups
Start with **`quick-reference.md`** for:
- Common code snippets
- Quick syntax reminders
- Frequently used patterns
### For Deep Learning
Read **`ndk-skill.md`** for:
- Complete API documentation
- Best practices
- Integration patterns
- Performance optimization
### For Problem Solving
Check **`troubleshooting.md`** for:
- Common error solutions
- Performance tips
- Testing strategies
- Debug techniques
### For Code Examples
Browse **`examples/`** directory for:
- Real production code
- Full implementations
- React integration patterns
- Error handling examples
## 📖 Core Topics Covered
### 1. Initialization & Setup
- Basic NDK initialization
- Multiple instance patterns (main + zap relays)
- Connection management with timeouts
- Relay pool configuration
- Connection status monitoring
### 2. Authentication
- **NIP-07**: Browser extension signers (Alby, nos2x)
- **NIP-46**: Remote signers (Bunker)
- **Private Keys**: Direct key management
- Auto-login with localStorage
- Multi-account session management
### 3. Event Publishing
- Basic text notes
- Parameterized replaceable events (products, profiles)
- Order and payment events
- Batch publishing
- Error handling patterns
### 4. Querying & Subscriptions
- One-time fetches with `fetchEvents()`
- Real-time subscriptions
- Tag filtering patterns
- Time-range queries
- Event monitoring
- React Query integration
### 5. User & Profile Management
- Fetch profiles (npub, hex, NIP-05)
- Update user profiles
- Follow/unfollow operations
- Batch profile loading
- Profile caching strategies
### 6. Advanced Patterns
- Store-based NDK management
- Query + subscription combination
- Event parsing utilities
- Memory leak prevention
- Performance optimization
## 🎯 Use Cases
### Building a Nostr Client
```typescript
// Initialize
const { ndk, isConnected } = await initializeNDK({
relays: ['wss://relay.damus.io', 'wss://nos.lol'],
timeoutMs: 10000
})
// Authenticate
const { user } = await loginWithExtension(ndk)
// Publish
await publishBasicNote(ndk, 'Hello Nostr!')
// Subscribe
const sub = subscribeToNotes(ndk, user.pubkey, (event) => {
console.log('New note:', event.content)
})
```
### Building a Marketplace
```typescript
// Publish product
await publishProduct(ndk, {
slug: 'bitcoin-shirt',
title: 'Bitcoin T-Shirt',
price: 25,
currency: 'USD',
images: ['https://...']
})
// Create order
await createOrder(ndk, {
orderId: uuidv4(),
sellerPubkey: merchant.pubkey,
productRef: '30402:pubkey:bitcoin-shirt',
quantity: 1,
totalAmount: '25.00'
})
// Monitor payment
monitorPaymentReceipt(ndk, orderId, invoiceId, (preimage) => {
console.log('Payment confirmed!')
})
```
### React Integration
```typescript
function Feed() {
const ndk = useNDK()
const { user } = useAuth()
// Query with real-time updates
const { data: notes } = useNotesWithSubscription(
ndk,
user.pubkey
)
return (
<div>
{notes?.map(note => (
<NoteCard key={note.id} note={note} />
))}
</div>
)
}
```
## 🔍 Common Patterns Quick Reference
### Safe NDK Access
```typescript
const ndk = ndkActions.getNDK()
if (!ndk) throw new Error('NDK not initialized')
```
### Subscription Cleanup
```typescript
useEffect(() => {
const sub = ndk.subscribe(filter, { closeOnEose: false })
sub.on('event', handleEvent)
return () => sub.stop() // Critical!
}, [ndk])
```
### Error Handling
```typescript
try {
await event.sign()
await event.publish()
} catch (error) {
console.error('Publishing failed:', error)
throw new Error('Failed to publish. Check connection.')
}
```
### Tag Filtering
```typescript
// ✅ Correct (note the # prefix for tag filters)
{ kinds: [16], '#order': [orderId] }
// ❌ Wrong
{ kinds: [16], 'order': [orderId] }
```
## 🛠 Development Tools
### VS Code Integration
These skill files work with:
- Cursor AI for code completion
- Claude for code assistance
- GitHub Copilot with context
### Debugging Tips
```typescript
// Check connection
console.log('Connected relays:',
Array.from(ndk.pool?.relays.values() || [])
.filter(r => r.status === 1)
.map(r => r.url)
)
// Verify signer
console.log('Signer:', ndk.signer)
console.log('Active user:', ndk.activeUser)
// Event inspection
console.log('Event:', {
id: event.id,
kind: event.kind,
tags: event.tags,
sig: event.sig
})
```
## 📊 Statistics
- **Total Documentation**: ~50KB
- **Code Examples**: 5 complete modules
- **Patterns Documented**: 50+
- **Common Issues Covered**: 15+
- **Based On**: Real production code
## 🔗 Additional Resources
### Official NDK Resources
- **GitHub**: https://github.com/nostr-dev-kit/ndk
- **Documentation**: https://ndk.fyi
- **NPM**: `@nostr-dev-kit/ndk`
### Nostr Protocol
- **NIPs**: https://github.com/nostr-protocol/nips
- **Nostr**: https://nostr.com
### Related Tools
- **TanStack Query**: React state management
- **TanStack Router**: Type-safe routing
- **Radix UI**: Accessible components
## 💡 Tips for Using This Skill
1. **Start Small**: Begin with quick-reference.md for syntax
2. **Go Deep**: Read ndk-skill.md section by section
3. **Copy Examples**: Use examples/ as templates
4. **Debug Issues**: Check troubleshooting.md first
5. **Stay Updated**: Patterns based on production usage
## 🤝 Contributing
This skill is maintained based on the Plebeian Market codebase. To improve it:
1. Document new patterns you discover
2. Add solutions to common problems
3. Update examples with better approaches
4. Keep synchronized with NDK updates
## 📝 Version Info
- **Skill Version**: 1.0.0
- **NDK Version**: Latest (based on production usage)
- **Last Updated**: November 2025
- **Codebase**: Plebeian Market
---
**Ready to build with NDK?** Start with `quick-reference.md` or dive into `examples/01-initialization.ts`!

View File

@@ -0,0 +1,38 @@
# NDK (Nostr Development Kit) Claude Skill
This skill provides comprehensive knowledge about working with the Nostr Development Kit (NDK) library.
## Files
- **ndk-skill.md** - Complete reference documentation with patterns from production usage
- **quick-reference.md** - Quick lookup guide for common NDK tasks
- **examples/** - Code examples extracted from the Plebeian Market codebase
## Usage
When working with NDK-related code, reference these documents to:
- Understand initialization patterns
- Learn authentication flows (NIP-07, NIP-46, private keys)
- Implement event creation and publishing
- Set up subscriptions for real-time updates
- Query events with filters
- Handle users and profiles
- Integrate with TanStack Query
## Key Topics Covered
1. NDK Initialization & Configuration
2. Authentication & Signers
3. Event Creation & Publishing
4. Querying Events
5. Real-time Subscriptions
6. User & Profile Management
7. Tag Handling
8. Replaceable Events
9. Relay Management
10. Integration with React/TanStack Query
11. Error Handling & Best Practices
12. Performance Optimization
All examples are based on real production code from the Plebeian Market application.

View File

@@ -0,0 +1,162 @@
/**
* NDK Initialization Patterns
*
* Examples from: src/lib/stores/ndk.ts
*/
import NDK from '@nostr-dev-kit/ndk'
// ============================================================
// BASIC INITIALIZATION
// ============================================================
const basicInit = () => {
const ndk = new NDK({
explicitRelayUrls: ['wss://relay.damus.io', 'wss://relay.nostr.band']
})
return ndk
}
// ============================================================
// PRODUCTION PATTERN - WITH MULTIPLE NDK INSTANCES
// ============================================================
const productionInit = (relays: string[], zapRelays: string[]) => {
// Main NDK instance for general operations
const ndk = new NDK({
explicitRelayUrls: relays
})
// Separate NDK for zap operations (performance optimization)
const zapNdk = new NDK({
explicitRelayUrls: zapRelays
})
return { ndk, zapNdk }
}
// ============================================================
// CONNECTION WITH TIMEOUT
// ============================================================
const connectWithTimeout = async (
ndk: NDK,
timeoutMs: number = 10000
): Promise<void> => {
// Create connection promise
const connectPromise = ndk.connect()
// Create timeout promise
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Connection timeout')), timeoutMs)
)
try {
// Race between connection and timeout
await Promise.race([connectPromise, timeoutPromise])
console.log('✅ NDK connected successfully')
} catch (error) {
if (error instanceof Error && error.message === 'Connection timeout') {
console.error('❌ Connection timed out after', timeoutMs, 'ms')
} else {
console.error('❌ Connection failed:', error)
}
throw error
}
}
// ============================================================
// FULL INITIALIZATION FLOW
// ============================================================
interface InitConfig {
relays?: string[]
zapRelays?: string[]
timeoutMs?: number
}
const defaultRelays = [
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nos.lol'
]
const defaultZapRelays = [
'wss://relay.damus.io',
'wss://nostr.wine'
]
const initializeNDK = async (config: InitConfig = {}) => {
const {
relays = defaultRelays,
zapRelays = defaultZapRelays,
timeoutMs = 10000
} = config
// Initialize instances
const ndk = new NDK({ explicitRelayUrls: relays })
const zapNdk = new NDK({ explicitRelayUrls: zapRelays })
// Connect with timeout protection
try {
await connectWithTimeout(ndk, timeoutMs)
await connectWithTimeout(zapNdk, timeoutMs)
return { ndk, zapNdk, isConnected: true }
} catch (error) {
return { ndk, zapNdk, isConnected: false, error }
}
}
// ============================================================
// CHECKING CONNECTION STATUS
// ============================================================
const getConnectionStatus = (ndk: NDK) => {
const connectedRelays = Array.from(ndk.pool?.relays.values() || [])
.filter(relay => relay.status === 1)
.map(relay => relay.url)
const isConnected = connectedRelays.length > 0
return {
isConnected,
connectedRelays,
totalRelays: ndk.pool?.relays.size || 0
}
}
// ============================================================
// USAGE EXAMPLE
// ============================================================
async function main() {
// Initialize
const { ndk, zapNdk, isConnected } = await initializeNDK({
relays: defaultRelays,
zapRelays: defaultZapRelays,
timeoutMs: 10000
})
if (!isConnected) {
console.error('Failed to connect to relays')
return
}
// Check status
const status = getConnectionStatus(ndk)
console.log('Connection status:', status)
// Ready to use
console.log('NDK ready for operations')
}
export {
basicInit,
productionInit,
connectWithTimeout,
initializeNDK,
getConnectionStatus
}

View File

@@ -0,0 +1,255 @@
/**
* NDK Authentication Patterns
*
* Examples from: src/lib/stores/auth.ts
*/
import NDK from '@nostr-dev-kit/ndk'
import { NDKNip07Signer, NDKPrivateKeySigner, NDKNip46Signer } from '@nostr-dev-kit/ndk'
// ============================================================
// NIP-07 - BROWSER EXTENSION SIGNER
// ============================================================
const loginWithExtension = async (ndk: NDK) => {
try {
// Create NIP-07 signer (browser extension like Alby, nos2x)
const signer = new NDKNip07Signer()
// Wait for signer to be ready
await signer.blockUntilReady()
// Set signer on NDK instance
ndk.signer = signer
// Get authenticated user
const user = await signer.user()
console.log('✅ Logged in via extension:', user.npub)
return { user, signer }
} catch (error) {
console.error('❌ Extension login failed:', error)
throw new Error('Failed to login with browser extension. Is it installed?')
}
}
// ============================================================
// PRIVATE KEY SIGNER
// ============================================================
const loginWithPrivateKey = async (ndk: NDK, privateKeyHex: string) => {
try {
// Validate private key format (64 hex characters)
if (!/^[0-9a-f]{64}$/.test(privateKeyHex)) {
throw new Error('Invalid private key format')
}
// Create private key signer
const signer = new NDKPrivateKeySigner(privateKeyHex)
// Wait for signer to be ready
await signer.blockUntilReady()
// Set signer on NDK instance
ndk.signer = signer
// Get authenticated user
const user = await signer.user()
console.log('✅ Logged in with private key:', user.npub)
return { user, signer }
} catch (error) {
console.error('❌ Private key login failed:', error)
throw error
}
}
// ============================================================
// NIP-46 - REMOTE SIGNER (BUNKER)
// ============================================================
const loginWithNip46 = async (
ndk: NDK,
bunkerUrl: string,
localPrivateKey?: string
) => {
try {
// Create or use existing local signer
const localSigner = localPrivateKey
? new NDKPrivateKeySigner(localPrivateKey)
: NDKPrivateKeySigner.generate()
// Create NIP-46 remote signer
const remoteSigner = new NDKNip46Signer(ndk, bunkerUrl, localSigner)
// Wait for signer to be ready (may require user approval)
await remoteSigner.blockUntilReady()
// Set signer on NDK instance
ndk.signer = remoteSigner
// Get authenticated user
const user = await remoteSigner.user()
console.log('✅ Logged in via NIP-46:', user.npub)
// Store local signer key for reconnection
return {
user,
signer: remoteSigner,
localSignerKey: localSigner.privateKey
}
} catch (error) {
console.error('❌ NIP-46 login failed:', error)
throw error
}
}
// ============================================================
// AUTO-LOGIN FROM LOCAL STORAGE
// ============================================================
const STORAGE_KEYS = {
AUTO_LOGIN: 'nostr:auto-login',
LOCAL_SIGNER: 'nostr:local-signer',
BUNKER_URL: 'nostr:bunker-url',
ENCRYPTED_KEY: 'nostr:encrypted-key'
}
const getAuthFromStorage = async (ndk: NDK) => {
try {
// Check if auto-login is enabled
const autoLogin = localStorage.getItem(STORAGE_KEYS.AUTO_LOGIN)
if (autoLogin !== 'true') {
return null
}
// Try NIP-46 bunker connection
const privateKey = localStorage.getItem(STORAGE_KEYS.LOCAL_SIGNER)
const bunkerUrl = localStorage.getItem(STORAGE_KEYS.BUNKER_URL)
if (privateKey && bunkerUrl) {
return await loginWithNip46(ndk, bunkerUrl, privateKey)
}
// Try encrypted private key
const encryptedKey = localStorage.getItem(STORAGE_KEYS.ENCRYPTED_KEY)
if (encryptedKey) {
// Would need decryption password from user
return { needsPassword: true, encryptedKey }
}
// Fallback to extension
return await loginWithExtension(ndk)
} catch (error) {
console.error('Auto-login failed:', error)
return null
}
}
// ============================================================
// SAVE AUTH TO STORAGE
// ============================================================
const saveAuthToStorage = (
method: 'extension' | 'private-key' | 'nip46',
data?: {
privateKey?: string
bunkerUrl?: string
encryptedKey?: string
}
) => {
// Enable auto-login
localStorage.setItem(STORAGE_KEYS.AUTO_LOGIN, 'true')
if (method === 'nip46' && data?.privateKey && data?.bunkerUrl) {
localStorage.setItem(STORAGE_KEYS.LOCAL_SIGNER, data.privateKey)
localStorage.setItem(STORAGE_KEYS.BUNKER_URL, data.bunkerUrl)
} else if (method === 'private-key' && data?.encryptedKey) {
localStorage.setItem(STORAGE_KEYS.ENCRYPTED_KEY, data.encryptedKey)
}
// Extension doesn't need storage
}
// ============================================================
// LOGOUT
// ============================================================
const logout = (ndk: NDK) => {
// Remove signer from NDK
ndk.signer = undefined
// Clear all auth storage
Object.values(STORAGE_KEYS).forEach(key => {
localStorage.removeItem(key)
})
console.log('✅ Logged out successfully')
}
// ============================================================
// GET CURRENT USER
// ============================================================
const getCurrentUser = async (ndk: NDK) => {
if (!ndk.signer) {
return null
}
try {
const user = await ndk.signer.user()
return {
pubkey: user.pubkey,
npub: user.npub,
profile: await user.fetchProfile()
}
} catch (error) {
console.error('Failed to get current user:', error)
return null
}
}
// ============================================================
// USAGE EXAMPLE
// ============================================================
async function authExample(ndk: NDK) {
// Try auto-login first
let auth = await getAuthFromStorage(ndk)
if (!auth) {
// Manual login options
console.log('Choose login method:')
console.log('1. Browser Extension (NIP-07)')
console.log('2. Private Key')
console.log('3. Remote Signer (NIP-46)')
// Example: login with extension
auth = await loginWithExtension(ndk)
saveAuthToStorage('extension')
}
if (auth && 'needsPassword' in auth) {
// Handle encrypted key case
console.log('Password required for encrypted key')
return
}
// Get current user info
const currentUser = await getCurrentUser(ndk)
console.log('Current user:', currentUser)
// Logout when done
// logout(ndk)
}
export {
loginWithExtension,
loginWithPrivateKey,
loginWithNip46,
getAuthFromStorage,
saveAuthToStorage,
logout,
getCurrentUser
}

View File

@@ -0,0 +1,376 @@
/**
* NDK Event Publishing Patterns
*
* Examples from: src/publish/orders.tsx, scripts/gen_products.ts
*/
import NDK, { NDKEvent, NDKTag } from '@nostr-dev-kit/ndk'
// ============================================================
// BASIC EVENT PUBLISHING
// ============================================================
const publishBasicNote = async (ndk: NDK, content: string) => {
// Create event
const event = new NDKEvent(ndk)
event.kind = 1 // Text note
event.content = content
event.tags = []
// Sign and publish
await event.sign()
await event.publish()
console.log('✅ Published note:', event.id)
return event.id
}
// ============================================================
// EVENT WITH TAGS
// ============================================================
const publishNoteWithTags = async (
ndk: NDK,
content: string,
options: {
mentions?: string[] // pubkeys to mention
hashtags?: string[]
replyTo?: string // event ID
}
) => {
const event = new NDKEvent(ndk)
event.kind = 1
event.content = content
event.tags = []
// Add mentions
if (options.mentions) {
options.mentions.forEach(pubkey => {
event.tags.push(['p', pubkey])
})
}
// Add hashtags
if (options.hashtags) {
options.hashtags.forEach(tag => {
event.tags.push(['t', tag])
})
}
// Add reply
if (options.replyTo) {
event.tags.push(['e', options.replyTo, '', 'reply'])
}
await event.sign()
await event.publish()
return event.id
}
// ============================================================
// PRODUCT LISTING (PARAMETERIZED REPLACEABLE EVENT)
// ============================================================
interface ProductData {
slug: string // Unique identifier
title: string
description: string
price: number
currency: string
images: string[]
shippingRefs?: string[]
category?: string
}
const publishProduct = async (ndk: NDK, product: ProductData) => {
const event = new NDKEvent(ndk)
event.kind = 30402 // Product listing kind
event.content = product.description
// Build tags
event.tags = [
['d', product.slug], // Unique identifier (required for replaceable)
['title', product.title],
['price', product.price.toString(), product.currency],
]
// Add images
product.images.forEach(image => {
event.tags.push(['image', image])
})
// Add shipping options
if (product.shippingRefs) {
product.shippingRefs.forEach(ref => {
event.tags.push(['shipping', ref])
})
}
// Add category
if (product.category) {
event.tags.push(['t', product.category])
}
// Optional: set custom timestamp
event.created_at = Math.floor(Date.now() / 1000)
await event.sign()
await event.publish()
console.log('✅ Published product:', product.title)
return event.id
}
// ============================================================
// ORDER CREATION EVENT
// ============================================================
interface OrderData {
orderId: string
sellerPubkey: string
productRef: string
quantity: number
totalAmount: string
currency: string
shippingRef?: string
shippingAddress?: string
email?: string
phone?: string
notes?: string
}
const createOrder = async (ndk: NDK, order: OrderData) => {
const event = new NDKEvent(ndk)
event.kind = 16 // Order processing kind
event.content = order.notes || ''
// Required tags per spec
event.tags = [
['p', order.sellerPubkey],
['subject', `Order ${order.orderId.substring(0, 8)}`],
['type', 'order-creation'],
['order', order.orderId],
['amount', order.totalAmount],
['item', order.productRef, order.quantity.toString()],
]
// Optional tags
if (order.shippingRef) {
event.tags.push(['shipping', order.shippingRef])
}
if (order.shippingAddress) {
event.tags.push(['address', order.shippingAddress])
}
if (order.email) {
event.tags.push(['email', order.email])
}
if (order.phone) {
event.tags.push(['phone', order.phone])
}
try {
await event.sign()
await event.publish()
console.log('✅ Order created:', order.orderId)
return { success: true, eventId: event.id }
} catch (error) {
console.error('❌ Failed to create order:', error)
return { success: false, error }
}
}
// ============================================================
// STATUS UPDATE EVENT
// ============================================================
const publishStatusUpdate = async (
ndk: NDK,
orderId: string,
recipientPubkey: string,
status: 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled',
notes?: string
) => {
const event = new NDKEvent(ndk)
event.kind = 16
event.content = notes || `Order status updated to ${status}`
event.tags = [
['p', recipientPubkey],
['subject', 'order-info'],
['type', 'status-update'],
['order', orderId],
['status', status],
]
await event.sign()
await event.publish()
return event.id
}
// ============================================================
// BATCH PUBLISHING
// ============================================================
const publishMultipleEvents = async (
ndk: NDK,
events: Array<{ kind: number; content: string; tags: NDKTag[] }>
) => {
const results = []
for (const eventData of events) {
try {
const event = new NDKEvent(ndk)
event.kind = eventData.kind
event.content = eventData.content
event.tags = eventData.tags
await event.sign()
await event.publish()
results.push({ success: true, eventId: event.id })
} catch (error) {
results.push({ success: false, error })
}
}
return results
}
// ============================================================
// PUBLISH WITH CUSTOM SIGNER
// ============================================================
import { NDKSigner } from '@nostr-dev-kit/ndk'
const publishWithCustomSigner = async (
ndk: NDK,
signer: NDKSigner,
eventData: { kind: number; content: string; tags: NDKTag[] }
) => {
const event = new NDKEvent(ndk)
event.kind = eventData.kind
event.content = eventData.content
event.tags = eventData.tags
// Sign with specific signer (not ndk.signer)
await event.sign(signer)
await event.publish()
return event.id
}
// ============================================================
// ERROR HANDLING PATTERN
// ============================================================
const publishWithErrorHandling = async (
ndk: NDK,
eventData: { kind: number; content: string; tags: NDKTag[] }
) => {
// Validate NDK
if (!ndk) {
throw new Error('NDK not initialized')
}
// Validate signer
if (!ndk.signer) {
throw new Error('No active signer. Please login first.')
}
try {
const event = new NDKEvent(ndk)
event.kind = eventData.kind
event.content = eventData.content
event.tags = eventData.tags
// Sign
await event.sign()
// Verify signature
if (!event.sig) {
throw new Error('Event signing failed')
}
// Publish
await event.publish()
// Verify event ID
if (!event.id) {
throw new Error('Event ID not generated')
}
return {
success: true,
eventId: event.id,
pubkey: event.pubkey
}
} catch (error) {
console.error('Publishing failed:', error)
if (error instanceof Error) {
// Handle specific error types
if (error.message.includes('relay')) {
throw new Error('Failed to publish to relays. Check connection.')
}
if (error.message.includes('sign')) {
throw new Error('Failed to sign event. Check signer.')
}
}
throw error
}
}
// ============================================================
// USAGE EXAMPLE
// ============================================================
async function publishingExample(ndk: NDK) {
// Simple note
await publishBasicNote(ndk, 'Hello Nostr!')
// Note with tags
await publishNoteWithTags(ndk, 'Check out this product!', {
hashtags: ['marketplace', 'nostr'],
mentions: ['pubkey123...']
})
// Product listing
await publishProduct(ndk, {
slug: 'bitcoin-tshirt',
title: 'Bitcoin T-Shirt',
description: 'High quality Bitcoin t-shirt',
price: 25,
currency: 'USD',
images: ['https://example.com/image.jpg'],
category: 'clothing'
})
// Order
await createOrder(ndk, {
orderId: 'order-123',
sellerPubkey: 'seller-pubkey',
productRef: '30402:pubkey:bitcoin-tshirt',
quantity: 1,
totalAmount: '25.00',
currency: 'USD',
email: 'customer@example.com'
})
}
export {
publishBasicNote,
publishNoteWithTags,
publishProduct,
createOrder,
publishStatusUpdate,
publishMultipleEvents,
publishWithCustomSigner,
publishWithErrorHandling
}

View File

@@ -0,0 +1,404 @@
/**
* NDK Query and Subscription Patterns
*
* Examples from: src/queries/orders.tsx, src/queries/payment.tsx
*/
import NDK, { NDKEvent, NDKFilter, NDKSubscription } from '@nostr-dev-kit/ndk'
// ============================================================
// BASIC FETCH (ONE-TIME QUERY)
// ============================================================
const fetchNotes = async (ndk: NDK, authorPubkey: string, limit: number = 50) => {
const filter: NDKFilter = {
kinds: [1], // Text notes
authors: [authorPubkey],
limit
}
// Fetch returns a Set
const events = await ndk.fetchEvents(filter)
// Convert to array and sort by timestamp
const eventArray = Array.from(events).sort((a, b) =>
(b.created_at || 0) - (a.created_at || 0)
)
return eventArray
}
// ============================================================
// FETCH WITH MULTIPLE FILTERS
// ============================================================
const fetchProductsByMultipleAuthors = async (
ndk: NDK,
pubkeys: string[]
) => {
const filter: NDKFilter = {
kinds: [30402], // Product listings
authors: pubkeys,
limit: 100
}
const events = await ndk.fetchEvents(filter)
return Array.from(events)
}
// ============================================================
// FETCH WITH TAG FILTERS
// ============================================================
const fetchOrderEvents = async (ndk: NDK, orderId: string) => {
const filter: NDKFilter = {
kinds: [16, 17], // Order and payment receipt
'#order': [orderId], // Tag filter (note the # prefix)
}
const events = await ndk.fetchEvents(filter)
return Array.from(events)
}
// ============================================================
// FETCH WITH TIME RANGE
// ============================================================
const fetchRecentEvents = async (
ndk: NDK,
kind: number,
hoursAgo: number = 24
) => {
const now = Math.floor(Date.now() / 1000)
const since = now - (hoursAgo * 3600)
const filter: NDKFilter = {
kinds: [kind],
since,
until: now,
limit: 100
}
const events = await ndk.fetchEvents(filter)
return Array.from(events)
}
// ============================================================
// FETCH BY EVENT ID
// ============================================================
const fetchEventById = async (ndk: NDK, eventId: string) => {
const filter: NDKFilter = {
ids: [eventId]
}
const events = await ndk.fetchEvents(filter)
if (events.size === 0) {
return null
}
return Array.from(events)[0]
}
// ============================================================
// BASIC SUBSCRIPTION (REAL-TIME)
// ============================================================
const subscribeToNotes = (
ndk: NDK,
authorPubkey: string,
onEvent: (event: NDKEvent) => void
): NDKSubscription => {
const filter: NDKFilter = {
kinds: [1],
authors: [authorPubkey]
}
const subscription = ndk.subscribe(filter, {
closeOnEose: false // Keep open for real-time updates
})
// Event handler
subscription.on('event', (event: NDKEvent) => {
onEvent(event)
})
// EOSE (End of Stored Events) handler
subscription.on('eose', () => {
console.log('✅ Received all stored events')
})
return subscription
}
// ============================================================
// SUBSCRIPTION WITH CLEANUP
// ============================================================
const createManagedSubscription = (
ndk: NDK,
filter: NDKFilter,
handlers: {
onEvent: (event: NDKEvent) => void
onEose?: () => void
onClose?: () => void
}
) => {
const subscription = ndk.subscribe(filter, { closeOnEose: false })
subscription.on('event', handlers.onEvent)
if (handlers.onEose) {
subscription.on('eose', handlers.onEose)
}
if (handlers.onClose) {
subscription.on('close', handlers.onClose)
}
// Return cleanup function
return () => {
subscription.stop()
console.log('✅ Subscription stopped')
}
}
// ============================================================
// MONITORING SPECIFIC EVENT
// ============================================================
const monitorPaymentReceipt = (
ndk: NDK,
orderId: string,
invoiceId: string,
onPaymentReceived: (preimage: string) => void
): NDKSubscription => {
const sessionStart = Math.floor(Date.now() / 1000)
const filter: NDKFilter = {
kinds: [17], // Payment receipt
'#order': [orderId],
'#payment-request': [invoiceId],
since: sessionStart - 30 // 30 second buffer for clock skew
}
const subscription = ndk.subscribe(filter, { closeOnEose: false })
subscription.on('event', (event: NDKEvent) => {
// Verify event is recent
if (event.created_at && event.created_at < sessionStart - 30) {
console.log('⏰ Ignoring old receipt')
return
}
// Verify it's the correct invoice
const paymentRequestTag = event.tags.find(tag => tag[0] === 'payment-request')
if (paymentRequestTag?.[1] !== invoiceId) {
return
}
// Extract preimage
const paymentTag = event.tags.find(tag => tag[0] === 'payment')
const preimage = paymentTag?.[3] || 'external-payment'
console.log('✅ Payment received!')
subscription.stop()
onPaymentReceived(preimage)
})
return subscription
}
// ============================================================
// REACT INTEGRATION PATTERN
// ============================================================
import { useEffect, useState } from 'react'
function useOrderSubscription(ndk: NDK | null, orderId: string) {
const [events, setEvents] = useState<NDKEvent[]>([])
const [eosed, setEosed] = useState(false)
useEffect(() => {
if (!ndk || !orderId) return
const filter: NDKFilter = {
kinds: [16, 17],
'#order': [orderId]
}
const subscription = ndk.subscribe(filter, { closeOnEose: false })
subscription.on('event', (event: NDKEvent) => {
setEvents(prev => {
// Avoid duplicates
if (prev.some(e => e.id === event.id)) {
return prev
}
return [...prev, event].sort((a, b) =>
(a.created_at || 0) - (b.created_at || 0)
)
})
})
subscription.on('eose', () => {
setEosed(true)
})
// Cleanup on unmount
return () => {
subscription.stop()
}
}, [ndk, orderId])
return { events, eosed }
}
// ============================================================
// REACT QUERY INTEGRATION
// ============================================================
import { useQuery, useQueryClient } from '@tanstack/react-query'
// Query function
const fetchProducts = async (ndk: NDK, pubkey: string) => {
if (!ndk) throw new Error('NDK not initialized')
const filter: NDKFilter = {
kinds: [30402],
authors: [pubkey]
}
const events = await ndk.fetchEvents(filter)
return Array.from(events)
}
// Hook with subscription for real-time updates
function useProductsWithSubscription(ndk: NDK | null, pubkey: string) {
const queryClient = useQueryClient()
// Initial query
const query = useQuery({
queryKey: ['products', pubkey],
queryFn: () => fetchProducts(ndk!, pubkey),
enabled: !!ndk && !!pubkey,
staleTime: 30000
})
// Real-time subscription
useEffect(() => {
if (!ndk || !pubkey) return
const filter: NDKFilter = {
kinds: [30402],
authors: [pubkey]
}
const subscription = ndk.subscribe(filter, { closeOnEose: false })
subscription.on('event', () => {
// Invalidate query to trigger refetch
queryClient.invalidateQueries({ queryKey: ['products', pubkey] })
})
return () => {
subscription.stop()
}
}, [ndk, pubkey, queryClient])
return query
}
// ============================================================
// ADVANCED: WAITING FOR SPECIFIC EVENT
// ============================================================
const waitForEvent = (
ndk: NDK,
filter: NDKFilter,
condition: (event: NDKEvent) => boolean,
timeoutMs: number = 30000
): Promise<NDKEvent | null> => {
return new Promise((resolve) => {
const subscription = ndk.subscribe(filter, { closeOnEose: false })
// Timeout
const timeout = setTimeout(() => {
subscription.stop()
resolve(null)
}, timeoutMs)
// Event handler
subscription.on('event', (event: NDKEvent) => {
if (condition(event)) {
clearTimeout(timeout)
subscription.stop()
resolve(event)
}
})
})
}
// Usage example
async function waitForPayment(ndk: NDK, orderId: string, invoiceId: string) {
const paymentEvent = await waitForEvent(
ndk,
{
kinds: [17],
'#order': [orderId],
since: Math.floor(Date.now() / 1000)
},
(event) => {
const tag = event.tags.find(t => t[0] === 'payment-request')
return tag?.[1] === invoiceId
},
60000 // 60 second timeout
)
if (paymentEvent) {
console.log('✅ Payment confirmed!')
return paymentEvent
} else {
console.log('⏰ Payment timeout')
return null
}
}
// ============================================================
// USAGE EXAMPLES
// ============================================================
async function queryExample(ndk: NDK) {
// Fetch notes
const notes = await fetchNotes(ndk, 'pubkey123', 50)
console.log(`Found ${notes.length} notes`)
// Subscribe to new notes
const cleanup = subscribeToNotes(ndk, 'pubkey123', (event) => {
console.log('New note:', event.content)
})
// Clean up after 60 seconds
setTimeout(cleanup, 60000)
// Monitor payment
monitorPaymentReceipt(ndk, 'order-123', 'invoice-456', (preimage) => {
console.log('Payment received:', preimage)
})
}
export {
fetchNotes,
fetchProductsByMultipleAuthors,
fetchOrderEvents,
fetchRecentEvents,
fetchEventById,
subscribeToNotes,
createManagedSubscription,
monitorPaymentReceipt,
useOrderSubscription,
useProductsWithSubscription,
waitForEvent
}

View File

@@ -0,0 +1,423 @@
/**
* NDK User and Profile Handling
*
* Examples from: src/queries/profiles.tsx, src/components/Profile.tsx
*/
import NDK, { NDKUser, NDKUserProfile } from '@nostr-dev-kit/ndk'
import { nip19 } from 'nostr-tools'
// ============================================================
// FETCH PROFILE BY NPUB
// ============================================================
const fetchProfileByNpub = async (ndk: NDK, npub: string): Promise<NDKUserProfile | null> => {
try {
// Get user object from npub
const user = ndk.getUser({ npub })
// Fetch profile from relays
const profile = await user.fetchProfile()
return profile
} catch (error) {
console.error('Failed to fetch profile:', error)
return null
}
}
// ============================================================
// FETCH PROFILE BY HEX PUBKEY
// ============================================================
const fetchProfileByPubkey = async (ndk: NDK, pubkey: string): Promise<NDKUserProfile | null> => {
try {
const user = ndk.getUser({ hexpubkey: pubkey })
const profile = await user.fetchProfile()
return profile
} catch (error) {
console.error('Failed to fetch profile:', error)
return null
}
}
// ============================================================
// FETCH PROFILE BY NIP-05
// ============================================================
const fetchProfileByNip05 = async (ndk: NDK, nip05: string): Promise<NDKUserProfile | null> => {
try {
// Resolve NIP-05 identifier to user
const user = await ndk.getUserFromNip05(nip05)
if (!user) {
console.log('User not found for NIP-05:', nip05)
return null
}
// Fetch profile
const profile = await user.fetchProfile()
return profile
} catch (error) {
console.error('Failed to fetch profile by NIP-05:', error)
return null
}
}
// ============================================================
// FETCH PROFILE BY ANY IDENTIFIER
// ============================================================
const fetchProfileByIdentifier = async (
ndk: NDK,
identifier: string
): Promise<{ profile: NDKUserProfile | null; user: NDKUser | null }> => {
try {
// Check if it's a NIP-05 (contains @)
if (identifier.includes('@')) {
const user = await ndk.getUserFromNip05(identifier)
if (!user) return { profile: null, user: null }
const profile = await user.fetchProfile()
return { profile, user }
}
// Check if it's an npub
if (identifier.startsWith('npub')) {
const user = ndk.getUser({ npub: identifier })
const profile = await user.fetchProfile()
return { profile, user }
}
// Assume it's a hex pubkey
const user = ndk.getUser({ hexpubkey: identifier })
const profile = await user.fetchProfile()
return { profile, user }
} catch (error) {
console.error('Failed to fetch profile:', error)
return { profile: null, user: null }
}
}
// ============================================================
// GET CURRENT USER
// ============================================================
const getCurrentUser = async (ndk: NDK): Promise<NDKUser | null> => {
if (!ndk.signer) {
console.log('No signer set')
return null
}
try {
const user = await ndk.signer.user()
return user
} catch (error) {
console.error('Failed to get current user:', error)
return null
}
}
// ============================================================
// PROFILE DATA STRUCTURE
// ============================================================
interface ProfileData {
// Standard fields
name?: string
displayName?: string
display_name?: string
picture?: string
image?: string
banner?: string
about?: string
// Contact
nip05?: string
lud06?: string // LNURL
lud16?: string // Lightning address
// Social
website?: string
// Raw data
[key: string]: any
}
// ============================================================
// EXTRACT PROFILE INFO
// ============================================================
const extractProfileInfo = (profile: NDKUserProfile | null) => {
if (!profile) {
return {
displayName: 'Anonymous',
avatar: null,
bio: null,
lightningAddress: null,
nip05: null
}
}
return {
displayName: profile.displayName || profile.display_name || profile.name || 'Anonymous',
avatar: profile.picture || profile.image || null,
banner: profile.banner || null,
bio: profile.about || null,
lightningAddress: profile.lud16 || profile.lud06 || null,
nip05: profile.nip05 || null,
website: profile.website || null
}
}
// ============================================================
// UPDATE PROFILE
// ============================================================
import { NDKEvent } from '@nostr-dev-kit/ndk'
const updateProfile = async (ndk: NDK, profileData: Partial<ProfileData>) => {
if (!ndk.signer) {
throw new Error('No signer available')
}
// Get current profile
const currentUser = await ndk.signer.user()
const currentProfile = await currentUser.fetchProfile()
// Merge with new data
const updatedProfile = {
...currentProfile,
...profileData
}
// Create kind 0 (metadata) event
const event = new NDKEvent(ndk)
event.kind = 0
event.content = JSON.stringify(updatedProfile)
event.tags = []
await event.sign()
await event.publish()
console.log('✅ Profile updated')
return event.id
}
// ============================================================
// BATCH FETCH PROFILES
// ============================================================
const fetchMultipleProfiles = async (
ndk: NDK,
pubkeys: string[]
): Promise<Map<string, NDKUserProfile | null>> => {
const profiles = new Map<string, NDKUserProfile | null>()
// Fetch all profiles in parallel
await Promise.all(
pubkeys.map(async (pubkey) => {
try {
const user = ndk.getUser({ hexpubkey: pubkey })
const profile = await user.fetchProfile()
profiles.set(pubkey, profile)
} catch (error) {
console.error(`Failed to fetch profile for ${pubkey}:`, error)
profiles.set(pubkey, null)
}
})
)
return profiles
}
// ============================================================
// CONVERT BETWEEN FORMATS
// ============================================================
const convertPubkeyFormats = (identifier: string) => {
try {
// If it's npub, convert to hex
if (identifier.startsWith('npub')) {
const decoded = nip19.decode(identifier)
if (decoded.type === 'npub') {
return {
hex: decoded.data as string,
npub: identifier
}
}
}
// If it's hex, convert to npub
if (/^[0-9a-f]{64}$/.test(identifier)) {
return {
hex: identifier,
npub: nip19.npubEncode(identifier)
}
}
throw new Error('Invalid pubkey format')
} catch (error) {
console.error('Format conversion failed:', error)
return null
}
}
// ============================================================
// REACT HOOK FOR PROFILE
// ============================================================
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
function useProfile(ndk: NDK | null, npub: string | undefined) {
return useQuery({
queryKey: ['profile', npub],
queryFn: async () => {
if (!ndk || !npub) throw new Error('NDK or npub missing')
return await fetchProfileByNpub(ndk, npub)
},
enabled: !!ndk && !!npub,
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 30 * 60 * 1000 // 30 minutes
})
}
// ============================================================
// REACT COMPONENT EXAMPLE
// ============================================================
interface ProfileDisplayProps {
ndk: NDK
pubkey: string
}
function ProfileDisplay({ ndk, pubkey }: ProfileDisplayProps) {
const [profile, setProfile] = useState<NDKUserProfile | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const loadProfile = async () => {
setLoading(true)
try {
const user = ndk.getUser({ hexpubkey: pubkey })
const fetchedProfile = await user.fetchProfile()
setProfile(fetchedProfile)
} catch (error) {
console.error('Failed to load profile:', error)
} finally {
setLoading(false)
}
}
loadProfile()
}, [ndk, pubkey])
if (loading) {
return <div>Loading profile...</div>
}
const info = extractProfileInfo(profile)
return (
<div className="profile">
{info.avatar && <img src={info.avatar} alt={info.displayName} />}
<h2>{info.displayName}</h2>
{info.bio && <p>{info.bio}</p>}
{info.nip05 && <span> {info.nip05}</span>}
{info.lightningAddress && <span> {info.lightningAddress}</span>}
</div>
)
}
// ============================================================
// FOLLOW/UNFOLLOW USER
// ============================================================
const followUser = async (ndk: NDK, pubkeyToFollow: string) => {
if (!ndk.signer) {
throw new Error('No signer available')
}
// Fetch current contact list (kind 3)
const currentUser = await ndk.signer.user()
const contactListFilter = {
kinds: [3],
authors: [currentUser.pubkey]
}
const existingEvents = await ndk.fetchEvents(contactListFilter)
const existingContactList = existingEvents.size > 0
? Array.from(existingEvents)[0]
: null
// Get existing p tags
const existingPTags = existingContactList
? existingContactList.tags.filter(tag => tag[0] === 'p')
: []
// Check if already following
const alreadyFollowing = existingPTags.some(tag => tag[1] === pubkeyToFollow)
if (alreadyFollowing) {
console.log('Already following this user')
return
}
// Create new contact list with added user
const event = new NDKEvent(ndk)
event.kind = 3
event.content = existingContactList?.content || ''
event.tags = [
...existingPTags,
['p', pubkeyToFollow]
]
await event.sign()
await event.publish()
console.log('✅ Now following user')
}
// ============================================================
// USAGE EXAMPLE
// ============================================================
async function profileExample(ndk: NDK) {
// Fetch by different identifiers
const profile1 = await fetchProfileByNpub(ndk, 'npub1...')
const profile2 = await fetchProfileByNip05(ndk, 'user@domain.com')
const profile3 = await fetchProfileByPubkey(ndk, 'hex pubkey...')
// Extract display info
const info = extractProfileInfo(profile1)
console.log('Display name:', info.displayName)
console.log('Avatar:', info.avatar)
// Update own profile
await updateProfile(ndk, {
name: 'My Name',
about: 'My bio',
picture: 'https://example.com/avatar.jpg',
lud16: 'me@getalby.com'
})
// Follow someone
await followUser(ndk, 'pubkey to follow')
}
export {
fetchProfileByNpub,
fetchProfileByPubkey,
fetchProfileByNip05,
fetchProfileByIdentifier,
getCurrentUser,
extractProfileInfo,
updateProfile,
fetchMultipleProfiles,
convertPubkeyFormats,
useProfile,
followUser
}

View File

@@ -0,0 +1,94 @@
# NDK Examples Index
Complete code examples extracted from the Plebeian Market production codebase.
## Available Examples
### 01-initialization.ts
- Basic NDK initialization
- Multiple NDK instances (main + zap relays)
- Connection with timeout protection
- Connection status checking
- Full initialization flow with error handling
### 02-authentication.ts
- NIP-07 browser extension login
- Private key signer
- NIP-46 remote signer (Bunker)
- Auto-login from localStorage
- Saving auth credentials
- Logout functionality
- Getting current user
### 03-publishing-events.ts
- Basic note publishing
- Events with tags (mentions, hashtags, replies)
- Product listings (parameterized replaceable events)
- Order creation events
- Status update events
- Batch publishing
- Custom signer usage
- Comprehensive error handling
### 04-querying-subscribing.ts
- Basic fetch queries
- Multiple author queries
- Tag filtering
- Time range filtering
- Event ID lookup
- Real-time subscriptions
- Subscription cleanup patterns
- React integration hooks
- React Query integration
- Waiting for specific events
- Payment monitoring
### 05-users-profiles.ts
- Fetch profile by npub
- Fetch profile by hex pubkey
- Fetch profile by NIP-05
- Universal identifier lookup
- Get current user
- Extract profile information
- Update user profile
- Batch fetch multiple profiles
- Convert between pubkey formats (hex/npub)
- React hooks for profiles
- Follow/unfollow users
## Usage
Each file contains:
- Fully typed TypeScript code
- JSDoc comments explaining the pattern
- Error handling examples
- Integration patterns with React/TanStack Query
- Real-world usage examples
All examples are based on actual production code from the Plebeian Market application.
## Running Examples
```typescript
import { initializeNDK } from './01-initialization'
import { loginWithExtension } from './02-authentication'
import { publishBasicNote } from './03-publishing-events'
// Initialize NDK
const { ndk, isConnected } = await initializeNDK()
if (isConnected) {
// Authenticate
const { user } = await loginWithExtension(ndk)
// Publish
await publishBasicNote(ndk, 'Hello Nostr!')
}
```
## Additional Resources
- See `../ndk-skill.md` for detailed documentation
- See `../quick-reference.md` for quick lookup
- Check the main codebase for more complex patterns

View File

@@ -0,0 +1,701 @@
# NDK (Nostr Development Kit) - Claude Skill Reference
## Overview
NDK is the primary Nostr development kit with outbox-model support, designed for building Nostr applications with TypeScript/JavaScript. This reference is based on analyzing production usage in the Plebeian Market codebase.
## Core Concepts
### 1. NDK Initialization
**Basic Pattern:**
```typescript
import NDK from '@nostr-dev-kit/ndk'
// Simple initialization
const ndk = new NDK({
explicitRelayUrls: ['wss://relay.damus.io', 'wss://relay.nostr.band']
})
await ndk.connect()
```
**Store-based Pattern (Production):**
```typescript
// From src/lib/stores/ndk.ts
const ndk = new NDK({
explicitRelayUrls: relays || defaultRelaysUrls,
})
// Separate NDK for zaps on specialized relays
const zapNdk = new NDK({
explicitRelayUrls: ZAP_RELAYS,
})
// Connect with timeout protection
const connectPromise = ndk.connect()
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Connection timeout')), timeoutMs)
)
await Promise.race([connectPromise, timeoutPromise])
```
### 2. Authentication & Signers
NDK supports multiple signer types for different authentication methods:
#### NIP-07 (Browser Extension)
```typescript
import { NDKNip07Signer } from '@nostr-dev-kit/ndk'
const signer = new NDKNip07Signer()
await signer.blockUntilReady()
ndk.signer = signer
const user = await signer.user()
```
#### Private Key Signer
```typescript
import { NDKPrivateKeySigner } from '@nostr-dev-kit/ndk'
const signer = new NDKPrivateKeySigner(privateKeyHex)
await signer.blockUntilReady()
ndk.signer = signer
const user = await signer.user()
```
#### NIP-46 (Remote Signer / Bunker)
```typescript
import { NDKNip46Signer } from '@nostr-dev-kit/ndk'
const localSigner = new NDKPrivateKeySigner(localPrivateKey)
const remoteSigner = new NDKNip46Signer(ndk, bunkerUrl, localSigner)
await remoteSigner.blockUntilReady()
ndk.signer = remoteSigner
const user = await remoteSigner.user()
```
**Key Points:**
- Always call `blockUntilReady()` before using a signer
- Store signer reference in your state management
- Set `ndk.signer` to enable signing operations
- Use `await signer.user()` to get the authenticated user
### 3. Event Creation & Publishing
#### Basic Event Pattern
```typescript
import { NDKEvent } from '@nostr-dev-kit/ndk'
// Create event
const event = new NDKEvent(ndk)
event.kind = 1 // Kind 1 = text note
event.content = "Hello Nostr!"
event.tags = [
['t', 'nostr'],
['p', recipientPubkey]
]
// Sign and publish
await event.sign() // Uses ndk.signer automatically
await event.publish()
// Get event ID after signing
console.log(event.id)
```
#### Production Pattern with Error Handling
```typescript
// From src/publish/orders.tsx
const event = new NDKEvent(ndk)
event.kind = ORDER_PROCESS_KIND
event.content = orderNotes || ''
event.tags = [
['p', sellerPubkey],
['subject', `Order for ${productName}`],
['type', 'order-creation'],
['order', orderId],
['amount', totalAmount],
['item', productRef, quantity.toString()],
]
// Optional tags
if (shippingRef) {
event.tags.push(['shipping', shippingRef])
}
try {
await event.sign(signer) // Can pass explicit signer
await event.publish()
return event.id
} catch (error) {
console.error('Failed to publish event:', error)
throw error
}
```
**Key Points:**
- Create event with `new NDKEvent(ndk)`
- Set `kind`, `content`, and `tags` properties
- Optional: Set `created_at` timestamp (defaults to now)
- Call `await event.sign()` before publishing
- Call `await event.publish()` to broadcast to relays
- Access `event.id` after signing for the event hash
### 4. Querying Events with Filters
#### fetchEvents() - One-time Fetch
```typescript
import { NDKFilter } from '@nostr-dev-kit/ndk'
// Simple filter
const filter: NDKFilter = {
kinds: [30402], // Product listings
authors: [merchantPubkey],
limit: 50
}
const events = await ndk.fetchEvents(filter)
// Returns Set<NDKEvent>
// Convert to array and process
const eventArray = Array.from(events)
const sortedEvents = eventArray.sort((a, b) =>
(b.created_at || 0) - (a.created_at || 0)
)
```
#### Advanced Filters
```typescript
// Multiple kinds
const filter: NDKFilter = {
kinds: [16, 17], // Orders and payment receipts
'#order': [orderId], // Tag filter (# prefix)
since: Math.floor(Date.now() / 1000) - 86400, // Last 24 hours
limit: 100
}
// Event ID lookup
const filter: NDKFilter = {
ids: [eventIdHex],
}
// Tag filtering
const filter: NDKFilter = {
kinds: [1],
'#p': [pubkey], // Events mentioning pubkey
'#t': ['nostr'], // Events with hashtag 'nostr'
}
```
### 5. Subscriptions (Real-time)
#### Basic Subscription
```typescript
// From src/queries/blacklist.tsx
const filter = {
kinds: [10000],
authors: [appPubkey],
}
const subscription = ndk.subscribe(filter, {
closeOnEose: false, // Keep open for real-time updates
})
subscription.on('event', (event: NDKEvent) => {
console.log('New event received:', event)
// Process event
})
subscription.on('eose', () => {
console.log('End of stored events')
})
// Cleanup
subscription.stop()
```
#### Production Pattern with React Query
```typescript
// From src/queries/orders.tsx
useEffect(() => {
if (!orderId || !ndk) return
const filter = {
kinds: [ORDER_PROCESS_KIND, PAYMENT_RECEIPT_KIND],
'#order': [orderId],
}
const subscription = ndk.subscribe(filter, {
closeOnEose: false,
})
subscription.on('event', (newEvent) => {
// Invalidate React Query cache to trigger refetch
queryClient.invalidateQueries({
queryKey: orderKeys.details(orderId)
})
})
// Cleanup on unmount
return () => {
subscription.stop()
}
}, [orderId, ndk, queryClient])
```
#### Monitoring Specific Events
```typescript
// From src/queries/payment.tsx - Payment receipt monitoring
const receiptFilter = {
kinds: [17], // Payment receipts
'#order': [orderId],
'#payment-request': [invoiceId],
since: sessionStartTime - 30, // Clock skew buffer
}
const subscription = ndk.subscribe(receiptFilter, {
closeOnEose: false,
})
subscription.on('event', (receiptEvent: NDKEvent) => {
// Verify this is the correct invoice
const paymentRequestTag = receiptEvent.tags.find(
tag => tag[0] === 'payment-request'
)
if (paymentRequestTag?.[1] === invoiceId) {
const paymentTag = receiptEvent.tags.find(tag => tag[0] === 'payment')
const preimage = paymentTag?.[3] || 'external-payment'
// Stop subscription after finding payment
subscription.stop()
handlePaymentReceived(preimage)
}
})
```
**Key Subscription Patterns:**
- Use `closeOnEose: false` for real-time monitoring
- Use `closeOnEose: true` for one-time historical fetch
- Always call `subscription.stop()` in cleanup
- Listen to both `'event'` and `'eose'` events
- Filter events in the handler for specific conditions
- Integrate with React Query for reactive UI updates
### 6. User & Profile Handling
#### Fetching User Profiles
```typescript
// From src/queries/profiles.tsx
// By npub
const user = ndk.getUser({ npub })
const profile = await user.fetchProfile()
// Returns NDKUserProfile with name, picture, about, etc.
// By hex pubkey
const user = ndk.getUser({ hexpubkey: pubkey })
const profile = await user.fetchProfile()
// By NIP-05 identifier
const user = await ndk.getUserFromNip05('user@domain.com')
if (user) {
const profile = await user.fetchProfile()
}
// Profile fields
const name = profile?.name || profile?.displayName
const avatar = profile?.picture || profile?.image
const bio = profile?.about
const nip05 = profile?.nip05
const lud16 = profile?.lud16 // Lightning address
```
#### Getting Current User
```typescript
// Active user (authenticated)
const user = ndk.activeUser
// From signer
const user = await ndk.signer?.user()
// User properties
const pubkey = user.pubkey // Hex format
const npub = user.npub // NIP-19 encoded
```
### 7. NDK Event Object
#### Essential Properties
```typescript
interface NDKEvent {
id: string // Event hash (after signing)
kind: number // Event kind
content: string // Event content
tags: NDKTag[] // Array of tag arrays
created_at?: number // Unix timestamp
pubkey?: string // Author pubkey (after signing)
sig?: string // Signature (after signing)
// Methods
sign(signer?: NDKSigner): Promise<void>
publish(): Promise<void>
tagValue(tagName: string): string | undefined
}
type NDKTag = string[] // e.g., ['p', pubkey, relay, petname]
```
#### Tag Helpers
```typescript
// Get first value of a tag
const orderId = event.tagValue('order')
const recipientPubkey = event.tagValue('p')
// Find specific tag
const paymentTag = event.tags.find(tag => tag[0] === 'payment')
const preimage = paymentTag?.[3]
// Get all tags of a type
const pTags = event.tags.filter(tag => tag[0] === 'p')
const allPubkeys = pTags.map(tag => tag[1])
// Common tag patterns
event.tags.push(['p', pubkey]) // Mention
event.tags.push(['e', eventId]) // Reference event
event.tags.push(['t', 'nostr']) // Hashtag
event.tags.push(['d', identifier]) // Replaceable event ID
event.tags.push(['a', '30402:pubkey:d-tag']) // Addressable event reference
```
### 8. Parameterized Replaceable Events (NIP-33)
Used for products, collections, profiles that need updates:
```typescript
// Product listing (kind 30402)
const event = new NDKEvent(ndk)
event.kind = 30402
event.content = JSON.stringify(productDetails)
event.tags = [
['d', productSlug], // Unique identifier
['title', productName],
['price', price, currency],
['image', imageUrl],
['shipping', shippingRef],
]
await event.sign()
await event.publish()
// Querying replaceable events
const filter = {
kinds: [30402],
authors: [merchantPubkey],
'#d': [productSlug], // Specific product
}
const events = await ndk.fetchEvents(filter)
// Returns only the latest version due to replaceable nature
```
### 9. Relay Management
#### Getting Relay Status
```typescript
// From src/lib/stores/ndk.ts
const connectedRelays = Array.from(ndk.pool?.relays.values() || [])
.filter(relay => relay.status === 1) // 1 = connected
.map(relay => relay.url)
const outboxRelays = Array.from(ndk.outboxPool?.relays.values() || [])
```
#### Adding Relays
```typescript
// Add explicit relays
ndk.addExplicitRelay('wss://relay.example.com')
// Multiple relays
const relays = ['wss://relay1.com', 'wss://relay2.com']
relays.forEach(url => ndk.addExplicitRelay(url))
```
### 10. Common Patterns & Best Practices
#### Null Safety
```typescript
// Always check NDK initialization
const ndk = ndkActions.getNDK()
if (!ndk) throw new Error('NDK not initialized')
// Check signer before operations requiring auth
const signer = ndk.signer
if (!signer) throw new Error('No active signer')
// Check user authentication
const user = ndk.activeUser
if (!user) throw new Error('Not authenticated')
```
#### Error Handling
```typescript
try {
const events = await ndk.fetchEvents(filter)
if (events.size === 0) {
return null // No results found
}
return Array.from(events)
} catch (error) {
console.error('Failed to fetch events:', error)
throw new Error('Could not fetch data from relays')
}
```
#### Connection Lifecycle
```typescript
// Initialize once at app startup
const ndk = new NDK({ explicitRelayUrls: relays })
// Connect with timeout
await Promise.race([
ndk.connect(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 10000)
)
])
// Check connection status
const isConnected = ndk.pool?.connectedRelays().length > 0
// Reconnect if needed
if (!isConnected) {
await ndk.connect()
}
```
#### Subscription Cleanup
```typescript
// In React components
useEffect(() => {
if (!ndk) return
const sub = ndk.subscribe(filter, { closeOnEose: false })
sub.on('event', handleEvent)
sub.on('eose', handleEose)
// Critical: cleanup on unmount
return () => {
sub.stop()
}
}, [dependencies])
```
#### Event Validation
```typescript
// Check required fields before processing
if (!event.pubkey) {
console.error('Event missing pubkey')
return
}
if (!event.created_at) {
console.error('Event missing timestamp')
return
}
// Verify event age
const now = Math.floor(Date.now() / 1000)
const eventAge = now - (event.created_at || 0)
if (eventAge > 86400) { // Older than 24 hours
console.log('Event is old, skipping')
return
}
// Validate specific tags exist
const orderId = event.tagValue('order')
if (!orderId) {
console.error('Order event missing order ID')
return
}
```
### 11. Common Event Kinds
```typescript
// NIP-01: Basic Events
const KIND_METADATA = 0 // User profile
const KIND_TEXT_NOTE = 1 // Short text note
const KIND_RECOMMEND_RELAY = 2 // Relay recommendation
// NIP-04: Encrypted Direct Messages
const KIND_ENCRYPTED_DM = 4
// NIP-25: Reactions
const KIND_REACTION = 7
// NIP-51: Lists
const KIND_MUTE_LIST = 10000
const KIND_PIN_LIST = 10001
const KIND_RELAY_LIST = 10002
// NIP-57: Lightning Zaps
const KIND_ZAP_REQUEST = 9734
const KIND_ZAP_RECEIPT = 9735
// Marketplace (Plebeian/Gamma spec)
const ORDER_PROCESS_KIND = 16 // Order processing
const PAYMENT_RECEIPT_KIND = 17 // Payment receipts
const DIRECT_MESSAGE_KIND = 14 // Direct messages
const ORDER_GENERAL_KIND = 27 // General order events
const SHIPPING_KIND = 30405 // Shipping options
const PRODUCT_KIND = 30402 // Product listings
const COLLECTION_KIND = 30401 // Product collections
const REVIEW_KIND = 30407 // Product reviews
// Application Handlers
const APP_HANDLER_KIND = 31990 // NIP-89 app handlers
```
## Integration with TanStack Query
NDK works excellently with TanStack Query for reactive data fetching:
### Query Functions
```typescript
// From src/queries/products.tsx
export const fetchProductsByPubkey = async (pubkey: string) => {
const ndk = ndkActions.getNDK()
if (!ndk) throw new Error('NDK not initialized')
const filter: NDKFilter = {
kinds: [30402],
authors: [pubkey],
}
const events = await ndk.fetchEvents(filter)
return Array.from(events).map(parseProductEvent)
}
export const useProductsByPubkey = (pubkey: string) => {
return useQuery({
queryKey: productKeys.byAuthor(pubkey),
queryFn: () => fetchProductsByPubkey(pubkey),
enabled: !!pubkey,
staleTime: 30000,
})
}
```
### Combining Queries with Subscriptions
```typescript
// Query for initial data
const { data: order, refetch } = useQuery({
queryKey: orderKeys.details(orderId),
queryFn: () => fetchOrderById(orderId),
enabled: !!orderId,
})
// Subscription for real-time updates
useEffect(() => {
if (!orderId || !ndk) return
const sub = ndk.subscribe(
{ kinds: [16, 17], '#order': [orderId] },
{ closeOnEose: false }
)
sub.on('event', () => {
// Invalidate query to trigger refetch
queryClient.invalidateQueries({
queryKey: orderKeys.details(orderId)
})
})
return () => sub.stop()
}, [orderId, ndk, queryClient])
```
## Troubleshooting
### Events Not Received
- Check relay connections: `ndk.pool?.connectedRelays()`
- Verify filter syntax (especially tag filters with `#` prefix)
- Check event timestamps match filter's `since`/`until`
- Ensure `closeOnEose: false` for real-time subscriptions
### Signing Errors
- Verify signer is initialized: `await signer.blockUntilReady()`
- Check signer is set: `ndk.signer !== undefined`
- For NIP-07, ensure browser extension is installed and enabled
- For NIP-46, verify bunker URL and local signer are correct
### Connection Timeouts
- Implement connection timeout pattern shown above
- Try connecting to fewer, more reliable relays initially
- Use fallback relays in production
### Duplicate Events
- NDK deduplicates by event ID automatically
- For subscriptions, track processed event IDs if needed
- Use replaceable events (kinds 10000-19999, 30000-39999) when appropriate
## Performance Optimization
### Batching Queries
```typescript
// Instead of multiple fetchEvents calls
const [products, orders, profiles] = await Promise.all([
ndk.fetchEvents(productFilter),
ndk.fetchEvents(orderFilter),
ndk.fetchEvents(profileFilter),
])
```
### Limiting Results
```typescript
const filter = {
kinds: [1],
authors: [pubkey],
limit: 50, // Limit results
since: recentTimestamp, // Only recent events
}
```
### Caching with React Query
```typescript
export const useProfile = (npub: string) => {
return useQuery({
queryKey: profileKeys.byNpub(npub),
queryFn: () => fetchProfileByNpub(npub),
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 30 * 60 * 1000, // 30 minutes
enabled: !!npub,
})
}
```
## References
- **NDK GitHub**: https://github.com/nostr-dev-kit/ndk
- **NDK Documentation**: https://ndk.fyi
- **Nostr NIPs**: https://github.com/nostr-protocol/nips
- **Production Example**: Plebeian Market codebase
## Key Files in This Codebase
- `src/lib/stores/ndk.ts` - NDK store and initialization
- `src/lib/stores/auth.ts` - Authentication with NDK signers
- `src/queries/*.tsx` - Query patterns with NDK
- `src/publish/*.tsx` - Event publishing patterns
- `scripts/gen_*.ts` - Event creation examples
---
*This reference is based on NDK version used in production and real-world patterns from the Plebeian Market application.*

View File

@@ -0,0 +1,351 @@
# NDK Quick Reference
Fast lookup guide for common NDK tasks.
## Quick Start
```typescript
import NDK from '@nostr-dev-kit/ndk'
const ndk = new NDK({ explicitRelayUrls: ['wss://relay.damus.io'] })
await ndk.connect()
```
## Authentication
### Browser Extension (NIP-07)
```typescript
import { NDKNip07Signer } from '@nostr-dev-kit/ndk'
const signer = new NDKNip07Signer()
await signer.blockUntilReady()
ndk.signer = signer
```
### Private Key
```typescript
import { NDKPrivateKeySigner } from '@nostr-dev-kit/ndk'
const signer = new NDKPrivateKeySigner(privateKeyHex)
await signer.blockUntilReady()
ndk.signer = signer
```
### Remote Signer (NIP-46)
```typescript
import { NDKNip46Signer, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk'
const localSigner = new NDKPrivateKeySigner()
const remoteSigner = new NDKNip46Signer(ndk, bunkerUrl, localSigner)
await remoteSigner.blockUntilReady()
ndk.signer = remoteSigner
```
## Publish Event
```typescript
import { NDKEvent } from '@nostr-dev-kit/ndk'
const event = new NDKEvent(ndk)
event.kind = 1
event.content = "Hello Nostr!"
event.tags = [['t', 'nostr']]
await event.sign()
await event.publish()
```
## Query Events (One-time)
```typescript
const events = await ndk.fetchEvents({
kinds: [1],
authors: [pubkey],
limit: 50
})
// Convert Set to Array
const eventArray = Array.from(events)
```
## Subscribe (Real-time)
```typescript
const sub = ndk.subscribe(
{ kinds: [1], authors: [pubkey] },
{ closeOnEose: false }
)
sub.on('event', (event) => {
console.log('New event:', event.content)
})
// Cleanup
sub.stop()
```
## Get User Profile
```typescript
// By npub
const user = ndk.getUser({ npub })
const profile = await user.fetchProfile()
// By hex pubkey
const user = ndk.getUser({ hexpubkey: pubkey })
const profile = await user.fetchProfile()
// By NIP-05
const user = await ndk.getUserFromNip05('user@domain.com')
const profile = await user?.fetchProfile()
```
## Common Filters
```typescript
// By author
{ kinds: [1], authors: [pubkey] }
// By tag
{ kinds: [1], '#p': [pubkey] }
{ kinds: [30402], '#d': [productSlug] }
// By time
{
kinds: [1],
since: Math.floor(Date.now() / 1000) - 86400, // Last 24h
until: Math.floor(Date.now() / 1000)
}
// By event ID
{ ids: [eventId] }
// Multiple conditions
{
kinds: [16, 17],
'#order': [orderId],
since: timestamp,
limit: 100
}
```
## Tag Helpers
```typescript
// Get first tag value
const orderId = event.tagValue('order')
// Find specific tag
const tag = event.tags.find(t => t[0] === 'payment')
const value = tag?.[1]
// Get all of one type
const pTags = event.tags.filter(t => t[0] === 'p')
// Common tag formats
['p', pubkey] // Mention
['e', eventId] // Event reference
['t', 'nostr'] // Hashtag
['d', identifier] // Replaceable ID
['a', '30402:pubkey:d-tag'] // Addressable reference
```
## Error Handling Pattern
```typescript
const ndk = ndkActions.getNDK()
if (!ndk) throw new Error('NDK not initialized')
const signer = ndk.signer
if (!signer) throw new Error('No active signer')
try {
await event.publish()
} catch (error) {
console.error('Publish failed:', error)
throw error
}
```
## React Integration
```typescript
// Query function
export const fetchProducts = async (pubkey: string) => {
const ndk = ndkActions.getNDK()
if (!ndk) throw new Error('NDK not initialized')
const events = await ndk.fetchEvents({
kinds: [30402],
authors: [pubkey]
})
return Array.from(events)
}
// React Query hook
export const useProducts = (pubkey: string) => {
return useQuery({
queryKey: ['products', pubkey],
queryFn: () => fetchProducts(pubkey),
enabled: !!pubkey,
})
}
// Subscription in useEffect
useEffect(() => {
if (!ndk || !orderId) return
const sub = ndk.subscribe(
{ kinds: [16], '#order': [orderId] },
{ closeOnEose: false }
)
sub.on('event', () => {
queryClient.invalidateQueries(['order', orderId])
})
return () => sub.stop()
}, [ndk, orderId, queryClient])
```
## Common Event Kinds
```typescript
0 // Metadata (profile)
1 // Text note
4 // Encrypted DM (NIP-04)
7 // Reaction
9735 // Zap receipt
10000 // Mute list
10002 // Relay list
30402 // Product listing (Marketplace)
31990 // App handler (NIP-89)
```
## Relay Management
```typescript
// Check connection
const connected = ndk.pool?.connectedRelays().length > 0
// Get connected relays
const relays = Array.from(ndk.pool?.relays.values() || [])
.filter(r => r.status === 1)
// Add relay
ndk.addExplicitRelay('wss://relay.example.com')
```
## Connection with Timeout
```typescript
const connectWithTimeout = async (timeoutMs = 10000) => {
const connectPromise = ndk.connect()
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeoutMs)
)
await Promise.race([connectPromise, timeoutPromise])
}
```
## Current User
```typescript
// Active user
const user = ndk.activeUser
// From signer
const user = await ndk.signer?.user()
// User info
const pubkey = user.pubkey // hex
const npub = user.npub // NIP-19
```
## Parameterized Replaceable Events
```typescript
// Create
const event = new NDKEvent(ndk)
event.kind = 30402
event.content = JSON.stringify(data)
event.tags = [
['d', uniqueIdentifier], // Required for replaceable
['title', 'Product Name'],
]
await event.sign()
await event.publish()
// Query (returns latest only)
const events = await ndk.fetchEvents({
kinds: [30402],
authors: [pubkey],
'#d': [identifier]
})
```
## Validation Checks
```typescript
// Event age check
const now = Math.floor(Date.now() / 1000)
const age = now - (event.created_at || 0)
if (age > 86400) console.log('Event older than 24h')
// Required fields
if (!event.pubkey || !event.created_at || !event.sig) {
throw new Error('Invalid event')
}
// Tag existence
const orderId = event.tagValue('order')
if (!orderId) throw new Error('Missing order tag')
```
## Performance Tips
```typescript
// Batch queries
const [products, orders] = await Promise.all([
ndk.fetchEvents(productFilter),
ndk.fetchEvents(orderFilter)
])
// Limit results
const filter = {
kinds: [1],
limit: 50,
since: recentTimestamp
}
// Cache with React Query
const { data } = useQuery({
queryKey: ['profile', npub],
queryFn: () => fetchProfile(npub),
staleTime: 5 * 60 * 1000, // 5 min
})
```
## Debugging
```typescript
// Check NDK state
console.log('Connected:', ndk.pool?.connectedRelays())
console.log('Signer:', ndk.signer)
console.log('Active user:', ndk.activeUser)
// Event inspection
console.log('Event ID:', event.id)
console.log('Tags:', event.tags)
console.log('Content:', event.content)
console.log('Author:', event.pubkey)
// Subscription events
sub.on('event', e => console.log('Event:', e))
sub.on('eose', () => console.log('End of stored events'))
```
---
For detailed explanations and advanced patterns, see `ndk-skill.md`.

View File

@@ -0,0 +1,530 @@
# NDK Common Patterns & Troubleshooting
Quick reference for common patterns and solutions to frequent NDK issues.
## Common Patterns
### Store-Based NDK Management
```typescript
// Store pattern (recommended for React apps)
import { Store } from '@tanstack/store'
interface NDKState {
ndk: NDK | null
isConnected: boolean
signer?: NDKSigner
}
const ndkStore = new Store<NDKState>({
ndk: null,
isConnected: false
})
export const ndkActions = {
initialize: () => {
const ndk = new NDK({ explicitRelayUrls: relays })
ndkStore.setState({ ndk })
return ndk
},
getNDK: () => ndkStore.state.ndk,
setSigner: (signer: NDKSigner) => {
const ndk = ndkStore.state.ndk
if (ndk) {
ndk.signer = signer
ndkStore.setState({ signer })
}
}
}
```
### Query + Subscription Pattern
```typescript
// Initial data load + real-time updates
function useOrdersWithRealtime(orderId: string) {
const queryClient = useQueryClient()
const ndk = ndkActions.getNDK()
// Fetch initial data
const query = useQuery({
queryKey: ['orders', orderId],
queryFn: () => fetchOrders(orderId),
})
// Subscribe to updates
useEffect(() => {
if (!ndk || !orderId) return
const sub = ndk.subscribe(
{ kinds: [16], '#order': [orderId] },
{ closeOnEose: false }
)
sub.on('event', () => {
queryClient.invalidateQueries(['orders', orderId])
})
return () => sub.stop()
}, [ndk, orderId])
return query
}
```
### Event Parsing Pattern
```typescript
// Parse event tags into structured data
function parseProductEvent(event: NDKEvent) {
const getTag = (name: string) =>
event.tags.find(t => t[0] === name)?.[1]
const getAllTags = (name: string) =>
event.tags.filter(t => t[0] === name).map(t => t[1])
return {
id: event.id,
slug: getTag('d'),
title: getTag('title'),
price: parseFloat(getTag('price') || '0'),
currency: event.tags.find(t => t[0] === 'price')?.[2] || 'USD',
images: getAllTags('image'),
shipping: getAllTags('shipping'),
description: event.content,
createdAt: event.created_at,
author: event.pubkey
}
}
```
### Relay Pool Pattern
```typescript
// Separate NDK instances for different purposes
const mainNdk = new NDK({
explicitRelayUrls: ['wss://relay.damus.io', 'wss://nos.lol']
})
const zapNdk = new NDK({
explicitRelayUrls: ['wss://relay.damus.io'] // Zap-optimized relays
})
const blossomNdk = new NDK({
explicitRelayUrls: ['wss://blossom.server.com'] // Media server
})
await Promise.all([
mainNdk.connect(),
zapNdk.connect(),
blossomNdk.connect()
])
```
## Troubleshooting
### Problem: Events Not Received
**Symptoms:** Subscription doesn't receive events, fetchEvents returns empty Set
**Solutions:**
1. Check relay connection:
```typescript
const status = ndk.pool?.connectedRelays()
console.log('Connected relays:', status?.length)
if (status?.length === 0) {
await ndk.connect()
}
```
2. Verify filter syntax (especially tags):
```typescript
// ❌ Wrong
{ kinds: [16], 'order': [orderId] }
// ✅ Correct (note the # prefix for tags)
{ kinds: [16], '#order': [orderId] }
```
3. Check timestamps:
```typescript
// Events might be too old/new
const now = Math.floor(Date.now() / 1000)
const filter = {
kinds: [1],
since: now - 86400, // Last 24 hours
until: now
}
```
4. Ensure closeOnEose is correct:
```typescript
// For real-time updates
ndk.subscribe(filter, { closeOnEose: false })
// For one-time historical fetch
ndk.subscribe(filter, { closeOnEose: true })
```
### Problem: "NDK not initialized"
**Symptoms:** `ndk` is null/undefined
**Solutions:**
1. Initialize before use:
```typescript
// In app entry point
const ndk = new NDK({ explicitRelayUrls: relays })
await ndk.connect()
```
2. Add null checks:
```typescript
const ndk = ndkActions.getNDK()
if (!ndk) throw new Error('NDK not initialized')
```
3. Use initialization guard:
```typescript
const ensureNDK = () => {
let ndk = ndkActions.getNDK()
if (!ndk) {
ndk = ndkActions.initialize()
}
return ndk
}
```
### Problem: "No active signer" / Cannot Sign Events
**Symptoms:** Event signing fails, publishing throws error
**Solutions:**
1. Check signer is set:
```typescript
if (!ndk.signer) {
throw new Error('Please login first')
}
```
2. Ensure blockUntilReady called:
```typescript
const signer = new NDKNip07Signer()
await signer.blockUntilReady() // ← Critical!
ndk.signer = signer
```
3. Handle NIP-07 unavailable:
```typescript
try {
const signer = new NDKNip07Signer()
await signer.blockUntilReady()
ndk.signer = signer
} catch (error) {
console.error('Browser extension not available')
// Fallback to other auth method
}
```
### Problem: Duplicate Events in Subscriptions
**Symptoms:** Same event received multiple times
**Solutions:**
1. Track processed event IDs:
```typescript
const processedIds = new Set<string>()
sub.on('event', (event) => {
if (processedIds.has(event.id)) return
processedIds.add(event.id)
handleEvent(event)
})
```
2. Use Map for event storage:
```typescript
const [events, setEvents] = useState<Map<string, NDKEvent>>(new Map())
sub.on('event', (event) => {
setEvents(prev => new Map(prev).set(event.id, event))
})
```
### Problem: Connection Timeout
**Symptoms:** connect() hangs, never resolves
**Solutions:**
1. Use timeout wrapper:
```typescript
const connectWithTimeout = async (ndk: NDK, ms = 10000) => {
await Promise.race([
ndk.connect(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), ms)
)
])
}
```
2. Try fewer relays:
```typescript
// Start with reliable relays only
const reliableRelays = ['wss://relay.damus.io']
const ndk = new NDK({ explicitRelayUrls: reliableRelays })
```
3. Add connection retry:
```typescript
const connectWithRetry = async (ndk: NDK, maxRetries = 3) => {
for (let i = 0; i < maxRetries; i++) {
try {
await connectWithTimeout(ndk, 10000)
return
} catch (error) {
console.log(`Retry ${i + 1}/${maxRetries}`)
if (i === maxRetries - 1) throw error
}
}
}
```
### Problem: Subscription Memory Leak
**Symptoms:** App gets slower, memory usage increases
**Solutions:**
1. Always stop subscriptions:
```typescript
useEffect(() => {
const sub = ndk.subscribe(filter, { closeOnEose: false })
// ← CRITICAL: cleanup
return () => {
sub.stop()
}
}, [dependencies])
```
2. Track active subscriptions:
```typescript
const activeSubscriptions = new Set<NDKSubscription>()
const createSub = (filter: NDKFilter) => {
const sub = ndk.subscribe(filter, { closeOnEose: false })
activeSubscriptions.add(sub)
return sub
}
const stopAllSubs = () => {
activeSubscriptions.forEach(sub => sub.stop())
activeSubscriptions.clear()
}
```
### Problem: Profile Not Found
**Symptoms:** fetchProfile() returns null/undefined
**Solutions:**
1. Check different relays:
```typescript
// Add more relay URLs
const ndk = new NDK({
explicitRelayUrls: [
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nos.lol'
]
})
```
2. Verify pubkey format:
```typescript
// Ensure correct format
if (pubkey.startsWith('npub')) {
const user = ndk.getUser({ npub: pubkey })
} else if (/^[0-9a-f]{64}$/.test(pubkey)) {
const user = ndk.getUser({ hexpubkey: pubkey })
}
```
3. Handle missing profiles gracefully:
```typescript
const profile = await user.fetchProfile()
const displayName = profile?.name || profile?.displayName || 'Anonymous'
const avatar = profile?.picture || '/default-avatar.png'
```
### Problem: Events Published But Not Visible
**Symptoms:** publish() succeeds but event not found in queries
**Solutions:**
1. Verify event was signed:
```typescript
await event.sign()
console.log('Event ID:', event.id) // Should be set
console.log('Signature:', event.sig) // Should exist
```
2. Check relay acceptance:
```typescript
const relays = await event.publish()
console.log('Published to relays:', relays)
```
3. Query immediately after publish:
```typescript
await event.publish()
// Wait a moment for relay propagation
await new Promise(resolve => setTimeout(resolve, 1000))
const found = await ndk.fetchEvents({ ids: [event.id] })
console.log('Event found:', found.size > 0)
```
### Problem: NIP-46 Connection Fails
**Symptoms:** Remote signer connection times out or fails
**Solutions:**
1. Verify bunker URL format:
```typescript
// Correct format: bunker://<remote-pubkey>?relay=wss://...
const isValidBunkerUrl = (url: string) => {
return url.startsWith('bunker://') && url.includes('?relay=')
}
```
2. Ensure local signer is ready:
```typescript
const localSigner = new NDKPrivateKeySigner(privateKey)
await localSigner.blockUntilReady()
const remoteSigner = new NDKNip46Signer(ndk, bunkerUrl, localSigner)
await remoteSigner.blockUntilReady()
```
3. Store credentials for reconnection:
```typescript
// Save for future sessions
localStorage.setItem('local-signer-key', localSigner.privateKey)
localStorage.setItem('bunker-url', bunkerUrl)
```
## Performance Tips
### Optimize Queries
```typescript
// ❌ Slow: Multiple sequential queries
const products = await ndk.fetchEvents({ kinds: [30402], authors: [pk1] })
const orders = await ndk.fetchEvents({ kinds: [16], authors: [pk1] })
const profiles = await ndk.fetchEvents({ kinds: [0], authors: [pk1] })
// ✅ Fast: Parallel queries
const [products, orders, profiles] = await Promise.all([
ndk.fetchEvents({ kinds: [30402], authors: [pk1] }),
ndk.fetchEvents({ kinds: [16], authors: [pk1] }),
ndk.fetchEvents({ kinds: [0], authors: [pk1] })
])
```
### Cache Profile Lookups
```typescript
const profileCache = new Map<string, NDKUserProfile>()
const getCachedProfile = async (ndk: NDK, pubkey: string) => {
if (profileCache.has(pubkey)) {
return profileCache.get(pubkey)!
}
const user = ndk.getUser({ hexpubkey: pubkey })
const profile = await user.fetchProfile()
if (profile) {
profileCache.set(pubkey, profile)
}
return profile
}
```
### Limit Result Sets
```typescript
// Always use limit to prevent over-fetching
const filter: NDKFilter = {
kinds: [1],
authors: [pubkey],
limit: 50 // ← Important!
}
```
### Debounce Subscription Updates
```typescript
import { debounce } from 'lodash'
const debouncedUpdate = debounce((event: NDKEvent) => {
handleEvent(event)
}, 300)
sub.on('event', debouncedUpdate)
```
## Testing Tips
### Mock NDK in Tests
```typescript
const mockNDK = {
fetchEvents: vi.fn().mockResolvedValue(new Set()),
subscribe: vi.fn().mockReturnValue({
on: vi.fn(),
stop: vi.fn()
}),
signer: {
user: vi.fn().mockResolvedValue({ pubkey: 'test-pubkey' })
}
} as unknown as NDK
```
### Test Event Creation
```typescript
const createTestEvent = (overrides?: Partial<NDKEvent>): NDKEvent => {
return {
id: 'test-id',
kind: 1,
content: 'test content',
tags: [],
created_at: Math.floor(Date.now() / 1000),
pubkey: 'test-pubkey',
sig: 'test-sig',
...overrides
} as NDKEvent
}
```
---
For more detailed information, see:
- `ndk-skill.md` - Complete reference
- `quick-reference.md` - Quick lookup
- `examples/` - Code examples

View File

@@ -0,0 +1,978 @@
---
name: nostr-websocket
description: This skill should be used when implementing, debugging, or discussing WebSocket connections for Nostr relays. Provides comprehensive knowledge of RFC 6455 WebSocket protocol, production-ready implementation patterns in Go (khatru), C++ (strfry), and Rust (nostr-rs-relay), including connection lifecycle, message framing, subscription management, and performance optimization techniques specific to Nostr relay operations.
---
# Nostr WebSocket Programming
## Overview
Implement robust, high-performance WebSocket connections for Nostr relays following RFC 6455 specifications and battle-tested production patterns. This skill provides comprehensive guidance on WebSocket protocol fundamentals, connection management, message handling, and language-specific implementation strategies using proven codebases.
## Core WebSocket Protocol (RFC 6455)
### Connection Upgrade Handshake
The WebSocket connection begins with an HTTP upgrade request:
**Client Request Headers:**
- `Upgrade: websocket` - Required
- `Connection: Upgrade` - Required
- `Sec-WebSocket-Key` - 16-byte random value, base64-encoded
- `Sec-WebSocket-Version: 13` - Required
- `Origin` - Required for browser clients (security)
**Server Response (HTTP 101):**
- `HTTP/1.1 101 Switching Protocols`
- `Upgrade: websocket`
- `Connection: Upgrade`
- `Sec-WebSocket-Accept` - SHA-1(client_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"), base64-encoded
**Security validation:** Always verify the `Sec-WebSocket-Accept` value matches expected computation. Reject connections with missing or incorrect values.
### Frame Structure
WebSocket frames use binary encoding with variable-length fields:
**Header (minimum 2 bytes):**
- **FIN bit** (1 bit) - Final fragment indicator
- **RSV1-3** (3 bits) - Reserved for extensions (must be 0)
- **Opcode** (4 bits) - Frame type identifier
- **MASK bit** (1 bit) - Payload masking indicator
- **Payload length** (7, 7+16, or 7+64 bits) - Variable encoding
**Payload length encoding:**
- 0-125: Direct 7-bit value
- 126: Next 16 bits contain length
- 127: Next 64 bits contain length
### Frame Opcodes
**Data Frames:**
- `0x0` - Continuation frame
- `0x1` - Text frame (UTF-8)
- `0x2` - Binary frame
**Control Frames:**
- `0x8` - Connection close
- `0x9` - Ping
- `0xA` - Pong
**Control frame constraints:**
- Maximum 125-byte payload
- Cannot be fragmented
- Must be processed immediately
### Masking Requirements
**Critical security requirement:**
- Client-to-server frames MUST be masked
- Server-to-client frames MUST NOT be masked
- Masking uses XOR with 4-byte random key
- Prevents cache poisoning and intermediary attacks
**Masking algorithm:**
```
transformed[i] = original[i] XOR masking_key[i MOD 4]
```
### Ping/Pong Keep-Alive
**Purpose:** Detect broken connections and maintain NAT traversal
**Pattern:**
1. Either endpoint sends Ping (0x9) with optional payload
2. Recipient responds with Pong (0xA) containing identical payload
3. Implement timeouts to detect unresponsive connections
**Nostr relay recommendations:**
- Send pings every 30-60 seconds
- Timeout after 60-120 seconds without pong response
- Close connections exceeding timeout threshold
### Close Handshake
**Initiation:** Either peer sends Close frame (0x8)
**Close frame structure:**
- Optional 2-byte status code
- Optional UTF-8 reason string
**Common status codes:**
- `1000` - Normal closure
- `1001` - Going away (server shutdown/navigation)
- `1002` - Protocol error
- `1003` - Unsupported data type
- `1006` - Abnormal closure (no close frame)
- `1011` - Server error
**Proper shutdown sequence:**
1. Initiator sends Close frame
2. Recipient responds with Close frame
3. Both close TCP connection
## Nostr Relay WebSocket Architecture
### Message Flow Overview
```
Client Relay
| |
|--- HTTP Upgrade ------->|
|<-- 101 Switching -------|
| |
|--- ["EVENT", {...}] --->| (Validate, store, broadcast)
|<-- ["OK", id, ...] -----|
| |
|--- ["REQ", id, {...}]-->| (Query + subscribe)
|<-- ["EVENT", id, {...}]-| (Stored events)
|<-- ["EOSE", id] --------| (End of stored)
|<-- ["EVENT", id, {...}]-| (Real-time events)
| |
|--- ["CLOSE", id] ------>| (Unsubscribe)
| |
|--- Close Frame -------->|
|<-- Close Frame ---------|
```
### Critical Concurrency Considerations
**Write concurrency:** WebSocket libraries panic/error on concurrent writes. Always protect writes with:
- Mutex locks (Go, C++)
- Single-writer goroutine/thread pattern
- Message queue with dedicated sender
**Read concurrency:** Concurrent reads generally allowed but not useful - implement single reader loop per connection.
**Subscription management:** Concurrent access to subscription maps requires synchronization or lock-free data structures.
## Language-Specific Implementation Patterns
### Go Implementation (khatru-style)
**Recommended library:** `github.com/fasthttp/websocket`
**Connection structure:**
```go
type WebSocket struct {
conn *websocket.Conn
mutex sync.Mutex // Protects writes
Request *http.Request // Original HTTP request
Context context.Context // Cancellation context
cancel context.CancelFunc
// NIP-42 authentication
Challenge string
AuthedPublicKey string
// Concurrent session management
negentropySessions *xsync.MapOf[string, *NegentropySession]
}
// Thread-safe write
func (ws *WebSocket) WriteJSON(v any) error {
ws.mutex.Lock()
defer ws.mutex.Unlock()
return ws.conn.WriteJSON(v)
}
```
**Lifecycle pattern (dual goroutines):**
```go
// Read goroutine
go func() {
defer cleanup()
ws.conn.SetReadLimit(maxMessageSize)
ws.conn.SetReadDeadline(time.Now().Add(pongWait))
ws.conn.SetPongHandler(func(string) error {
ws.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
typ, msg, err := ws.conn.ReadMessage()
if err != nil {
return // Connection closed
}
if typ == websocket.PingMessage {
ws.WriteMessage(websocket.PongMessage, nil)
continue
}
// Parse and handle message in separate goroutine
go handleMessage(msg)
}
}()
// Write/ping goroutine
go func() {
defer cleanup()
ticker := time.NewTicker(pingPeriod)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := ws.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}()
```
**Key patterns:**
- **Mutex-protected writes** - Prevent concurrent write panics
- **Context-based lifecycle** - Clean cancellation hierarchy
- **Swap-delete for subscriptions** - O(1) removal from listener arrays
- **Zero-copy string conversion** - `unsafe.String()` for message parsing
- **Goroutine-per-message** - Sequential parsing, concurrent handling
- **Hook-based extensibility** - Plugin architecture without core modifications
**Configuration constants:**
```go
WriteWait: 10 * time.Second // Write timeout
PongWait: 60 * time.Second // Pong timeout
PingPeriod: 30 * time.Second // Ping interval (< PongWait)
MaxMessageSize: 512000 // 512 KB limit
```
**Subscription management:**
```go
type listenerSpec struct {
id string
cancel context.CancelCauseFunc
index int
subrelay *Relay
}
// Efficient removal with swap-delete
func (rl *Relay) removeListenerId(ws *WebSocket, id string) {
rl.clientsMutex.Lock()
defer rl.clientsMutex.Unlock()
if specs, ok := rl.clients[ws]; ok {
for i := len(specs) - 1; i >= 0; i-- {
if specs[i].id == id {
specs[i].cancel(ErrSubscriptionClosedByClient)
specs[i] = specs[len(specs)-1]
specs = specs[:len(specs)-1]
rl.clients[ws] = specs
break
}
}
}
}
```
For detailed khatru implementation examples, see [references/khatru_implementation.md](references/khatru_implementation.md).
### C++ Implementation (strfry-style)
**Recommended library:** Custom fork of `uWebSockets` with epoll
**Architecture highlights:**
- Single-threaded I/O using epoll for connection multiplexing
- Thread pool architecture: 6 specialized pools (WebSocket, Ingester, Writer, ReqWorker, ReqMonitor, Negentropy)
- "Shared nothing" message-passing design eliminates lock contention
- Deterministic thread assignment: `connId % numThreads`
**Connection structure:**
```cpp
struct ConnectionState {
uint64_t connId;
std::string remoteAddr;
flat_str subId; // Subscription ID
std::shared_ptr<Subscription> sub;
PerMessageDeflate pmd; // Compression state
uint64_t latestEventSent = 0;
// Message parsing state
secp256k1_context *secpCtx;
std::string parseBuffer;
};
```
**Message handling pattern:**
```cpp
// WebSocket message callback
ws->onMessage([=](std::string_view msg, uWS::OpCode opCode) {
// Reuse buffer to avoid allocations
state->parseBuffer.assign(msg.data(), msg.size());
try {
auto json = nlohmann::json::parse(state->parseBuffer);
auto cmdStr = json[0].get<std::string>();
if (cmdStr == "EVENT") {
// Send to Ingester thread pool
auto packed = MsgIngester::Message(connId, std::move(json));
tpIngester->dispatchToThread(connId, std::move(packed));
}
else if (cmdStr == "REQ") {
// Send to ReqWorker thread pool
auto packed = MsgReq::Message(connId, std::move(json));
tpReqWorker->dispatchToThread(connId, std::move(packed));
}
} catch (std::exception &e) {
sendNotice("Error: " + std::string(e.what()));
}
});
```
**Critical performance optimizations:**
1. **Event batching** - Serialize event JSON once, reuse for thousands of subscribers:
```cpp
// Single serialization
std::string eventJson = event.toJson();
// Broadcast to all matching subscriptions
for (auto &[connId, sub] : activeSubscriptions) {
if (sub->matches(event)) {
sendToConnection(connId, eventJson); // Reuse serialized JSON
}
}
```
2. **Move semantics** - Zero-copy message passing:
```cpp
tpIngester->dispatchToThread(connId, std::move(message));
```
3. **Pre-allocated buffers** - Single reusable buffer per connection:
```cpp
state->parseBuffer.assign(msg.data(), msg.size());
```
4. **std::variant dispatch** - Type-safe without virtual function overhead:
```cpp
std::variant<MsgReq, MsgIngester, MsgWriter> message;
std::visit([](auto&& msg) { msg.handle(); }, message);
```
For detailed strfry implementation examples, see [references/strfry_implementation.md](references/strfry_implementation.md).
### Rust Implementation (nostr-rs-relay-style)
**Recommended libraries:**
- `tokio-tungstenite 0.17` - Async WebSocket support
- `tokio 1.x` - Async runtime
- `serde_json` - Message parsing
**WebSocket configuration:**
```rust
let config = WebSocketConfig {
max_send_queue: Some(1024),
max_message_size: settings.limits.max_ws_message_bytes,
max_frame_size: settings.limits.max_ws_frame_bytes,
..Default::default()
};
let ws_stream = WebSocketStream::from_raw_socket(
upgraded,
Role::Server,
Some(config),
).await;
```
**Connection state:**
```rust
pub struct ClientConn {
client_ip_addr: String,
client_id: Uuid,
subscriptions: HashMap<String, Subscription>,
max_subs: usize,
auth: Nip42AuthState,
}
pub enum Nip42AuthState {
NoAuth,
Challenge(String),
AuthPubkey(String),
}
```
**Async message loop with tokio::select!:**
```rust
async fn nostr_server(
repo: Arc<dyn NostrRepo>,
mut ws_stream: WebSocketStream<Upgraded>,
broadcast: Sender<Event>,
mut shutdown: Receiver<()>,
) {
let mut conn = ClientConn::new(client_ip);
let mut bcast_rx = broadcast.subscribe();
let mut ping_interval = tokio::time::interval(Duration::from_secs(300));
loop {
tokio::select! {
// Handle shutdown
_ = shutdown.recv() => { break; }
// Send periodic pings
_ = ping_interval.tick() => {
ws_stream.send(Message::Ping(Vec::new())).await.ok();
}
// Handle broadcast events (real-time)
Ok(event) = bcast_rx.recv() => {
for (id, sub) in conn.subscriptions() {
if sub.interested_in_event(&event) {
let msg = format!("[\"EVENT\",\"{}\",{}]", id,
serde_json::to_string(&event)?);
ws_stream.send(Message::Text(msg)).await.ok();
}
}
}
// Handle incoming client messages
Some(result) = ws_stream.next() => {
match result {
Ok(Message::Text(msg)) => {
handle_nostr_message(&msg, &mut conn).await;
}
Ok(Message::Binary(_)) => {
send_notice("binary messages not accepted").await;
}
Ok(Message::Ping(_) | Message::Pong(_)) => {
continue; // Auto-handled by tungstenite
}
Ok(Message::Close(_)) | Err(_) => {
break;
}
_ => {}
}
}
}
}
}
```
**Subscription filtering:**
```rust
pub struct ReqFilter {
pub ids: Option<Vec<String>>,
pub kinds: Option<Vec<u64>>,
pub since: Option<u64>,
pub until: Option<u64>,
pub authors: Option<Vec<String>>,
pub limit: Option<u64>,
pub tags: Option<HashMap<char, HashSet<String>>>,
}
impl ReqFilter {
pub fn interested_in_event(&self, event: &Event) -> bool {
self.ids_match(event)
&& self.since.map_or(true, |t| event.created_at >= t)
&& self.until.map_or(true, |t| event.created_at <= t)
&& self.kind_match(event.kind)
&& self.authors_match(event)
&& self.tag_match(event)
}
fn ids_match(&self, event: &Event) -> bool {
self.ids.as_ref()
.map_or(true, |ids| ids.iter().any(|id| event.id.starts_with(id)))
}
}
```
**Error handling:**
```rust
match ws_stream.next().await {
Some(Ok(Message::Text(msg))) => { /* handle */ }
Some(Err(WsError::Capacity(MessageTooLong{size, max_size}))) => {
send_notice(&format!("message too large ({} > {})", size, max_size)).await;
continue;
}
None | Some(Ok(Message::Close(_))) => {
info!("client closed connection");
break;
}
Some(Err(WsError::Io(e))) => {
warn!("IO error: {:?}", e);
break;
}
_ => { break; }
}
```
For detailed Rust implementation examples, see [references/rust_implementation.md](references/rust_implementation.md).
## Common Implementation Patterns
### Pattern 1: Dual Goroutine/Task Architecture
**Purpose:** Separate read and write concerns, enable ping/pong management
**Structure:**
- **Reader goroutine/task:** Blocks on `ReadMessage()`, handles incoming frames
- **Writer goroutine/task:** Sends periodic pings, processes outgoing message queue
**Benefits:**
- Natural separation of concerns
- Ping timer doesn't block message processing
- Clean shutdown coordination via context/channels
### Pattern 2: Subscription Lifecycle
**Create subscription (REQ):**
1. Parse filter from client message
2. Query database for matching stored events
3. Send stored events to client
4. Send EOSE (End of Stored Events)
5. Add subscription to active listeners for real-time events
**Handle real-time event:**
1. Check all active subscriptions
2. For each matching subscription:
- Apply filter matching logic
- Send EVENT message to client
3. Track broadcast count for monitoring
**Close subscription (CLOSE):**
1. Find subscription by ID
2. Cancel subscription context
3. Remove from active listeners
4. Clean up resources
### Pattern 3: Write Serialization
**Problem:** Concurrent writes cause panics/errors in WebSocket libraries
**Solutions:**
**Mutex approach (Go, C++):**
```go
func (ws *WebSocket) WriteJSON(v any) error {
ws.mutex.Lock()
defer ws.mutex.Unlock()
return ws.conn.WriteJSON(v)
}
```
**Single-writer goroutine (Alternative):**
```go
type writeMsg struct {
data []byte
done chan error
}
go func() {
for msg := range writeChan {
msg.done <- ws.conn.WriteMessage(websocket.TextMessage, msg.data)
}
}()
```
### Pattern 4: Connection Cleanup
**Essential cleanup steps:**
1. Cancel all subscription contexts
2. Stop ping ticker/interval
3. Remove connection from active clients map
4. Close WebSocket connection
5. Close TCP connection
6. Log connection statistics
**Go cleanup function:**
```go
kill := func() {
// Cancel contexts
cancel()
ws.cancel()
// Stop timers
ticker.Stop()
// Remove from tracking
rl.removeClientAndListeners(ws)
// Close connection
ws.conn.Close()
// Trigger hooks
for _, ondisconnect := range rl.OnDisconnect {
ondisconnect(ctx)
}
}
defer kill()
```
### Pattern 5: Event Broadcasting Optimization
**Naive approach (inefficient):**
```go
// DON'T: Serialize for each subscriber
for _, listener := range listeners {
if listener.filter.Matches(event) {
json := serializeEvent(event) // Repeated work!
listener.ws.WriteJSON(json)
}
}
```
**Optimized approach:**
```go
// DO: Serialize once, reuse for all subscribers
eventJSON, err := json.Marshal(event)
if err != nil {
return
}
for _, listener := range listeners {
if listener.filter.Matches(event) {
listener.ws.WriteMessage(websocket.TextMessage, eventJSON)
}
}
```
**Savings:** For 1000 subscribers, reduces 1000 JSON serializations to 1.
## Security Considerations
### Origin Validation
Always validate the `Origin` header for browser-based clients:
```go
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
return isAllowedOrigin(origin) // Implement allowlist
},
}
```
**Default behavior:** Most libraries reject all cross-origin connections. Override with caution.
### Rate Limiting
Implement rate limits for:
- Connection establishment (per IP)
- Message throughput (per connection)
- Subscription creation (per connection)
- Event publication (per connection, per pubkey)
```go
// Example: Connection rate limiting
type rateLimiter struct {
connections map[string]*rate.Limiter
mu sync.Mutex
}
func (rl *Relay) checkRateLimit(ip string) bool {
limiter := rl.rateLimiter.getLimiter(ip)
return limiter.Allow()
}
```
### Message Size Limits
Configure limits to prevent memory exhaustion:
```go
ws.conn.SetReadLimit(maxMessageSize) // e.g., 512 KB
```
```rust
max_message_size: Some(512_000),
max_frame_size: Some(16_384),
```
### Subscription Limits
Prevent resource exhaustion:
- Max subscriptions per connection (typically 10-20)
- Max subscription ID length (prevent hash collision attacks)
- Require specific filters (prevent full database scans)
```rust
const MAX_SUBSCRIPTION_ID_LEN: usize = 256;
const MAX_SUBS_PER_CLIENT: usize = 20;
if subscriptions.len() >= MAX_SUBS_PER_CLIENT {
return Err(Error::SubMaxExceededError);
}
```
### Authentication (NIP-42)
Implement challenge-response authentication:
1. **Generate challenge on connect:**
```go
challenge := make([]byte, 8)
rand.Read(challenge)
ws.Challenge = hex.EncodeToString(challenge)
```
2. **Send AUTH challenge when required:**
```json
["AUTH", "<challenge>"]
```
3. **Validate AUTH event:**
```go
func validateAuthEvent(event *Event, challenge, relayURL string) bool {
// Check kind 22242
if event.Kind != 22242 { return false }
// Check challenge in tags
if !hasTag(event, "challenge", challenge) { return false }
// Check relay URL
if !hasTag(event, "relay", relayURL) { return false }
// Check timestamp (within 10 minutes)
if abs(time.Now().Unix() - event.CreatedAt) > 600 { return false }
// Verify signature
return event.CheckSignature()
}
```
## Performance Optimization Techniques
### 1. Connection Pooling
Reuse connections for database queries:
```go
db, _ := sql.Open("postgres", dsn)
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
```
### 2. Event Caching
Cache frequently accessed events:
```go
type EventCache struct {
cache *lru.Cache
mu sync.RWMutex
}
func (ec *EventCache) Get(id string) (*Event, bool) {
ec.mu.RLock()
defer ec.mu.RUnlock()
if val, ok := ec.cache.Get(id); ok {
return val.(*Event), true
}
return nil, false
}
```
### 3. Batch Database Queries
Execute queries concurrently for multi-filter subscriptions:
```go
var wg sync.WaitGroup
for _, filter := range filters {
wg.Add(1)
go func(f Filter) {
defer wg.Done()
events := queryDatabase(f)
sendEvents(events)
}(filter)
}
wg.Wait()
sendEOSE()
```
### 4. Compression (permessage-deflate)
Enable WebSocket compression for text frames:
```go
upgrader := websocket.Upgrader{
EnableCompression: true,
}
```
**Typical savings:** 60-80% bandwidth reduction for JSON messages
**Trade-off:** Increased CPU usage (usually worthwhile)
### 5. Monitoring and Metrics
Track key performance indicators:
- Connections (active, total, per IP)
- Messages (received, sent, per type)
- Events (stored, broadcast, per second)
- Subscriptions (active, per connection)
- Query latency (p50, p95, p99)
- Database pool utilization
```go
// Prometheus-style metrics
type Metrics struct {
Connections prometheus.Gauge
MessagesRecv prometheus.Counter
MessagesSent prometheus.Counter
EventsStored prometheus.Counter
QueryDuration prometheus.Histogram
}
```
## Testing WebSocket Implementations
### Unit Testing
Test individual components in isolation:
```go
func TestFilterMatching(t *testing.T) {
filter := Filter{
Kinds: []int{1, 3},
Authors: []string{"abc123"},
}
event := &Event{
Kind: 1,
PubKey: "abc123",
}
if !filter.Matches(event) {
t.Error("Expected filter to match event")
}
}
```
### Integration Testing
Test WebSocket connection handling:
```go
func TestWebSocketConnection(t *testing.T) {
// Start test server
server := startTestRelay(t)
defer server.Close()
// Connect client
ws, _, err := websocket.DefaultDialer.Dial(server.URL, nil)
if err != nil {
t.Fatalf("Failed to connect: %v", err)
}
defer ws.Close()
// Send REQ
req := `["REQ","test",{"kinds":[1]}]`
if err := ws.WriteMessage(websocket.TextMessage, []byte(req)); err != nil {
t.Fatalf("Failed to send REQ: %v", err)
}
// Read EOSE
_, msg, err := ws.ReadMessage()
if err != nil {
t.Fatalf("Failed to read message: %v", err)
}
if !strings.Contains(string(msg), "EOSE") {
t.Errorf("Expected EOSE, got: %s", msg)
}
}
```
### Load Testing
Use tools like `websocat` or custom scripts:
```bash
# Connect 1000 concurrent clients
for i in {1..1000}; do
(websocat "ws://localhost:8080" <<< '["REQ","test",{"kinds":[1]}]' &)
done
```
Monitor server metrics during load testing:
- CPU usage
- Memory consumption
- Connection count
- Message throughput
- Database query rate
## Debugging and Troubleshooting
### Common Issues
**1. Concurrent write panic/error**
**Symptom:** `concurrent write to websocket connection` error
**Solution:** Ensure all writes protected by mutex or use single-writer pattern
**2. Connection timeouts**
**Symptom:** Connections close after 60 seconds
**Solution:** Implement ping/pong mechanism properly:
```go
ws.SetPongHandler(func(string) error {
ws.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
```
**3. Memory leaks**
**Symptom:** Memory usage grows over time
**Common causes:**
- Subscriptions not removed on disconnect
- Event channels not closed
- Goroutines not terminated
**Solution:** Ensure cleanup function called on disconnect
**4. Slow subscription queries**
**Symptom:** EOSE delayed by seconds
**Solution:**
- Add database indexes on filtered columns
- Implement query timeouts
- Consider caching frequently accessed events
### Logging Best Practices
Log critical events with context:
```go
log.Printf(
"connection closed: cid=%s ip=%s duration=%v sent=%d recv=%d",
conn.ID,
conn.IP,
time.Since(conn.ConnectedAt),
conn.EventsSent,
conn.EventsRecv,
)
```
Use log levels appropriately:
- **DEBUG:** Message parsing, filter matching
- **INFO:** Connection lifecycle, subscription changes
- **WARN:** Rate limit violations, invalid messages
- **ERROR:** Database errors, unexpected panics
## Resources
This skill includes comprehensive reference documentation with production code examples:
### references/
- **websocket_protocol.md** - Complete RFC 6455 specification details including frame structure, opcodes, masking algorithm, and security considerations
- **khatru_implementation.md** - Go WebSocket patterns from khatru including connection lifecycle, subscription management, and performance optimizations (3000+ lines)
- **strfry_implementation.md** - C++ high-performance patterns from strfry including thread pool architecture, message batching, and zero-copy techniques (2000+ lines)
- **rust_implementation.md** - Rust async patterns from nostr-rs-relay including tokio::select! usage, error handling, and subscription filtering (2000+ lines)
Load these references when implementing specific language solutions or troubleshooting complex WebSocket issues.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,921 @@
# C++ WebSocket Implementation for Nostr Relays (strfry patterns)
This reference documents high-performance WebSocket patterns from the strfry Nostr relay implementation in C++.
## Repository Information
- **Project:** strfry - High-performance Nostr relay
- **Repository:** https://github.com/hoytech/strfry
- **Language:** C++ (C++20)
- **WebSocket Library:** Custom fork of uWebSockets with epoll
- **Architecture:** Single-threaded I/O with specialized thread pools
## Core Architecture
### Thread Pool Design
strfry uses 6 specialized thread pools for different operations:
```
┌─────────────────────────────────────────────────────────────┐
│ Main Thread (I/O) │
│ - epoll event loop │
│ - WebSocket message reception │
│ - Connection management │
└─────────────────────────────────────────────────────────────┘
┌───────────────────┼───────────────────┐
│ │ │
┌────▼────┐ ┌───▼────┐ ┌───▼────┐
│Ingester │ │ReqWorker│ │Negentropy│
│ (3) │ │ (3) │ │ (2) │
└─────────┘ └─────────┘ └─────────┘
│ │ │
┌────▼────┐ ┌───▼────┐
│ Writer │ │ReqMonitor│
│ (1) │ │ (3) │
└─────────┘ └─────────┘
```
**Thread Pool Responsibilities:**
1. **WebSocket (1 thread):** Main I/O loop, epoll event handling
2. **Ingester (3 threads):** Event validation, signature verification, deduplication
3. **Writer (1 thread):** Database writes, event storage
4. **ReqWorker (3 threads):** Process REQ subscriptions, query database
5. **ReqMonitor (3 threads):** Monitor active subscriptions, send real-time events
6. **Negentropy (2 threads):** NIP-77 set reconciliation
**Deterministic thread assignment:**
```cpp
int threadId = connId % numThreads;
```
**Benefits:**
- **No lock contention:** Shared-nothing architecture
- **Predictable performance:** Same connection always same thread
- **CPU cache efficiency:** Thread-local data stays hot
### Connection State
```cpp
struct ConnectionState {
uint64_t connId; // Unique connection identifier
std::string remoteAddr; // Client IP address
// Subscription state
flat_str subId; // Current subscription ID
std::shared_ptr<Subscription> sub; // Subscription filter
uint64_t latestEventSent = 0; // Latest event ID sent
// Compression state (per-message deflate)
PerMessageDeflate pmd;
// Parsing state (reused buffer)
std::string parseBuffer;
// Signature verification context (reused)
secp256k1_context *secpCtx;
};
```
**Key design decisions:**
1. **Reusable parseBuffer:** Single allocation per connection
2. **Persistent secp256k1_context:** Expensive to create, reused for all signatures
3. **Connection ID:** Enables deterministic thread assignment
4. **Flat string (flat_str):** Value-semantic string-like type for zero-copy
## WebSocket Message Reception
### Main Event Loop (epoll)
```cpp
// Pseudocode representation of strfry's I/O loop
uWS::App app;
app.ws<ConnectionState>("/*", {
.compression = uWS::SHARED_COMPRESSOR,
.maxPayloadLength = 16 * 1024 * 1024,
.idleTimeout = 120,
.maxBackpressure = 1 * 1024 * 1024,
.upgrade = nullptr,
.open = [](auto *ws) {
auto *state = ws->getUserData();
state->connId = nextConnId++;
state->remoteAddr = getRemoteAddress(ws);
state->secpCtx = secp256k1_context_create(SECP256K1_CONTEXT_VERIFY);
LI << "New connection: " << state->connId << " from " << state->remoteAddr;
},
.message = [](auto *ws, std::string_view message, uWS::OpCode opCode) {
auto *state = ws->getUserData();
// Reuse parseBuffer to avoid allocation
state->parseBuffer.assign(message.data(), message.size());
try {
// Parse JSON (nlohmann::json)
auto json = nlohmann::json::parse(state->parseBuffer);
// Extract command type
auto cmdStr = json[0].get<std::string>();
if (cmdStr == "EVENT") {
handleEventMessage(ws, std::move(json));
}
else if (cmdStr == "REQ") {
handleReqMessage(ws, std::move(json));
}
else if (cmdStr == "CLOSE") {
handleCloseMessage(ws, std::move(json));
}
else if (cmdStr == "NEG-OPEN") {
handleNegentropyOpen(ws, std::move(json));
}
else {
sendNotice(ws, "unknown command: " + cmdStr);
}
}
catch (std::exception &e) {
sendNotice(ws, "Error: " + std::string(e.what()));
}
},
.close = [](auto *ws, int code, std::string_view message) {
auto *state = ws->getUserData();
LI << "Connection closed: " << state->connId
<< " code=" << code
<< " msg=" << std::string(message);
// Cleanup
secp256k1_context_destroy(state->secpCtx);
cleanupSubscription(state->connId);
},
});
app.listen(8080, [](auto *token) {
if (token) {
LI << "Listening on port 8080";
}
});
app.run();
```
**Key patterns:**
1. **epoll-based I/O:** Single thread handles thousands of connections
2. **Buffer reuse:** `state->parseBuffer` avoids allocation per message
3. **Move semantics:** `std::move(json)` transfers ownership to handler
4. **Exception handling:** Catches parsing errors, sends NOTICE
### Message Dispatch to Thread Pools
```cpp
void handleEventMessage(auto *ws, nlohmann::json &&json) {
auto *state = ws->getUserData();
// Pack message with connection ID
auto msg = MsgIngester{
.connId = state->connId,
.payload = std::move(json),
};
// Dispatch to Ingester thread pool (deterministic assignment)
tpIngester->dispatchToThread(state->connId, std::move(msg));
}
void handleReqMessage(auto *ws, nlohmann::json &&json) {
auto *state = ws->getUserData();
// Pack message
auto msg = MsgReq{
.connId = state->connId,
.payload = std::move(json),
};
// Dispatch to ReqWorker thread pool
tpReqWorker->dispatchToThread(state->connId, std::move(msg));
}
```
**Message passing pattern:**
```cpp
// ThreadPool::dispatchToThread
void dispatchToThread(uint64_t connId, Message &&msg) {
size_t threadId = connId % threads.size();
threads[threadId]->queue.push(std::move(msg));
}
```
**Benefits:**
- **Zero-copy:** `std::move` transfers ownership without copying
- **Deterministic:** Same connection always processed by same thread
- **Lock-free:** Each thread has own queue
## Event Ingestion Pipeline
### Ingester Thread Pool
```cpp
void IngesterThread::run() {
while (running) {
Message msg;
if (!queue.pop(msg, 100ms)) continue;
// Extract event from JSON
auto event = parseEvent(msg.payload);
// Validate event ID
if (!validateEventId(event)) {
sendOK(msg.connId, event.id, false, "invalid: id mismatch");
continue;
}
// Verify signature (using thread-local secp256k1 context)
if (!verifySignature(event, secpCtx)) {
sendOK(msg.connId, event.id, false, "invalid: signature verification failed");
continue;
}
// Check for duplicate (bloom filter + database)
if (isDuplicate(event.id)) {
sendOK(msg.connId, event.id, true, "duplicate: already have this event");
continue;
}
// Send to Writer thread
auto writerMsg = MsgWriter{
.connId = msg.connId,
.event = std::move(event),
};
tpWriter->dispatch(std::move(writerMsg));
}
}
```
**Validation sequence:**
1. Parse JSON into Event struct
2. Validate event ID matches content hash
3. Verify secp256k1 signature
4. Check duplicate (bloom filter for speed)
5. Forward to Writer thread for storage
### Writer Thread
```cpp
void WriterThread::run() {
// Single thread for all database writes
while (running) {
Message msg;
if (!queue.pop(msg, 100ms)) continue;
// Write to database
bool success = db.insertEvent(msg.event);
// Send OK to client
sendOK(msg.connId, msg.event.id, success,
success ? "" : "error: failed to store");
if (success) {
// Broadcast to subscribers
broadcastEvent(msg.event);
}
}
}
```
**Single-writer pattern:**
- Only one thread writes to database
- Eliminates write conflicts
- Simplified transaction management
### Event Broadcasting
```cpp
void broadcastEvent(const Event &event) {
// Serialize event JSON once
std::string eventJson = serializeEvent(event);
// Iterate all active subscriptions
for (auto &[connId, sub] : activeSubscriptions) {
// Check if filter matches
if (!sub->filter.matches(event)) continue;
// Check if event newer than last sent
if (event.id <= sub->latestEventSent) continue;
// Send to connection
auto msg = MsgWebSocket{
.connId = connId,
.payload = eventJson, // Reuse serialized JSON
};
tpWebSocket->dispatch(std::move(msg));
// Update latest sent
sub->latestEventSent = event.id;
}
}
```
**Critical optimization:** Serialize event JSON once, send to N subscribers
**Performance impact:** For 1000 subscribers, reduces:
- JSON serialization: 1000× → 1×
- Memory allocations: 1000× → 1×
- CPU time: ~100ms → ~1ms
## Subscription Management
### REQ Processing
```cpp
void ReqWorkerThread::run() {
while (running) {
MsgReq msg;
if (!queue.pop(msg, 100ms)) continue;
// Parse REQ message: ["REQ", subId, filter1, filter2, ...]
std::string subId = msg.payload[1];
// Create subscription object
auto sub = std::make_shared<Subscription>();
sub->subId = subId;
// Parse filters
for (size_t i = 2; i < msg.payload.size(); i++) {
Filter filter = parseFilter(msg.payload[i]);
sub->filters.push_back(filter);
}
// Store subscription
activeSubscriptions[msg.connId] = sub;
// Query stored events
std::vector<Event> events = db.queryEvents(sub->filters);
// Send matching events
for (const auto &event : events) {
sendEvent(msg.connId, subId, event);
}
// Send EOSE
sendEOSE(msg.connId, subId);
// Notify ReqMonitor to watch for real-time events
auto monitorMsg = MsgReqMonitor{
.connId = msg.connId,
.subId = subId,
};
tpReqMonitor->dispatchToThread(msg.connId, std::move(monitorMsg));
}
}
```
**Query optimization:**
```cpp
std::vector<Event> Database::queryEvents(const std::vector<Filter> &filters) {
// Combine filters with OR logic
std::string sql = "SELECT * FROM events WHERE ";
for (size_t i = 0; i < filters.size(); i++) {
if (i > 0) sql += " OR ";
sql += buildFilterSQL(filters[i]);
}
sql += " ORDER BY created_at DESC LIMIT 1000";
return executeQuery(sql);
}
```
**Filter SQL generation:**
```cpp
std::string buildFilterSQL(const Filter &filter) {
std::vector<std::string> conditions;
// Event IDs
if (!filter.ids.empty()) {
conditions.push_back("id IN (" + joinQuoted(filter.ids) + ")");
}
// Authors
if (!filter.authors.empty()) {
conditions.push_back("pubkey IN (" + joinQuoted(filter.authors) + ")");
}
// Kinds
if (!filter.kinds.empty()) {
conditions.push_back("kind IN (" + join(filter.kinds) + ")");
}
// Time range
if (filter.since) {
conditions.push_back("created_at >= " + std::to_string(*filter.since));
}
if (filter.until) {
conditions.push_back("created_at <= " + std::to_string(*filter.until));
}
// Tags (requires JOIN with tags table)
if (!filter.tags.empty()) {
for (const auto &[tagName, tagValues] : filter.tags) {
conditions.push_back(
"EXISTS (SELECT 1 FROM tags WHERE tags.event_id = events.id "
"AND tags.name = '" + tagName + "' "
"AND tags.value IN (" + joinQuoted(tagValues) + "))"
);
}
}
return "(" + join(conditions, " AND ") + ")";
}
```
### ReqMonitor for Real-Time Events
```cpp
void ReqMonitorThread::run() {
// Subscribe to event broadcast channel
auto eventSubscription = subscribeToEvents();
while (running) {
Event event;
if (!eventSubscription.receive(event, 100ms)) continue;
// Check all subscriptions assigned to this thread
for (auto &[connId, sub] : mySubscriptions) {
// Only process subscriptions for this thread
if (connId % numThreads != threadId) continue;
// Check if filter matches
bool matches = false;
for (const auto &filter : sub->filters) {
if (filter.matches(event)) {
matches = true;
break;
}
}
if (matches) {
sendEvent(connId, sub->subId, event);
}
}
}
}
```
**Pattern:** Monitor thread watches event stream, sends to matching subscriptions
### CLOSE Handling
```cpp
void handleCloseMessage(auto *ws, nlohmann::json &&json) {
auto *state = ws->getUserData();
// Parse CLOSE message: ["CLOSE", subId]
std::string subId = json[1];
// Remove subscription
activeSubscriptions.erase(state->connId);
LI << "Subscription closed: connId=" << state->connId
<< " subId=" << subId;
}
```
## Performance Optimizations
### 1. Event Batching
**Problem:** Serializing same event 1000× for 1000 subscribers is wasteful
**Solution:** Serialize once, send to all
```cpp
// BAD: Serialize for each subscriber
for (auto &sub : subscriptions) {
std::string json = serializeEvent(event); // Repeated!
send(sub.connId, json);
}
// GOOD: Serialize once
std::string json = serializeEvent(event);
for (auto &sub : subscriptions) {
send(sub.connId, json); // Reuse!
}
```
**Measurement:** For 1000 subscribers, reduces broadcast time from 100ms to 1ms
### 2. Move Semantics
**Problem:** Copying large JSON objects is expensive
**Solution:** Transfer ownership with `std::move`
```cpp
// BAD: Copies JSON object
void dispatch(Message msg) {
queue.push(msg); // Copy
}
// GOOD: Moves JSON object
void dispatch(Message &&msg) {
queue.push(std::move(msg)); // Move
}
```
**Benefit:** Zero-copy message passing between threads
### 3. Pre-allocated Buffers
**Problem:** Allocating buffer for each message
**Solution:** Reuse buffer per connection
```cpp
struct ConnectionState {
std::string parseBuffer; // Reused for all messages
};
void handleMessage(std::string_view msg) {
state->parseBuffer.assign(msg.data(), msg.size());
auto json = nlohmann::json::parse(state->parseBuffer);
// ...
}
```
**Benefit:** Eliminates 10,000+ allocations/second per connection
### 4. std::variant for Message Types
**Problem:** Virtual function calls for polymorphic messages
**Solution:** `std::variant` with `std::visit`
```cpp
// BAD: Virtual function (pointer indirection, vtable lookup)
struct Message {
virtual void handle() = 0;
};
// GOOD: std::variant (no indirection, inlined)
using Message = std::variant<
MsgIngester,
MsgReq,
MsgWriter,
MsgWebSocket
>;
void handle(Message &&msg) {
std::visit([](auto &&m) { m.handle(); }, msg);
}
```
**Benefit:** Compiler inlines visit, eliminates virtual call overhead
### 5. Bloom Filter for Duplicate Detection
**Problem:** Database query for every event to check duplicate
**Solution:** In-memory bloom filter for fast negative
```cpp
class DuplicateDetector {
BloomFilter bloom; // Fast probabilistic check
bool isDuplicate(const std::string &eventId) {
// Fast negative (definitely not seen)
if (!bloom.contains(eventId)) {
bloom.insert(eventId);
return false;
}
// Possible positive (maybe seen, check database)
if (db.eventExists(eventId)) {
return true;
}
// False positive
bloom.insert(eventId);
return false;
}
};
```
**Benefit:** 99% of duplicate checks avoid database query
### 6. Batch Queue Operations
**Problem:** Lock contention on message queue
**Solution:** Batch multiple pushes with single lock
```cpp
class MessageQueue {
std::mutex mutex;
std::deque<Message> queue;
void pushBatch(std::vector<Message> &messages) {
std::lock_guard lock(mutex);
for (auto &msg : messages) {
queue.push_back(std::move(msg));
}
}
};
```
**Benefit:** Reduces lock acquisitions by 10-100×
### 7. ZSTD Dictionary Compression
**Problem:** WebSocket compression slower than desired
**Solution:** Train ZSTD dictionary on typical Nostr messages
```cpp
// Train dictionary on corpus of Nostr events
std::string corpus = collectTypicalEvents();
ZSTD_CDict *dict = ZSTD_createCDict(
corpus.data(), corpus.size(),
compressionLevel
);
// Use dictionary for compression
size_t compressedSize = ZSTD_compress_usingCDict(
cctx, dst, dstSize,
src, srcSize, dict
);
```
**Benefit:** 10-20% better compression ratio, 2× faster decompression
### 8. String Views
**Problem:** Unnecessary string copies when parsing
**Solution:** Use `std::string_view` for zero-copy
```cpp
// BAD: Copies substring
std::string extractCommand(const std::string &msg) {
return msg.substr(0, 5); // Copy
}
// GOOD: View into original string
std::string_view extractCommand(std::string_view msg) {
return msg.substr(0, 5); // No copy
}
```
**Benefit:** Eliminates allocations during parsing
## Compression (permessage-deflate)
### WebSocket Compression Configuration
```cpp
struct PerMessageDeflate {
z_stream deflate_stream;
z_stream inflate_stream;
// Sliding window for compression history
static constexpr int WINDOW_BITS = 15;
static constexpr int MEM_LEVEL = 8;
void init() {
// Initialize deflate (compression)
deflate_stream.zalloc = Z_NULL;
deflate_stream.zfree = Z_NULL;
deflate_stream.opaque = Z_NULL;
deflateInit2(&deflate_stream,
Z_DEFAULT_COMPRESSION,
Z_DEFLATED,
-WINDOW_BITS, // Negative = no zlib header
MEM_LEVEL,
Z_DEFAULT_STRATEGY);
// Initialize inflate (decompression)
inflate_stream.zalloc = Z_NULL;
inflate_stream.zfree = Z_NULL;
inflate_stream.opaque = Z_NULL;
inflateInit2(&inflate_stream, -WINDOW_BITS);
}
std::string compress(std::string_view data) {
// Compress with sliding window
deflate_stream.next_in = (Bytef*)data.data();
deflate_stream.avail_in = data.size();
std::string compressed;
compressed.resize(deflateBound(&deflate_stream, data.size()));
deflate_stream.next_out = (Bytef*)compressed.data();
deflate_stream.avail_out = compressed.size();
deflate(&deflate_stream, Z_SYNC_FLUSH);
compressed.resize(compressed.size() - deflate_stream.avail_out);
return compressed;
}
};
```
**Typical compression ratios:**
- JSON events: 60-80% reduction
- Subscription filters: 40-60% reduction
- Binary events: 10-30% reduction
## Database Schema (LMDB)
strfry uses LMDB (Lightning Memory-Mapped Database) for event storage:
```cpp
// Key-value stores
struct EventDB {
// Primary event storage (key: event ID, value: event data)
lmdb::dbi eventsDB;
// Index by pubkey (key: pubkey + created_at, value: event ID)
lmdb::dbi pubkeyDB;
// Index by kind (key: kind + created_at, value: event ID)
lmdb::dbi kindDB;
// Index by tags (key: tag_name + tag_value + created_at, value: event ID)
lmdb::dbi tagsDB;
// Deletion index (key: event ID, value: deletion event ID)
lmdb::dbi deletionsDB;
};
```
**Why LMDB?**
- Memory-mapped I/O (kernel manages caching)
- Copy-on-write (MVCC without locks)
- Ordered keys (enables range queries)
- Crash-proof (no corruption on power loss)
## Monitoring and Metrics
### Connection Statistics
```cpp
struct RelayStats {
std::atomic<uint64_t> totalConnections{0};
std::atomic<uint64_t> activeConnections{0};
std::atomic<uint64_t> eventsReceived{0};
std::atomic<uint64_t> eventsSent{0};
std::atomic<uint64_t> bytesReceived{0};
std::atomic<uint64_t> bytesSent{0};
void recordConnection() {
totalConnections.fetch_add(1, std::memory_order_relaxed);
activeConnections.fetch_add(1, std::memory_order_relaxed);
}
void recordDisconnection() {
activeConnections.fetch_sub(1, std::memory_order_relaxed);
}
void recordEventReceived(size_t bytes) {
eventsReceived.fetch_add(1, std::memory_order_relaxed);
bytesReceived.fetch_add(bytes, std::memory_order_relaxed);
}
};
```
**Atomic operations:** Lock-free updates from multiple threads
### Performance Metrics
```cpp
struct PerformanceMetrics {
// Latency histograms
Histogram eventIngestionLatency;
Histogram subscriptionQueryLatency;
Histogram eventBroadcastLatency;
// Thread pool queue depths
std::atomic<size_t> ingesterQueueDepth{0};
std::atomic<size_t> writerQueueDepth{0};
std::atomic<size_t> reqWorkerQueueDepth{0};
void recordIngestion(std::chrono::microseconds duration) {
eventIngestionLatency.record(duration.count());
}
};
```
## Configuration
### relay.conf Example
```ini
[relay]
bind = 0.0.0.0
port = 8080
maxConnections = 10000
maxMessageSize = 16777216 # 16 MB
[ingester]
threads = 3
queueSize = 10000
[writer]
threads = 1
queueSize = 1000
batchSize = 100
[reqWorker]
threads = 3
queueSize = 10000
[db]
path = /var/lib/strfry/events.lmdb
maxSizeGB = 100
```
## Deployment Considerations
### System Limits
```bash
# Increase file descriptor limit
ulimit -n 65536
# Increase maximum socket connections
sysctl -w net.core.somaxconn=4096
# TCP tuning
sysctl -w net.ipv4.tcp_fin_timeout=15
sysctl -w net.ipv4.tcp_tw_reuse=1
```
### Memory Requirements
**Per connection:**
- ConnectionState: ~1 KB
- WebSocket buffers: ~32 KB (16 KB send + 16 KB receive)
- Compression state: ~400 KB (200 KB deflate + 200 KB inflate)
**Total:** ~433 KB per connection
**For 10,000 connections:** ~4.3 GB
### CPU Requirements
**Single-core can handle:**
- 1000 concurrent connections
- 10,000 events/sec ingestion
- 100,000 events/sec broadcast (cached)
**Recommended:**
- 8+ cores for 10,000 connections
- 16+ cores for 50,000 connections
## Summary
**Key architectural patterns:**
1. **Single-threaded I/O:** epoll handles all connections in one thread
2. **Specialized thread pools:** Different operations use dedicated threads
3. **Deterministic assignment:** Connection ID determines thread assignment
4. **Move semantics:** Zero-copy message passing
5. **Event batching:** Serialize once, send to many
6. **Pre-allocated buffers:** Reuse memory per connection
7. **Bloom filters:** Fast duplicate detection
8. **LMDB:** Memory-mapped database for zero-copy reads
**Performance characteristics:**
- **50,000+ concurrent connections** per server
- **100,000+ events/sec** throughput
- **Sub-millisecond** latency for broadcasts
- **10 GB+ event database** with fast queries
**When to use strfry patterns:**
- Need maximum performance (trading complexity)
- Have C++ expertise on team
- Running large public relay (thousands of users)
- Want minimal memory footprint
- Need to scale to 50K+ connections
**Trade-offs:**
- **Complexity:** More complex than Go/Rust implementations
- **Portability:** Linux-specific (epoll, LMDB)
- **Development speed:** Slower iteration than higher-level languages
**Further reading:**
- strfry repository: https://github.com/hoytech/strfry
- uWebSockets: https://github.com/uNetworking/uWebSockets
- LMDB: http://www.lmdb.tech/doc/
- epoll: https://man7.org/linux/man-pages/man7/epoll.7.html

View File

@@ -0,0 +1,881 @@
# WebSocket Protocol (RFC 6455) - Complete Reference
## Connection Establishment
### HTTP Upgrade Handshake
The WebSocket protocol begins as an HTTP request that upgrades to WebSocket:
**Client Request:**
```http
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
```
**Server Response:**
```http
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
```
### Handshake Details
**Sec-WebSocket-Key Generation (Client):**
1. Generate 16 random bytes
2. Base64-encode the result
3. Send in `Sec-WebSocket-Key` header
**Sec-WebSocket-Accept Computation (Server):**
1. Concatenate client key with GUID: `258EAFA5-E914-47DA-95CA-C5AB0DC85B11`
2. Compute SHA-1 hash of concatenated string
3. Base64-encode the hash
4. Send in `Sec-WebSocket-Accept` header
**Example computation:**
```
Client Key: dGhlIHNhbXBsZSBub25jZQ==
Concatenated: dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11
SHA-1 Hash: b37a4f2cc0cb4e7e8cf769a5f3f8f2e8e4c9f7a3
Base64: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
```
**Validation (Client):**
- Verify HTTP status is 101
- Verify `Sec-WebSocket-Accept` matches expected value
- If validation fails, do not establish connection
### Origin Header
The `Origin` header provides protection against cross-site WebSocket hijacking:
**Server-side validation:**
```go
func checkOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
allowedOrigins := []string{
"https://example.com",
"https://app.example.com",
}
for _, allowed := range allowedOrigins {
if origin == allowed {
return true
}
}
return false
}
```
**Security consideration:** Browser-based clients MUST send Origin header. Non-browser clients MAY omit it. Servers SHOULD validate Origin for browser clients to prevent CSRF attacks.
## Frame Format
### Base Framing Protocol
WebSocket frames use a binary format with variable-length fields:
```
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
```
### Frame Header Fields
**FIN (1 bit):**
- `1` = Final fragment in message
- `0` = More fragments follow
- Used for message fragmentation
**RSV1, RSV2, RSV3 (1 bit each):**
- Reserved for extensions
- MUST be 0 unless extension negotiated
- Server MUST fail connection if non-zero with no extension
**Opcode (4 bits):**
- Defines interpretation of payload data
- See "Frame Opcodes" section below
**MASK (1 bit):**
- `1` = Payload is masked (required for client-to-server)
- `0` = Payload is not masked (required for server-to-client)
- Client MUST mask all frames sent to server
- Server MUST NOT mask frames sent to client
**Payload Length (7 bits, 7+16 bits, or 7+64 bits):**
- If 0-125: Actual payload length
- If 126: Next 2 bytes are 16-bit unsigned payload length
- If 127: Next 8 bytes are 64-bit unsigned payload length
**Masking-key (0 or 4 bytes):**
- Present if MASK bit is set
- 32-bit value used to mask payload
- MUST be unpredictable (strong entropy source)
### Frame Opcodes
**Data Frame Opcodes:**
- `0x0` - Continuation Frame
- Used for fragmented messages
- Must follow initial data frame (text/binary)
- Carries same data type as initial frame
- `0x1` - Text Frame
- Payload is UTF-8 encoded text
- MUST be valid UTF-8
- Endpoint MUST fail connection if invalid UTF-8
- `0x2` - Binary Frame
- Payload is arbitrary binary data
- Application interprets data
- `0x3-0x7` - Reserved for future non-control frames
**Control Frame Opcodes:**
- `0x8` - Connection Close
- Initiates or acknowledges connection closure
- MAY contain status code and reason
- See "Close Handshake" section
- `0x9` - Ping
- Heartbeat mechanism
- MAY contain application data
- Recipient MUST respond with Pong
- `0xA` - Pong
- Response to Ping
- MUST contain identical payload as Ping
- MAY be sent unsolicited (unidirectional heartbeat)
- `0xB-0xF` - Reserved for future control frames
### Control Frame Constraints
**Control frames are subject to strict rules:**
1. **Maximum payload:** 125 bytes
- Allows control frames to fit in single IP packet
- Reduces fragmentation
2. **No fragmentation:** Control frames MUST NOT be fragmented
- FIN bit MUST be 1
- Ensures immediate processing
3. **Interleaving:** Control frames MAY be injected in middle of fragmented message
- Enables ping/pong during long transfers
- Close frames can interrupt any operation
4. **All control frames MUST be handled immediately**
### Masking
**Purpose of masking:**
- Prevents cache poisoning attacks
- Protects against misinterpretation by intermediaries
- Makes WebSocket traffic unpredictable to proxies
**Masking algorithm:**
```
j = i MOD 4
transformed-octet-i = original-octet-i XOR masking-key-octet-j
```
**Implementation:**
```go
func maskBytes(data []byte, mask [4]byte) {
for i := range data {
data[i] ^= mask[i%4]
}
}
```
**Example:**
```
Original: [0x48, 0x65, 0x6C, 0x6C, 0x6F] // "Hello"
Masking Key: [0x37, 0xFA, 0x21, 0x3D]
Masked: [0x7F, 0x9F, 0x4D, 0x51, 0x58]
Calculation:
0x48 XOR 0x37 = 0x7F
0x65 XOR 0xFA = 0x9F
0x6C XOR 0x21 = 0x4D
0x6C XOR 0x3D = 0x51
0x6F XOR 0x37 = 0x58 (wraps around to mask[0])
```
**Security requirement:** Masking key MUST be derived from strong source of entropy. Predictable masking keys defeat the security purpose.
## Message Fragmentation
### Why Fragment?
- Send message without knowing total size upfront
- Multiplex logical channels (interleave messages)
- Keep control frames responsive during large transfers
### Fragmentation Rules
**Sender rules:**
1. First fragment has opcode (text/binary)
2. Subsequent fragments have opcode 0x0 (continuation)
3. Last fragment has FIN bit set to 1
4. Control frames MAY be interleaved
**Receiver rules:**
1. Reassemble fragments in order
2. Final message type determined by first fragment opcode
3. Validate UTF-8 across all text fragments
4. Process control frames immediately (don't wait for FIN)
### Fragmentation Example
**Sending "Hello World" in 3 fragments:**
```
Frame 1 (Text, More Fragments):
FIN=0, Opcode=0x1, Payload="Hello"
Frame 2 (Continuation, More Fragments):
FIN=0, Opcode=0x0, Payload=" Wor"
Frame 3 (Continuation, Final):
FIN=1, Opcode=0x0, Payload="ld"
```
**With interleaved Ping:**
```
Frame 1: FIN=0, Opcode=0x1, Payload="Hello"
Frame 2: FIN=1, Opcode=0x9, Payload="" <- Ping (complete)
Frame 3: FIN=0, Opcode=0x0, Payload=" Wor"
Frame 4: FIN=1, Opcode=0x0, Payload="ld"
```
### Implementation Pattern
```go
type fragmentState struct {
messageType int
fragments [][]byte
}
func (ws *WebSocket) handleFrame(fin bool, opcode int, payload []byte) {
switch opcode {
case 0x1, 0x2: // Text or Binary (first fragment)
if fin {
ws.handleCompleteMessage(opcode, payload)
} else {
ws.fragmentState = &fragmentState{
messageType: opcode,
fragments: [][]byte{payload},
}
}
case 0x0: // Continuation
if ws.fragmentState == nil {
ws.fail("Unexpected continuation frame")
return
}
ws.fragmentState.fragments = append(ws.fragmentState.fragments, payload)
if fin {
complete := bytes.Join(ws.fragmentState.fragments, nil)
ws.handleCompleteMessage(ws.fragmentState.messageType, complete)
ws.fragmentState = nil
}
case 0x8, 0x9, 0xA: // Control frames
ws.handleControlFrame(opcode, payload)
}
}
```
## Ping and Pong Frames
### Purpose
1. **Keep-alive:** Detect broken connections
2. **Latency measurement:** Time round-trip
3. **NAT traversal:** Maintain mapping in stateful firewalls
### Protocol Rules
**Ping (0x9):**
- MAY be sent by either endpoint at any time
- MAY contain application data (≤125 bytes)
- Application data arbitrary (often empty or timestamp)
**Pong (0xA):**
- MUST be sent in response to Ping
- MUST contain identical payload as Ping
- MUST be sent "as soon as practical"
- MAY be sent unsolicited (one-way heartbeat)
**No Response:**
- If Pong not received within timeout, connection assumed dead
- Application should close connection
### Implementation Patterns
**Pattern 1: Automatic Pong (most WebSocket libraries)**
```go
// Library handles pong automatically
ws.SetPingHandler(func(appData string) error {
// Custom handler if needed
return nil // Library sends pong automatically
})
```
**Pattern 2: Manual Pong**
```go
func (ws *WebSocket) handlePing(payload []byte) {
pongFrame := Frame{
FIN: true,
Opcode: 0xA,
Payload: payload, // Echo same payload
}
ws.writeFrame(pongFrame)
}
```
**Pattern 3: Periodic Client Ping**
```go
func (ws *WebSocket) pingLoop() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := ws.writePing([]byte{}); err != nil {
return // Connection dead
}
case <-ws.done:
return
}
}
}
```
**Pattern 4: Timeout Detection**
```go
const pongWait = 60 * time.Second
ws.SetReadDeadline(time.Now().Add(pongWait))
ws.SetPongHandler(func(string) error {
ws.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
// If no frame received in pongWait, ReadMessage returns timeout error
```
### Nostr Relay Recommendations
**Server-side:**
- Send ping every 30-60 seconds
- Close connection if no pong within 60-120 seconds
- Log timeout closures for monitoring
**Client-side:**
- Respond to pings automatically (use library handler)
- Consider sending unsolicited pongs every 30 seconds (some proxies)
- Reconnect if no frames received for 120 seconds
## Close Handshake
### Close Frame Structure
**Close frame (Opcode 0x8) payload:**
```
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Status Code (16) | Reason (variable length)... |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
```
**Status Code (2 bytes, optional):**
- 16-bit unsigned integer
- Network byte order (big-endian)
- See "Status Codes" section below
**Reason (variable length, optional):**
- UTF-8 encoded text
- MUST be valid UTF-8
- Typically human-readable explanation
### Close Handshake Sequence
**Initiator (either endpoint):**
1. Send Close frame with optional status/reason
2. Stop sending data frames
3. Continue processing received frames until Close frame received
4. Close underlying TCP connection
**Recipient:**
1. Receive Close frame
2. Send Close frame in response (if not already sent)
3. Close underlying TCP connection
### Status Codes
**Normal Closure Codes:**
- `1000` - Normal Closure
- Successful operation complete
- Default if no code specified
- `1001` - Going Away
- Endpoint going away (server shutdown, browser navigation)
- Client navigating to new page
**Error Closure Codes:**
- `1002` - Protocol Error
- Endpoint terminating due to protocol error
- Invalid frame format, unexpected opcode, etc.
- `1003` - Unsupported Data
- Endpoint cannot accept data type
- Server received binary when expecting text
- `1007` - Invalid Frame Payload Data
- Inconsistent data (e.g., non-UTF-8 in text frame)
- `1008` - Policy Violation
- Message violates endpoint policy
- Generic code when specific code doesn't fit
- `1009` - Message Too Big
- Message too large to process
- `1010` - Mandatory Extension
- Client expected server to negotiate extension
- Server didn't respond with extension
- `1011` - Internal Server Error
- Server encountered unexpected condition
- Prevents fulfilling request
**Reserved Codes:**
- `1004` - Reserved
- `1005` - No Status Rcvd (internal use only, never sent)
- `1006` - Abnormal Closure (internal use only, never sent)
- `1015` - TLS Handshake (internal use only, never sent)
**Custom Application Codes:**
- `3000-3999` - Library/framework use
- `4000-4999` - Application use (e.g., Nostr-specific)
### Implementation Patterns
**Graceful close (initiator):**
```go
func (ws *WebSocket) Close() error {
// Send close frame
closeFrame := Frame{
FIN: true,
Opcode: 0x8,
Payload: encodeCloseStatus(1000, "goodbye"),
}
ws.writeFrame(closeFrame)
// Wait for close frame response (with timeout)
ws.SetReadDeadline(time.Now().Add(5 * time.Second))
for {
frame, err := ws.readFrame()
if err != nil || frame.Opcode == 0x8 {
break
}
// Process other frames
}
// Close TCP connection
return ws.conn.Close()
}
```
**Handling received close:**
```go
func (ws *WebSocket) handleCloseFrame(payload []byte) {
status, reason := decodeClosePayload(payload)
log.Printf("Close received: %d %s", status, reason)
// Send close response
closeFrame := Frame{
FIN: true,
Opcode: 0x8,
Payload: payload, // Echo same status/reason
}
ws.writeFrame(closeFrame)
// Close connection
ws.conn.Close()
}
```
**Nostr relay close examples:**
```go
// Client subscription limit exceeded
ws.SendClose(4000, "subscription limit exceeded")
// Invalid message format
ws.SendClose(1002, "protocol error: invalid JSON")
// Relay shutting down
ws.SendClose(1001, "relay shutting down")
// Client rate limit exceeded
ws.SendClose(4001, "rate limit exceeded")
```
## Security Considerations
### Origin-Based Security Model
**Threat:** Malicious web page opens WebSocket to victim server using user's credentials
**Mitigation:**
1. Server checks `Origin` header
2. Reject connections from untrusted origins
3. Implement same-origin or allowlist policy
**Example:**
```go
func validateOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
// Allow same-origin
if origin == "https://"+r.Host {
return true
}
// Allowlist trusted origins
trusted := []string{
"https://app.example.com",
"https://mobile.example.com",
}
for _, t := range trusted {
if origin == t {
return true
}
}
return false
}
```
### Masking Attacks
**Why masking is required:**
- Without masking, attacker can craft WebSocket frames that look like HTTP requests
- Proxies might misinterpret frame data as HTTP
- Could lead to cache poisoning or request smuggling
**Example attack (without masking):**
```
WebSocket payload: "GET /admin HTTP/1.1\r\nHost: victim.com\r\n\r\n"
Proxy might interpret as separate HTTP request
```
**Defense:** Client MUST mask all frames. Server MUST reject unmasked frames from client.
### Connection Limits
**Prevent resource exhaustion:**
```go
type ConnectionLimiter struct {
connections map[string]int
maxPerIP int
mu sync.Mutex
}
func (cl *ConnectionLimiter) Allow(ip string) bool {
cl.mu.Lock()
defer cl.mu.Unlock()
if cl.connections[ip] >= cl.maxPerIP {
return false
}
cl.connections[ip]++
return true
}
func (cl *ConnectionLimiter) Release(ip string) {
cl.mu.Lock()
defer cl.mu.Unlock()
cl.connections[ip]--
}
```
### TLS (WSS)
**Use WSS (WebSocket Secure) for:**
- Authentication credentials
- Private user data
- Financial transactions
- Any sensitive information
**WSS connection flow:**
1. Establish TLS connection
2. Perform TLS handshake
3. Verify server certificate
4. Perform WebSocket handshake over TLS
**URL schemes:**
- `ws://` - Unencrypted WebSocket (default port 80)
- `wss://` - Encrypted WebSocket over TLS (default port 443)
### Message Size Limits
**Prevent memory exhaustion:**
```go
const maxMessageSize = 512 * 1024 // 512 KB
ws.SetReadLimit(maxMessageSize)
// Or during frame reading:
if payloadLength > maxMessageSize {
ws.SendClose(1009, "message too large")
ws.Close()
}
```
### Rate Limiting
**Prevent abuse:**
```go
type RateLimiter struct {
limiter *rate.Limiter
}
func (rl *RateLimiter) Allow() bool {
return rl.limiter.Allow()
}
// Per-connection limiter
limiter := rate.NewLimiter(10, 20) // 10 msgs/sec, burst 20
if !limiter.Allow() {
ws.SendClose(4001, "rate limit exceeded")
}
```
## Error Handling
### Connection Errors
**Types of errors:**
1. **Network errors:** TCP connection failure, timeout
2. **Protocol errors:** Invalid frame format, wrong opcode
3. **Application errors:** Invalid message content
**Handling strategy:**
```go
for {
frame, err := ws.ReadFrame()
if err != nil {
// Check error type
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
// Timeout - connection likely dead
log.Println("Connection timeout")
ws.Close()
return
}
if err == io.EOF || err == io.ErrUnexpectedEOF {
// Connection closed
log.Println("Connection closed")
return
}
if protocolErr, ok := err.(*ProtocolError); ok {
// Protocol violation
log.Printf("Protocol error: %v", protocolErr)
ws.SendClose(1002, protocolErr.Error())
ws.Close()
return
}
// Unknown error
log.Printf("Unknown error: %v", err)
ws.Close()
return
}
// Process frame
}
```
### UTF-8 Validation
**Text frames MUST contain valid UTF-8:**
```go
func validateUTF8(data []byte) bool {
return utf8.Valid(data)
}
func handleTextFrame(payload []byte) error {
if !validateUTF8(payload) {
return fmt.Errorf("invalid UTF-8 in text frame")
}
// Process valid text
return nil
}
```
**For fragmented messages:** Validate UTF-8 across all fragments when reassembled.
## Implementation Checklist
### Client Implementation
- [ ] Generate random Sec-WebSocket-Key
- [ ] Compute and validate Sec-WebSocket-Accept
- [ ] MUST mask all frames sent to server
- [ ] Handle unmasked frames from server
- [ ] Respond to Ping with Pong
- [ ] Implement close handshake (both initiating and responding)
- [ ] Validate UTF-8 in text frames
- [ ] Handle fragmented messages
- [ ] Set reasonable timeouts
- [ ] Implement reconnection logic
### Server Implementation
- [ ] Validate Sec-WebSocket-Key format
- [ ] Compute correct Sec-WebSocket-Accept
- [ ] Validate Origin header
- [ ] MUST NOT mask frames sent to client
- [ ] Reject masked frames from server (protocol error)
- [ ] Respond to Ping with Pong
- [ ] Implement close handshake (both initiating and responding)
- [ ] Validate UTF-8 in text frames
- [ ] Handle fragmented messages
- [ ] Implement connection limits (per IP, total)
- [ ] Implement message size limits
- [ ] Implement rate limiting
- [ ] Log connection statistics
- [ ] Graceful shutdown (close all connections)
### Both Client and Server
- [ ] Handle concurrent read/write safely
- [ ] Process control frames immediately (even during fragmentation)
- [ ] Implement proper timeout mechanisms
- [ ] Log errors with appropriate detail
- [ ] Handle unexpected close gracefully
- [ ] Validate frame structure
- [ ] Check RSV bits (must be 0 unless extension)
- [ ] Support standard close status codes
- [ ] Implement proper error handling for all operations
## Common Implementation Mistakes
### 1. Concurrent Writes
**Mistake:** Writing to WebSocket from multiple goroutines without synchronization
**Fix:** Use mutex or single-writer goroutine
```go
type WebSocket struct {
conn *websocket.Conn
mutex sync.Mutex
}
func (ws *WebSocket) WriteMessage(data []byte) error {
ws.mutex.Lock()
defer ws.mutex.Unlock()
return ws.conn.WriteMessage(websocket.TextMessage, data)
}
```
### 2. Not Handling Pong
**Mistake:** Sending Ping but not updating read deadline on Pong
**Fix:**
```go
ws.SetPongHandler(func(string) error {
ws.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
```
### 3. Forgetting Close Handshake
**Mistake:** Just calling `conn.Close()` without sending Close frame
**Fix:** Send Close frame first, wait for response, then close TCP
### 4. Not Validating UTF-8
**Mistake:** Accepting any bytes in text frames
**Fix:** Validate UTF-8 and fail connection on invalid text
### 5. No Message Size Limit
**Mistake:** Allowing unlimited message sizes
**Fix:** Set `SetReadLimit()` to reasonable value (e.g., 512 KB)
### 6. Blocking on Write
**Mistake:** Blocking indefinitely on slow clients
**Fix:** Set write deadline before each write
```go
ws.SetWriteDeadline(time.Now().Add(10 * time.Second))
```
### 7. Memory Leaks
**Mistake:** Not cleaning up resources on disconnect
**Fix:** Use defer for cleanup, ensure all goroutines terminate
### 8. Race Conditions in Close
**Mistake:** Multiple goroutines trying to close connection
**Fix:** Use `sync.Once` for close operation
```go
type WebSocket struct {
conn *websocket.Conn
closeOnce sync.Once
}
func (ws *WebSocket) Close() error {
var err error
ws.closeOnce.Do(func() {
err = ws.conn.Close()
})
return err
}
```

View File

@@ -0,0 +1,119 @@
# React 19 Skill
A comprehensive Claude skill for working with React 19, including hooks, components, server components, and modern React architecture.
## Contents
### Main Skill File
- **SKILL.md** - Main skill document with React 19 fundamentals, hooks, components, and best practices
### References
- **hooks-quick-reference.md** - Quick reference for all React hooks with examples
- **server-components.md** - Complete guide to React Server Components and Server Functions
- **performance.md** - Performance optimization strategies and techniques
### Examples
- **practical-patterns.tsx** - Real-world React patterns and solutions
## What This Skill Covers
### Core Topics
- React 19 features and improvements
- All built-in hooks (useState, useEffect, useTransition, useOptimistic, etc.)
- Component patterns and composition
- Server Components and Server Functions
- React Compiler and automatic optimization
- Performance optimization techniques
- Form handling and validation
- Error boundaries and error handling
- Context and global state management
- Code splitting and lazy loading
### Best Practices
- Component design principles
- State management strategies
- Performance optimization
- Error handling patterns
- TypeScript integration
- Testing considerations
- Accessibility guidelines
## When to Use This Skill
Use this skill when:
- Building React 19 applications
- Working with React hooks
- Implementing server components
- Optimizing React performance
- Troubleshooting React-specific issues
- Understanding concurrent features
- Working with forms and user input
- Implementing complex UI patterns
## Quick Start Examples
### Basic Component
```typescript
interface ButtonProps {
label: string
onClick: () => void
}
const Button = ({ label, onClick }: ButtonProps) => {
return <button onClick={onClick}>{label}</button>
}
```
### Using Hooks
```typescript
const Counter = () => {
const [count, setCount] = useState(0)
useEffect(() => {
console.log(`Count is: ${count}`)
}, [count])
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
)
}
```
### Server Component
```typescript
const Page = async () => {
const data = await fetchData()
return <div>{data}</div>
}
```
### Server Function
```typescript
'use server'
export async function createUser(formData: FormData) {
const name = formData.get('name')
return await db.user.create({ data: { name } })
}
```
## Related Skills
- **typescript** - TypeScript patterns for React
- **ndk** - Nostr integration with React
- **skill-creator** - Creating reusable component libraries
## Resources
- [React Documentation](https://react.dev)
- [React API Reference](https://react.dev/reference/react)
- [React Hooks Reference](https://react.dev/reference/react/hooks)
- [React Server Components](https://react.dev/reference/rsc)
- [React Compiler](https://react.dev/reference/react-compiler)
## Version
This skill is based on React 19.2 and includes the latest features and APIs.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,878 @@
# React Practical Examples
This file contains real-world examples of React patterns and solutions.
## Example 1: Custom Hook for Data Fetching
```typescript
import { useState, useEffect } from 'react'
interface FetchState<T> {
data: T | null
loading: boolean
error: Error | null
}
const useFetch = <T,>(url: string) => {
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: true,
error: null
})
useEffect(() => {
let cancelled = false
const controller = new AbortController()
const fetchData = async () => {
try {
setState(prev => ({ ...prev, loading: true, error: null }))
const response = await fetch(url, {
signal: controller.signal
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
if (!cancelled) {
setState({ data, loading: false, error: null })
}
} catch (error) {
if (!cancelled && error.name !== 'AbortError') {
setState({
data: null,
loading: false,
error: error as Error
})
}
}
}
fetchData()
return () => {
cancelled = true
controller.abort()
}
}, [url])
return state
}
// Usage
const UserProfile = ({ userId }: { userId: string }) => {
const { data, loading, error } = useFetch<User>(`/api/users/${userId}`)
if (loading) return <Spinner />
if (error) return <ErrorMessage error={error} />
if (!data) return null
return <UserCard user={data} />
}
```
## Example 2: Form with Validation
```typescript
import { useState, useCallback } from 'react'
import { z } from 'zod'
const userSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
age: z.number().min(18, 'Must be 18 or older')
})
type UserForm = z.infer<typeof userSchema>
type FormErrors = Partial<Record<keyof UserForm, string>>
const UserForm = () => {
const [formData, setFormData] = useState<UserForm>({
name: '',
email: '',
age: 0
})
const [errors, setErrors] = useState<FormErrors>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const handleChange = useCallback((
field: keyof UserForm,
value: string | number
) => {
setFormData(prev => ({ ...prev, [field]: value }))
// Clear error when user starts typing
setErrors(prev => ({ ...prev, [field]: undefined }))
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// Validate
const result = userSchema.safeParse(formData)
if (!result.success) {
const fieldErrors: FormErrors = {}
result.error.errors.forEach(err => {
const field = err.path[0] as keyof UserForm
fieldErrors[field] = err.message
})
setErrors(fieldErrors)
return
}
// Submit
setIsSubmitting(true)
try {
await submitUser(result.data)
// Success handling
} catch (error) {
console.error(error)
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Name</label>
<input
id="name"
value={formData.name}
onChange={e => handleChange('name', e.target.value)}
/>
{errors.name && <span className="error">{errors.name}</span>}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={formData.email}
onChange={e => handleChange('email', e.target.value)}
/>
{errors.email && <span className="error">{errors.email}</span>}
</div>
<div>
<label htmlFor="age">Age</label>
<input
id="age"
type="number"
value={formData.age || ''}
onChange={e => handleChange('age', Number(e.target.value))}
/>
{errors.age && <span className="error">{errors.age}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
)
}
```
## Example 3: Modal with Portal
```typescript
import { createPortal } from 'react-dom'
import { useEffect, useRef, useState } from 'react'
interface ModalProps {
isOpen: boolean
onClose: () => void
children: React.ReactNode
title?: string
}
const Modal = ({ isOpen, onClose, children, title }: ModalProps) => {
const modalRef = useRef<HTMLDivElement>(null)
// Close on Escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
if (isOpen) {
document.addEventListener('keydown', handleEscape)
// Prevent body scroll
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = 'unset'
}
}, [isOpen, onClose])
// Close on backdrop click
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === modalRef.current) {
onClose()
}
}
if (!isOpen) return null
return createPortal(
<div
ref={modalRef}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={handleBackdropClick}
>
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<div className="flex justify-between items-center mb-4">
{title && <h2 className="text-xl font-bold">{title}</h2>}
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700"
aria-label="Close modal"
>
</button>
</div>
{children}
</div>
</div>,
document.body
)
}
// Usage
const App = () => {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<button onClick={() => setIsOpen(true)}>Open Modal</button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)} title="My Modal">
<p>Modal content goes here</p>
<button onClick={() => setIsOpen(false)}>Close</button>
</Modal>
</>
)
}
```
## Example 4: Infinite Scroll
```typescript
import { useState, useEffect, useRef, useCallback } from 'react'
interface InfiniteScrollProps<T> {
fetchData: (page: number) => Promise<T[]>
renderItem: (item: T, index: number) => React.ReactNode
loader?: React.ReactNode
endMessage?: React.ReactNode
}
const InfiniteScroll = <T extends { id: string | number },>({
fetchData,
renderItem,
loader = <div>Loading...</div>,
endMessage = <div>No more items</div>
}: InfiniteScrollProps<T>) => {
const [items, setItems] = useState<T[]>([])
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const observerRef = useRef<IntersectionObserver | null>(null)
const loadMoreRef = useRef<HTMLDivElement>(null)
const loadMore = useCallback(async () => {
if (loading || !hasMore) return
setLoading(true)
try {
const newItems = await fetchData(page)
if (newItems.length === 0) {
setHasMore(false)
} else {
setItems(prev => [...prev, ...newItems])
setPage(prev => prev + 1)
}
} catch (error) {
console.error('Failed to load items:', error)
} finally {
setLoading(false)
}
}, [page, loading, hasMore, fetchData])
// Set up intersection observer
useEffect(() => {
observerRef.current = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting) {
loadMore()
}
},
{ threshold: 0.1 }
)
const currentRef = loadMoreRef.current
if (currentRef) {
observerRef.current.observe(currentRef)
}
return () => {
if (observerRef.current && currentRef) {
observerRef.current.unobserve(currentRef)
}
}
}, [loadMore])
// Initial load
useEffect(() => {
loadMore()
}, [])
return (
<div>
{items.map((item, index) => (
<div key={item.id}>
{renderItem(item, index)}
</div>
))}
<div ref={loadMoreRef}>
{loading && loader}
{!loading && !hasMore && endMessage}
</div>
</div>
)
}
// Usage
const PostsList = () => {
const fetchPosts = async (page: number) => {
const response = await fetch(`/api/posts?page=${page}`)
return response.json()
}
return (
<InfiniteScroll<Post>
fetchData={fetchPosts}
renderItem={(post) => <PostCard post={post} />}
/>
)
}
```
## Example 5: Dark Mode Toggle
```typescript
import { createContext, useContext, useState, useEffect } from 'react'
type Theme = 'light' | 'dark'
interface ThemeContextType {
theme: Theme
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType | null>(null)
export const useTheme = () => {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within ThemeProvider')
}
return context
}
export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
const [theme, setTheme] = useState<Theme>(() => {
// Check localStorage and system preference
const saved = localStorage.getItem('theme') as Theme | null
if (saved) return saved
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark'
}
return 'light'
})
useEffect(() => {
// Update DOM and localStorage
const root = document.documentElement
root.classList.remove('light', 'dark')
root.classList.add(theme)
localStorage.setItem('theme', theme)
}, [theme])
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light')
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
// Usage
const ThemeToggle = () => {
const { theme, toggleTheme } = useTheme()
return (
<button onClick={toggleTheme} aria-label="Toggle theme">
{theme === 'light' ? '🌙' : '☀️'}
</button>
)
}
```
## Example 6: Debounced Search
```typescript
import { useState, useEffect, useMemo } from 'react'
const useDebounce = <T,>(value: T, delay: number): T => {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(timer)
}
}, [value, delay])
return debouncedValue
}
const SearchPage = () => {
const [query, setQuery] = useState('')
const [results, setResults] = useState<Product[]>([])
const [loading, setLoading] = useState(false)
const debouncedQuery = useDebounce(query, 500)
useEffect(() => {
if (!debouncedQuery) {
setResults([])
return
}
const searchProducts = async () => {
setLoading(true)
try {
const response = await fetch(`/api/search?q=${debouncedQuery}`)
const data = await response.json()
setResults(data)
} catch (error) {
console.error('Search failed:', error)
} finally {
setLoading(false)
}
}
searchProducts()
}, [debouncedQuery])
return (
<div>
<input
type="search"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search products..."
/>
{loading && <Spinner />}
{!loading && results.length > 0 && (
<div>
{results.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
)}
{!loading && query && results.length === 0 && (
<p>No results found for "{query}"</p>
)}
</div>
)
}
```
## Example 7: Tabs Component
```typescript
import { createContext, useContext, useState, useId } from 'react'
interface TabsContextType {
activeTab: string
setActiveTab: (id: string) => void
tabsId: string
}
const TabsContext = createContext<TabsContextType | null>(null)
const useTabs = () => {
const context = useContext(TabsContext)
if (!context) throw new Error('Tabs compound components must be used within Tabs')
return context
}
interface TabsProps {
children: React.ReactNode
defaultValue: string
className?: string
}
const Tabs = ({ children, defaultValue, className }: TabsProps) => {
const [activeTab, setActiveTab] = useState(defaultValue)
const tabsId = useId()
return (
<TabsContext.Provider value={{ activeTab, setActiveTab, tabsId }}>
<div className={className}>
{children}
</div>
</TabsContext.Provider>
)
}
const TabsList = ({ children, className }: {
children: React.ReactNode
className?: string
}) => (
<div role="tablist" className={className}>
{children}
</div>
)
interface TabsTriggerProps {
value: string
children: React.ReactNode
className?: string
}
const TabsTrigger = ({ value, children, className }: TabsTriggerProps) => {
const { activeTab, setActiveTab, tabsId } = useTabs()
const isActive = activeTab === value
return (
<button
role="tab"
id={`${tabsId}-tab-${value}`}
aria-controls={`${tabsId}-panel-${value}`}
aria-selected={isActive}
onClick={() => setActiveTab(value)}
className={`${className} ${isActive ? 'active' : ''}`}
>
{children}
</button>
)
}
interface TabsContentProps {
value: string
children: React.ReactNode
className?: string
}
const TabsContent = ({ value, children, className }: TabsContentProps) => {
const { activeTab, tabsId } = useTabs()
if (activeTab !== value) return null
return (
<div
role="tabpanel"
id={`${tabsId}-panel-${value}`}
aria-labelledby={`${tabsId}-tab-${value}`}
className={className}
>
{children}
</div>
)
}
// Export compound component
export { Tabs, TabsList, TabsTrigger, TabsContent }
// Usage
const App = () => (
<Tabs defaultValue="profile">
<TabsList>
<TabsTrigger value="profile">Profile</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="notifications">Notifications</TabsTrigger>
</TabsList>
<TabsContent value="profile">
<h2>Profile Content</h2>
</TabsContent>
<TabsContent value="settings">
<h2>Settings Content</h2>
</TabsContent>
<TabsContent value="notifications">
<h2>Notifications Content</h2>
</TabsContent>
</Tabs>
)
```
## Example 8: Error Boundary
```typescript
import { Component, ErrorInfo, ReactNode } from 'react'
interface Props {
children: ReactNode
fallback?: (error: Error, reset: () => void) => ReactNode
onError?: (error: Error, errorInfo: ErrorInfo) => void
}
interface State {
hasError: boolean
error: Error | null
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo)
this.props.onError?.(error, errorInfo)
}
reset = () => {
this.setState({ hasError: false, error: null })
}
render() {
if (this.state.hasError && this.state.error) {
if (this.props.fallback) {
return this.props.fallback(this.state.error, this.reset)
}
return (
<div className="error-boundary">
<h2>Something went wrong</h2>
<details>
<summary>Error details</summary>
<pre>{this.state.error.message}</pre>
</details>
<button onClick={this.reset}>Try again</button>
</div>
)
}
return this.props.children
}
}
// Usage
const App = () => (
<ErrorBoundary
fallback={(error, reset) => (
<div>
<h1>Oops! Something went wrong</h1>
<p>{error.message}</p>
<button onClick={reset}>Retry</button>
</div>
)}
onError={(error, errorInfo) => {
// Send to error tracking service
console.error('Error logged:', error, errorInfo)
}}
>
<YourApp />
</ErrorBoundary>
)
```
## Example 9: Custom Hook for Local Storage
```typescript
import { useState, useEffect, useCallback } from 'react'
const useLocalStorage = <T,>(
key: string,
initialValue: T
): [T, (value: T | ((val: T) => T)) => void, () => void] => {
// Get initial value from localStorage
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch (error) {
console.error(`Error loading ${key} from localStorage:`, error)
return initialValue
}
})
// Update localStorage when value changes
const setValue = useCallback((value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
window.localStorage.setItem(key, JSON.stringify(valueToStore))
// Dispatch storage event for other tabs
window.dispatchEvent(new Event('storage'))
} catch (error) {
console.error(`Error saving ${key} to localStorage:`, error)
}
}, [key, storedValue])
// Remove from localStorage
const removeValue = useCallback(() => {
try {
window.localStorage.removeItem(key)
setStoredValue(initialValue)
} catch (error) {
console.error(`Error removing ${key} from localStorage:`, error)
}
}, [key, initialValue])
// Listen for changes in other tabs
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue) {
setStoredValue(JSON.parse(e.newValue))
}
}
window.addEventListener('storage', handleStorageChange)
return () => window.removeEventListener('storage', handleStorageChange)
}, [key])
return [storedValue, setValue, removeValue]
}
// Usage
const UserPreferences = () => {
const [preferences, setPreferences, clearPreferences] = useLocalStorage('user-prefs', {
theme: 'light',
language: 'en',
notifications: true
})
return (
<div>
<label>
<input
type="checkbox"
checked={preferences.notifications}
onChange={e => setPreferences({
...preferences,
notifications: e.target.checked
})}
/>
Enable notifications
</label>
<button onClick={clearPreferences}>
Reset to defaults
</button>
</div>
)
}
```
## Example 10: Optimistic Updates with useOptimistic
```typescript
'use client'
import { useOptimistic } from 'react'
import { likePost, unlikePost } from './actions'
interface Post {
id: string
content: string
likes: number
isLiked: boolean
}
const PostCard = ({ post }: { post: Post }) => {
const [optimisticPost, addOptimistic] = useOptimistic(
post,
(currentPost, update: Partial<Post>) => ({
...currentPost,
...update
})
)
const handleLike = async () => {
// Optimistically update UI
addOptimistic({
likes: optimisticPost.likes + 1,
isLiked: true
})
try {
// Send server request
await likePost(post.id)
} catch (error) {
// Server will send correct state via revalidation
console.error('Failed to like post:', error)
}
}
const handleUnlike = async () => {
addOptimistic({
likes: optimisticPost.likes - 1,
isLiked: false
})
try {
await unlikePost(post.id)
} catch (error) {
console.error('Failed to unlike post:', error)
}
}
return (
<div className="post-card">
<p>{optimisticPost.content}</p>
<button
onClick={optimisticPost.isLiked ? handleUnlike : handleLike}
className={optimisticPost.isLiked ? 'liked' : ''}
>
❤️ {optimisticPost.likes}
</button>
</div>
)
}
```
## References
These examples demonstrate:
- Custom hooks for reusable logic
- Form handling with validation
- Portal usage for modals
- Infinite scroll with Intersection Observer
- Context for global state
- Debouncing for performance
- Compound components pattern
- Error boundaries
- LocalStorage integration
- Optimistic updates (React 19)

View File

@@ -0,0 +1,291 @@
# React Hooks Quick Reference
## State Hooks
### useState
```typescript
const [state, setState] = useState<Type>(initialValue)
const [count, setCount] = useState(0)
// Functional update
setCount(prev => prev + 1)
// Lazy initialization
const [state, setState] = useState(() => expensiveComputation())
```
### useReducer
```typescript
type State = { count: number }
type Action = { type: 'increment' } | { type: 'decrement' }
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'increment': return { count: state.count + 1 }
case 'decrement': return { count: state.count - 1 }
}
}
const [state, dispatch] = useReducer(reducer, { count: 0 })
dispatch({ type: 'increment' })
```
### useActionState (React 19)
```typescript
const [state, formAction, isPending] = useActionState(
async (previousState, formData: FormData) => {
// Server action
return await processForm(formData)
},
initialState
)
<form action={formAction}>
<button disabled={isPending}>Submit</button>
</form>
```
## Effect Hooks
### useEffect
```typescript
useEffect(() => {
// Side effect
const subscription = api.subscribe()
// Cleanup
return () => subscription.unsubscribe()
}, [dependencies])
```
**Timing**: After render & paint
**Use for**: Data fetching, subscriptions, DOM mutations
### useLayoutEffect
```typescript
useLayoutEffect(() => {
// Runs before paint
const height = ref.current.offsetHeight
setHeight(height)
}, [])
```
**Timing**: After render, before paint
**Use for**: DOM measurements, preventing flicker
### useInsertionEffect
```typescript
useInsertionEffect(() => {
// Insert styles before any DOM reads
const style = document.createElement('style')
style.textContent = css
document.head.appendChild(style)
return () => document.head.removeChild(style)
}, [css])
```
**Timing**: Before any DOM mutations
**Use for**: CSS-in-JS libraries
## Performance Hooks
### useMemo
```typescript
const memoizedValue = useMemo(() => {
return expensiveComputation(a, b)
}, [a, b])
```
**Use for**: Expensive calculations, stable object references
### useCallback
```typescript
const memoizedCallback = useCallback(() => {
doSomething(a, b)
}, [a, b])
```
**Use for**: Passing callbacks to optimized components
## Ref Hooks
### useRef
```typescript
// DOM reference
const ref = useRef<HTMLDivElement>(null)
ref.current?.focus()
// Mutable value (doesn't trigger re-render)
const countRef = useRef(0)
countRef.current += 1
```
### useImperativeHandle
```typescript
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => inputRef.current && (inputRef.current.value = '')
}), [])
```
## Context Hook
### useContext
```typescript
const value = useContext(MyContext)
```
Must be used within a Provider.
## Transition Hooks
### useTransition
```typescript
const [isPending, startTransition] = useTransition()
startTransition(() => {
setState(newValue) // Non-urgent update
})
```
### useDeferredValue
```typescript
const [input, setInput] = useState('')
const deferredInput = useDeferredValue(input)
// Use deferredInput for expensive operations
const results = useMemo(() => search(deferredInput), [deferredInput])
```
## Optimistic Updates (React 19)
### useOptimistic
```typescript
const [optimisticState, addOptimistic] = useOptimistic(
actualState,
(currentState, optimisticValue) => {
return [...currentState, optimisticValue]
}
)
```
## Other Hooks
### useId
```typescript
const id = useId()
<label htmlFor={id}>Name</label>
<input id={id} />
```
### useSyncExternalStore
```typescript
const state = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot
)
```
### useDebugValue
```typescript
useDebugValue(isOnline ? 'Online' : 'Offline')
```
### use (React 19)
```typescript
// Read context or promise
const value = use(MyContext)
const data = use(fetchPromise) // Must be in Suspense
```
## Form Hooks (React DOM)
### useFormStatus
```typescript
import { useFormStatus } from 'react-dom'
const { pending, data, method, action } = useFormStatus()
```
## Hook Rules
1. **Only call at top level** - Not in loops, conditions, or nested functions
2. **Only call from React functions** - Components or custom hooks
3. **Custom hooks start with "use"** - Naming convention
4. **Same hooks in same order** - Every render must call same hooks
## Dependencies Best Practices
1. **Include all used values** - Variables, props, state from component scope
2. **Use ESLint plugin** - `eslint-plugin-react-hooks` enforces rules
3. **Functions as dependencies** - Wrap with useCallback or define outside component
4. **Object/array dependencies** - Use useMemo for stable references
## Common Patterns
### Fetching Data
```typescript
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
const controller = new AbortController()
fetch('/api/data', { signal: controller.signal })
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false))
return () => controller.abort()
}, [])
```
### Debouncing
```typescript
const [value, setValue] = useState('')
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, 500)
return () => clearTimeout(timer)
}, [value])
```
### Previous Value
```typescript
const usePrevious = <T,>(value: T): T | undefined => {
const ref = useRef<T>()
useEffect(() => {
ref.current = value
})
return ref.current
}
```
### Interval
```typescript
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1)
}, 1000)
return () => clearInterval(id)
}, [])
```
### Event Listeners
```typescript
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth)
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
```

View File

@@ -0,0 +1,658 @@
# React Performance Optimization Guide
## Overview
This guide covers performance optimization strategies for React 19 applications.
## Measurement & Profiling
### React DevTools Profiler
Record performance data:
1. Open React DevTools
2. Go to Profiler tab
3. Click record button
4. Interact with app
5. Stop recording
6. Analyze flame graph and ranked chart
### Profiler Component
```typescript
import { Profiler } from 'react'
const App = () => {
const onRender = (
id: string,
phase: 'mount' | 'update',
actualDuration: number,
baseDuration: number,
startTime: number,
commitTime: number
) => {
console.log({
component: id,
phase,
actualDuration, // Time spent rendering this update
baseDuration // Estimated time without memoization
})
}
return (
<Profiler id="App" onRender={onRender}>
<YourApp />
</Profiler>
)
}
```
### Performance Metrics
```typescript
// Custom performance tracking
const startTime = performance.now()
// ... do work
const endTime = performance.now()
console.log(`Operation took ${endTime - startTime}ms`)
// React rendering metrics
import { unstable_trace as trace } from 'react'
trace('expensive-operation', async () => {
await performExpensiveOperation()
})
```
## Memoization Strategies
### React.memo
Prevent unnecessary re-renders:
```typescript
// Basic memoization
const ExpensiveComponent = memo(({ data }: Props) => {
return <div>{processData(data)}</div>
})
// Custom comparison
const MemoizedComponent = memo(
({ user }: Props) => <UserCard user={user} />,
(prevProps, nextProps) => {
// Return true if props are equal (skip render)
return prevProps.user.id === nextProps.user.id
}
)
```
**When to use:**
- Component renders often with same props
- Rendering is expensive
- Component receives complex prop objects
**When NOT to use:**
- Props change frequently
- Component is already fast
- Premature optimization
### useMemo
Memoize computed values:
```typescript
const SortedList = ({ items, filter }: Props) => {
// Without memoization - runs every render
const filteredItems = items.filter(item => item.type === filter)
const sortedItems = filteredItems.sort((a, b) => a.name.localeCompare(b.name))
// With memoization - only runs when dependencies change
const sortedFilteredItems = useMemo(() => {
const filtered = items.filter(item => item.type === filter)
return filtered.sort((a, b) => a.name.localeCompare(b.name))
}, [items, filter])
return (
<ul>
{sortedFilteredItems.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
)
}
```
**When to use:**
- Expensive calculations (sorting, filtering large arrays)
- Creating stable object references
- Computed values used as dependencies
### useCallback
Memoize callback functions:
```typescript
const Parent = () => {
const [count, setCount] = useState(0)
// Without useCallback - new function every render
const handleClick = () => {
setCount(c => c + 1)
}
// With useCallback - stable function reference
const handleClickMemo = useCallback(() => {
setCount(c => c + 1)
}, [])
return <MemoizedChild onClick={handleClickMemo} />
}
const MemoizedChild = memo(({ onClick }: Props) => {
return <button onClick={onClick}>Click</button>
})
```
**When to use:**
- Passing callbacks to memoized components
- Callback is used in dependency array
- Callback is expensive to create
## React Compiler (Automatic Optimization)
### Enable React Compiler
React 19 can automatically optimize without manual memoization:
```javascript
// babel.config.js
module.exports = {
plugins: [
['react-compiler', {
compilationMode: 'all', // Optimize all components
}]
]
}
```
### Compilation Modes
```javascript
{
compilationMode: 'annotation', // Only components with "use memo"
compilationMode: 'all', // All components (recommended)
compilationMode: 'infer' // Based on component complexity
}
```
### Directives
```typescript
// Force memoization
'use memo'
const Component = ({ data }: Props) => {
return <div>{data}</div>
}
// Prevent memoization
'use no memo'
const SimpleComponent = ({ text }: Props) => {
return <span>{text}</span>
}
```
## State Management Optimization
### State Colocation
Keep state as close as possible to where it's used:
```typescript
// Bad - state too high
const App = () => {
const [showModal, setShowModal] = useState(false)
return (
<>
<Header />
<Content />
<Modal show={showModal} onClose={() => setShowModal(false)} />
</>
)
}
// Good - state colocated
const App = () => {
return (
<>
<Header />
<Content />
<ModalContainer />
</>
)
}
const ModalContainer = () => {
const [showModal, setShowModal] = useState(false)
return <Modal show={showModal} onClose={() => setShowModal(false)} />
}
```
### Split Context
Avoid unnecessary re-renders by splitting context:
```typescript
// Bad - single context causes all consumers to re-render
const AppContext = createContext({ user, theme, settings })
// Good - split into separate contexts
const UserContext = createContext(user)
const ThemeContext = createContext(theme)
const SettingsContext = createContext(settings)
```
### Context with useMemo
```typescript
const ThemeProvider = ({ children }: Props) => {
const [theme, setTheme] = useState('light')
// Memoize context value to prevent unnecessary re-renders
const value = useMemo(() => ({
theme,
setTheme
}), [theme])
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
)
}
```
## Code Splitting & Lazy Loading
### React.lazy
Split components into separate bundles:
```typescript
import { lazy, Suspense } from 'react'
// Lazy load components
const Dashboard = lazy(() => import('./Dashboard'))
const Settings = lazy(() => import('./Settings'))
const Profile = lazy(() => import('./Profile'))
const App = () => {
return (
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/profile" element={<Profile />} />
</Routes>
</Suspense>
)
}
```
### Route-based Splitting
```typescript
// App.tsx
const routes = [
{ path: '/', component: lazy(() => import('./pages/Home')) },
{ path: '/about', component: lazy(() => import('./pages/About')) },
{ path: '/products', component: lazy(() => import('./pages/Products')) },
]
const App = () => (
<Suspense fallback={<PageLoader />}>
<Routes>
{routes.map(({ path, component: Component }) => (
<Route key={path} path={path} element={<Component />} />
))}
</Routes>
</Suspense>
)
```
### Component-based Splitting
```typescript
// Split expensive components
const HeavyChart = lazy(() => import('./HeavyChart'))
const Dashboard = () => {
const [showChart, setShowChart] = useState(false)
return (
<>
<button onClick={() => setShowChart(true)}>
Load Chart
</button>
{showChart && (
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart />
</Suspense>
)}
</>
)
}
```
## List Rendering Optimization
### Keys
Always use stable, unique keys:
```typescript
// Bad - index as key (causes issues on reorder/insert)
{items.map((item, index) => (
<Item key={index} data={item} />
))}
// Good - unique ID as key
{items.map(item => (
<Item key={item.id} data={item} />
))}
// For static lists without IDs
{items.map(item => (
<Item key={`${item.name}-${item.category}`} data={item} />
))}
```
### Virtualization
For long lists, render only visible items:
```typescript
import { useVirtualizer } from '@tanstack/react-virtual'
const VirtualList = ({ items }: { items: Item[] }) => {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // Estimated item height
overscan: 5 // Render 5 extra items above/below viewport
})
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative'
}}
>
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`
}}
>
<Item data={items[virtualItem.index]} />
</div>
))}
</div>
</div>
)
}
```
### Pagination
```typescript
const PaginatedList = ({ items }: Props) => {
const [page, setPage] = useState(1)
const itemsPerPage = 20
const paginatedItems = useMemo(() => {
const start = (page - 1) * itemsPerPage
const end = start + itemsPerPage
return items.slice(start, end)
}, [items, page, itemsPerPage])
return (
<>
{paginatedItems.map(item => (
<Item key={item.id} data={item} />
))}
<Pagination
page={page}
total={Math.ceil(items.length / itemsPerPage)}
onChange={setPage}
/>
</>
)
}
```
## Transitions & Concurrent Features
### useTransition
Keep UI responsive during expensive updates:
```typescript
const SearchPage = () => {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [isPending, startTransition] = useTransition()
const handleSearch = (value: string) => {
setQuery(value) // Urgent - update input immediately
// Non-urgent - can be interrupted
startTransition(() => {
const filtered = expensiveFilter(items, value)
setResults(filtered)
})
}
return (
<>
<input value={query} onChange={e => handleSearch(e.target.value)} />
{isPending && <Spinner />}
<ResultsList results={results} />
</>
)
}
```
### useDeferredValue
Defer non-urgent renders:
```typescript
const SearchPage = () => {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
// Input updates immediately
// Results update with deferred value (can be interrupted)
const results = useMemo(() => {
return expensiveFilter(items, deferredQuery)
}, [deferredQuery])
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ResultsList results={results} />
</>
)
}
```
## Image & Asset Optimization
### Lazy Load Images
```typescript
const LazyImage = ({ src, alt }: Props) => {
const [isLoaded, setIsLoaded] = useState(false)
return (
<div className="relative">
{!isLoaded && <ImageSkeleton />}
<img
src={src}
alt={alt}
loading="lazy" // Native lazy loading
onLoad={() => setIsLoaded(true)}
className={isLoaded ? 'opacity-100' : 'opacity-0'}
/>
</div>
)
}
```
### Next.js Image Component
```typescript
import Image from 'next/image'
const OptimizedImage = () => (
<Image
src="/hero.jpg"
alt="Hero"
width={800}
height={600}
priority // Load immediately for above-fold images
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
)
```
## Bundle Size Optimization
### Tree Shaking
Import only what you need:
```typescript
// Bad - imports entire library
import _ from 'lodash'
// Good - import only needed functions
import debounce from 'lodash/debounce'
import throttle from 'lodash/throttle'
// Even better - use native methods when possible
const debounce = (fn, delay) => {
let timeoutId
return (...args) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => fn(...args), delay)
}
}
```
### Analyze Bundle
```bash
# Next.js
ANALYZE=true npm run build
# Create React App
npm install --save-dev webpack-bundle-analyzer
```
### Dynamic Imports
```typescript
// Load library only when needed
const handleExport = async () => {
const { jsPDF } = await import('jspdf')
const doc = new jsPDF()
doc.save('report.pdf')
}
```
## Common Performance Pitfalls
### 1. Inline Object Creation
```typescript
// Bad - new object every render
<Component style={{ margin: 10 }} />
// Good - stable reference
const style = { margin: 10 }
<Component style={style} />
// Or use useMemo
const style = useMemo(() => ({ margin: 10 }), [])
```
### 2. Inline Functions
```typescript
// Bad - new function every render (if child is memoized)
<MemoizedChild onClick={() => handleClick(id)} />
// Good
const handleClickMemo = useCallback(() => handleClick(id), [id])
<MemoizedChild onClick={handleClickMemo} />
```
### 3. Spreading Props
```typescript
// Bad - causes re-renders even when props unchanged
<Component {...props} />
// Good - pass only needed props
<Component value={props.value} onChange={props.onChange} />
```
### 4. Large Context
```typescript
// Bad - everything re-renders on any state change
const AppContext = createContext({ user, theme, cart, settings, ... })
// Good - split into focused contexts
const UserContext = createContext(user)
const ThemeContext = createContext(theme)
const CartContext = createContext(cart)
```
## Performance Checklist
- [ ] Measure before optimizing (use Profiler)
- [ ] Use React DevTools to identify slow components
- [ ] Implement code splitting for large routes
- [ ] Lazy load below-the-fold content
- [ ] Virtualize long lists
- [ ] Memoize expensive calculations
- [ ] Split large contexts
- [ ] Colocate state close to usage
- [ ] Use transitions for non-urgent updates
- [ ] Optimize images and assets
- [ ] Analyze and minimize bundle size
- [ ] Remove console.logs in production
- [ ] Use production build for testing
- [ ] Monitor real-world performance metrics
## References
- React Performance: https://react.dev/learn/render-and-commit
- React Profiler: https://react.dev/reference/react/Profiler
- React Compiler: https://react.dev/reference/react-compiler
- Web Vitals: https://web.dev/vitals/

View File

@@ -0,0 +1,656 @@
# React Server Components & Server Functions
## Overview
React Server Components (RSC) allow components to render on the server, improving performance and enabling direct data access. Server Functions allow client components to call server-side functions.
## Server Components
### What are Server Components?
Components that run **only on the server**:
- Can access databases directly
- Zero bundle size (code stays on server)
- Better performance (less JavaScript to client)
- Automatic code splitting
### Creating Server Components
```typescript
// app/products/page.tsx
// Server Component by default in App Router
import { db } from '@/lib/db'
const ProductsPage = async () => {
// Direct database access
const products = await db.product.findMany({
where: { active: true },
include: { category: true }
})
return (
<div>
<h1>Products</h1>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
)
}
export default ProductsPage
```
### Server Component Rules
**Can do:**
- Access databases and APIs directly
- Use server-only modules (fs, path, etc.)
- Keep secrets secure (API keys, tokens)
- Reduce client bundle size
- Use async/await at top level
**Cannot do:**
- Use hooks (useState, useEffect, etc.)
- Use browser APIs (window, document)
- Attach event handlers (onClick, etc.)
- Use Context
### Mixing Server and Client Components
```typescript
// Server Component (default)
const Page = async () => {
const data = await fetchData()
return (
<div>
<ServerComponent data={data} />
{/* Client component for interactivity */}
<ClientComponent initialData={data} />
</div>
)
}
// Client Component
'use client'
import { useState } from 'react'
const ClientComponent = ({ initialData }) => {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(c => c + 1)}>
{count}
</button>
)
}
```
### Server Component Patterns
#### Data Fetching
```typescript
// app/user/[id]/page.tsx
interface PageProps {
params: { id: string }
}
const UserPage = async ({ params }: PageProps) => {
const user = await db.user.findUnique({
where: { id: params.id }
})
if (!user) {
notFound() // Next.js 404
}
return <UserProfile user={user} />
}
```
#### Parallel Data Fetching
```typescript
const DashboardPage = async () => {
// Fetch in parallel
const [user, orders, stats] = await Promise.all([
fetchUser(),
fetchOrders(),
fetchStats()
])
return (
<>
<UserHeader user={user} />
<OrdersList orders={orders} />
<StatsWidget stats={stats} />
</>
)
}
```
#### Streaming with Suspense
```typescript
const Page = () => {
return (
<>
<Header />
<Suspense fallback={<ProductsSkeleton />}>
<Products />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews />
</Suspense>
</>
)
}
const Products = async () => {
const products = await fetchProducts() // Slow query
return <ProductsList products={products} />
}
```
## Server Functions (Server Actions)
### What are Server Functions?
Functions that run on the server but can be called from client components:
- Marked with `'use server'` directive
- Can mutate data
- Integrated with forms
- Type-safe with TypeScript
### Creating Server Functions
#### File-level directive
```typescript
// app/actions.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function createProduct(formData: FormData) {
const name = formData.get('name') as string
const price = Number(formData.get('price'))
const product = await db.product.create({
data: { name, price }
})
revalidatePath('/products')
return product
}
export async function deleteProduct(id: string) {
await db.product.delete({ where: { id } })
revalidatePath('/products')
}
```
#### Function-level directive
```typescript
// Inside a Server Component
const MyComponent = async () => {
async function handleSubmit(formData: FormData) {
'use server'
const email = formData.get('email') as string
await saveEmail(email)
}
return <form action={handleSubmit}>...</form>
}
```
### Using Server Functions
#### With Forms
```typescript
'use client'
import { createProduct } from './actions'
const ProductForm = () => {
return (
<form action={createProduct}>
<input name="name" required />
<input name="price" type="number" required />
<button type="submit">Create</button>
</form>
)
}
```
#### With useActionState
```typescript
'use client'
import { useActionState } from 'react'
import { createProduct } from './actions'
type FormState = {
message: string
success: boolean
} | null
const ProductForm = () => {
const [state, formAction, isPending] = useActionState<FormState>(
async (previousState, formData: FormData) => {
try {
await createProduct(formData)
return { message: 'Product created!', success: true }
} catch (error) {
return { message: 'Failed to create product', success: false }
}
},
null
)
return (
<form action={formAction}>
<input name="name" required />
<input name="price" type="number" required />
<button disabled={isPending}>
{isPending ? 'Creating...' : 'Create'}
</button>
{state?.message && (
<p className={state.success ? 'text-green-600' : 'text-red-600'}>
{state.message}
</p>
)}
</form>
)
}
```
#### Programmatic Invocation
```typescript
'use client'
import { deleteProduct } from './actions'
const DeleteButton = ({ productId }: { productId: string }) => {
const [isPending, setIsPending] = useState(false)
const handleDelete = async () => {
setIsPending(true)
try {
await deleteProduct(productId)
} catch (error) {
console.error(error)
} finally {
setIsPending(false)
}
}
return (
<button onClick={handleDelete} disabled={isPending}>
{isPending ? 'Deleting...' : 'Delete'}
</button>
)
}
```
### Server Function Patterns
#### Validation with Zod
```typescript
'use server'
import { z } from 'zod'
const ProductSchema = z.object({
name: z.string().min(3),
price: z.number().positive(),
description: z.string().optional()
})
export async function createProduct(formData: FormData) {
const rawData = {
name: formData.get('name'),
price: Number(formData.get('price')),
description: formData.get('description')
}
// Validate
const result = ProductSchema.safeParse(rawData)
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors
}
}
// Create product
const product = await db.product.create({
data: result.data
})
revalidatePath('/products')
return { success: true, product }
}
```
#### Authentication Check
```typescript
'use server'
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export async function createOrder(formData: FormData) {
const session = await auth()
if (!session?.user) {
redirect('/login')
}
const order = await db.order.create({
data: {
userId: session.user.id,
// ... other fields
}
})
return order
}
```
#### Error Handling
```typescript
'use server'
export async function updateProfile(formData: FormData) {
try {
const userId = await getCurrentUserId()
const profile = await db.user.update({
where: { id: userId },
data: {
name: formData.get('name') as string,
bio: formData.get('bio') as string
}
})
revalidatePath('/profile')
return { success: true, profile }
} catch (error) {
console.error('Failed to update profile:', error)
return {
success: false,
error: 'Failed to update profile. Please try again.'
}
}
}
```
#### Optimistic Updates
```typescript
'use client'
import { useOptimistic } from 'react'
import { likePost } from './actions'
const Post = ({ post }: { post: Post }) => {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
post.likes,
(currentLikes) => currentLikes + 1
)
const handleLike = async () => {
addOptimisticLike(null)
await likePost(post.id)
}
return (
<div>
<p>{post.content}</p>
<button onClick={handleLike}>
{optimisticLikes}
</button>
</div>
)
}
```
## Data Mutations & Revalidation
### revalidatePath
Invalidate cached data for a path:
```typescript
'use server'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
await db.post.create({ data: {...} })
// Revalidate the posts page
revalidatePath('/posts')
// Revalidate with layout
revalidatePath('/posts', 'layout')
}
```
### revalidateTag
Invalidate cached data by tag:
```typescript
'use server'
import { revalidateTag } from 'next/cache'
export async function updateProduct(id: string, data: ProductData) {
await db.product.update({ where: { id }, data })
// Revalidate all queries tagged with 'products'
revalidateTag('products')
}
```
### redirect
Redirect after mutation:
```typescript
'use server'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
const post = await db.post.create({ data: {...} })
// Redirect to the new post
redirect(`/posts/${post.id}`)
}
```
## Caching with Server Components
### cache Function
Deduplicate requests within a render:
```typescript
import { cache } from 'react'
export const getUser = cache(async (id: string) => {
return await db.user.findUnique({ where: { id } })
})
// Called multiple times but only fetches once per render
const Page = async () => {
const user1 = await getUser('123')
const user2 = await getUser('123') // Uses cached result
return <div>...</div>
}
```
### Next.js fetch Caching
```typescript
// Cached by default
const data = await fetch('https://api.example.com/data')
// Revalidate every 60 seconds
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 60 }
})
// Never cache
const data = await fetch('https://api.example.com/data', {
cache: 'no-store'
})
// Tag for revalidation
const data = await fetch('https://api.example.com/data', {
next: { tags: ['products'] }
})
```
## Best Practices
### 1. Component Placement
- Keep interactive components client-side
- Use server components for data fetching
- Place 'use client' as deep as possible in tree
### 2. Data Fetching
- Fetch in parallel when possible
- Use Suspense for streaming
- Cache expensive operations
### 3. Server Functions
- Validate all inputs
- Check authentication/authorization
- Handle errors gracefully
- Return serializable data only
### 4. Performance
- Minimize client JavaScript
- Use streaming for slow queries
- Implement proper caching
- Optimize database queries
### 5. Security
- Never expose secrets to client
- Validate server function inputs
- Use environment variables
- Implement rate limiting
## Common Patterns
### Layout with Dynamic Data
```typescript
// app/layout.tsx
const RootLayout = async ({ children }: { children: React.ReactNode }) => {
const user = await getCurrentUser()
return (
<html>
<body>
<Header user={user} />
{children}
<Footer />
</body>
</html>
)
}
```
### Loading States
```typescript
// app/products/loading.tsx
export default function Loading() {
return <ProductsSkeleton />
}
// app/products/page.tsx
const ProductsPage = async () => {
const products = await fetchProducts()
return <ProductsList products={products} />
}
```
### Error Boundaries
```typescript
// app/products/error.tsx
'use client'
export default function Error({
error,
reset
}: {
error: Error
reset: () => void
}) {
return (
<div>
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
)
}
```
### Search with Server Functions
```typescript
'use client'
import { searchProducts } from './actions'
import { useDeferredValue, useState, useEffect } from 'react'
const SearchPage = () => {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const deferredQuery = useDeferredValue(query)
useEffect(() => {
if (deferredQuery) {
searchProducts(deferredQuery).then(setResults)
}
}, [deferredQuery])
return (
<>
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
<ResultsList results={results} />
</>
)
}
```
## Troubleshooting
### Common Issues
1. **"Cannot use hooks in Server Component"**
- Add 'use client' directive
- Move state logic to client component
2. **"Functions cannot be passed to Client Components"**
- Use Server Functions instead
- Pass data, not functions
3. **Hydration mismatches**
- Ensure server and client render same HTML
- Use useEffect for browser-only code
4. **Slow initial load**
- Implement Suspense boundaries
- Use streaming rendering
- Optimize database queries
## References
- React Server Components: https://react.dev/reference/rsc/server-components
- Server Functions: https://react.dev/reference/rsc/server-functions
- Next.js App Router: https://nextjs.org/docs/app

View File

@@ -0,0 +1,133 @@
# TypeScript Claude Skill
Comprehensive TypeScript skill for type-safe development with modern JavaScript/TypeScript applications.
## Overview
This skill provides in-depth knowledge about TypeScript's type system, patterns, best practices, and integration with popular frameworks like React. It covers everything from basic types to advanced type manipulation techniques.
## Files
### Core Documentation
- **SKILL.md** - Main skill file with workflows and when to use this skill
- **quick-reference.md** - Quick lookup guide for common TypeScript syntax and patterns
### Reference Materials
- **references/type-system.md** - Comprehensive guide to TypeScript's type system
- **references/utility-types.md** - Complete reference for built-in and custom utility types
- **references/common-patterns.md** - Real-world TypeScript patterns and idioms
### Examples
- **examples/type-system-basics.ts** - Fundamental TypeScript concepts
- **examples/advanced-types.ts** - Generics, conditional types, mapped types
- **examples/react-patterns.ts** - Type-safe React components and hooks
- **examples/README.md** - Guide to using the examples
## Usage
### When to Use This Skill
Reference this skill when:
- Writing or refactoring TypeScript code
- Designing type-safe APIs and interfaces
- Working with advanced type system features
- Configuring TypeScript projects
- Troubleshooting type errors
- Implementing type-safe patterns with libraries
- Converting JavaScript to TypeScript
### Quick Start
For quick lookups, start with `quick-reference.md` which provides concise syntax and patterns.
For learning or deep dives:
1. **Fundamentals**: Start with `references/type-system.md`
2. **Utilities**: Learn about transformations in `references/utility-types.md`
3. **Patterns**: Study real-world patterns in `references/common-patterns.md`
4. **Practice**: Explore code examples in `examples/`
## Key Topics Covered
### Type System
- Primitive types and special types
- Object types (interfaces, type aliases)
- Union and intersection types
- Literal types and template literal types
- Type inference and narrowing
- Generic types with constraints
- Conditional types and mapped types
- Recursive types
### Advanced Features
- Type guards and type predicates
- Assertion functions
- Branded types for nominal typing
- Key remapping and filtering
- Distributive conditional types
- Type-level programming
### Utility Types
- Built-in utilities (Partial, Pick, Omit, etc.)
- Custom utility type patterns
- Deep transformations
- Type composition
### React Integration
- Component props typing
- Generic components
- Hooks with TypeScript
- Context with type safety
- Event handlers
- Ref typing
### Best Practices
- Type safety patterns
- Error handling
- Code organization
- Integration with Zod for runtime validation
- Named return variables (Go-style)
- Discriminated unions for state management
## Integration with Project Stack
This skill is designed to work seamlessly with:
- **React 19**: Type-safe component development
- **TanStack Ecosystem**: Typed queries, routing, forms, and stores
- **Zod**: Runtime validation with type inference
- **Radix UI**: Component prop typing
- **Tailwind CSS**: Type-safe className composition
## Examples
All examples are self-contained and demonstrate practical patterns:
- Based on real-world usage
- Follow project best practices
- Include comprehensive comments
- Can be run with `ts-node`
- Ready to adapt to your needs
## Configuration
The skill includes guidance on TypeScript configuration with recommended settings for:
- Strict type checking
- Module resolution
- JSX support
- Path aliases
- Declaration files
## Contributing
When adding new patterns or examples:
1. Follow existing file structure
2. Include comprehensive comments
3. Demonstrate real-world usage
4. Add to appropriate reference file
5. Update this README if needed
## Resources
- [TypeScript Handbook](https://www.typescriptlang.org/docs/handbook/)
- [TypeScript Deep Dive](https://basarat.gitbook.io/typescript/)
- [Type Challenges](https://github.com/type-challenges/type-challenges)
- [TSConfig Reference](https://www.typescriptlang.org/tsconfig)

View File

@@ -0,0 +1,359 @@
---
name: typescript
description: This skill should be used when working with TypeScript code, including type definitions, type inference, generics, utility types, and TypeScript configuration. Provides comprehensive knowledge of TypeScript patterns, best practices, and advanced type system features.
---
# TypeScript Skill
This skill provides comprehensive knowledge and patterns for working with TypeScript effectively in modern applications.
## When to Use This Skill
Use this skill when:
- Writing or refactoring TypeScript code
- Designing type-safe APIs and interfaces
- Working with advanced type system features (generics, conditional types, mapped types)
- Configuring TypeScript projects (tsconfig.json)
- Troubleshooting type errors
- Implementing type-safe patterns with libraries (React, TanStack, etc.)
- Converting JavaScript code to TypeScript
## Core Concepts
### Type System Fundamentals
TypeScript provides static typing for JavaScript with a powerful type system that includes:
- Primitive types (string, number, boolean, null, undefined, symbol, bigint)
- Object types (interfaces, type aliases, classes)
- Array and tuple types
- Union and intersection types
- Literal types and template literal types
- Type inference and type narrowing
- Generic types with constraints
- Conditional types and mapped types
### Type Inference
Leverage TypeScript's type inference to write less verbose code:
- Let TypeScript infer return types when obvious
- Use type inference for variable declarations
- Rely on generic type inference in function calls
- Use `as const` for immutable literal types
### Type Safety Patterns
Implement type-safe patterns:
- Use discriminated unions for state management
- Implement type guards for runtime type checking
- Use branded types for nominal typing
- Leverage conditional types for API design
- Use template literal types for string manipulation
## Key Workflows
### 1. Designing Type-Safe APIs
When designing APIs, follow these patterns:
**Interface vs Type Alias:**
- Use `interface` for object shapes that may be extended
- Use `type` for unions, intersections, and complex type operations
- Use `type` with mapped types and conditional types
**Generic Constraints:**
```typescript
// Use extends for generic constraints
function getValue<T extends { id: string }>(item: T): string {
return item.id
}
```
**Discriminated Unions:**
```typescript
// Use for type-safe state machines
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: Data }
| { status: 'error'; error: Error }
```
### 2. Working with Utility Types
Use built-in utility types for common transformations:
- `Partial<T>` - Make all properties optional
- `Required<T>` - Make all properties required
- `Readonly<T>` - Make all properties readonly
- `Pick<T, K>` - Select specific properties
- `Omit<T, K>` - Exclude specific properties
- `Record<K, T>` - Create object type with specific keys
- `Exclude<T, U>` - Exclude types from union
- `Extract<T, U>` - Extract types from union
- `NonNullable<T>` - Remove null/undefined
- `ReturnType<T>` - Get function return type
- `Parameters<T>` - Get function parameter types
- `Awaited<T>` - Unwrap Promise type
### 3. Advanced Type Patterns
**Mapped Types:**
```typescript
// Transform object types
type Nullable<T> = {
[K in keyof T]: T[K] | null
}
type ReadonlyDeep<T> = {
readonly [K in keyof T]: T[K] extends object
? ReadonlyDeep<T[K]>
: T[K]
}
```
**Conditional Types:**
```typescript
// Type-level logic
type IsArray<T> = T extends Array<any> ? true : false
type Flatten<T> = T extends Array<infer U> ? U : T
```
**Template Literal Types:**
```typescript
// String manipulation at type level
type EventName<T extends string> = `on${Capitalize<T>}`
type Route = `/api/${'users' | 'posts'}/${string}`
```
### 4. Type Narrowing
Use type guards and narrowing techniques:
**typeof guards:**
```typescript
if (typeof value === 'string') {
// value is string here
}
```
**instanceof guards:**
```typescript
if (error instanceof Error) {
// error is Error here
}
```
**Custom type guards:**
```typescript
function isUser(value: unknown): value is User {
return typeof value === 'object' && value !== null && 'id' in value
}
```
**Discriminated unions:**
```typescript
function handle(state: State) {
switch (state.status) {
case 'idle':
// state is { status: 'idle' }
break
case 'success':
// state is { status: 'success'; data: Data }
console.log(state.data)
break
}
}
```
### 5. Working with External Libraries
**Typing Third-Party Libraries:**
- Install type definitions: `npm install --save-dev @types/package-name`
- Create custom declarations in `.d.ts` files when types unavailable
- Use module augmentation to extend existing type definitions
**Declaration Files:**
```typescript
// globals.d.ts
declare global {
interface Window {
myCustomProperty: string
}
}
export {}
```
### 6. TypeScript Configuration
Configure `tsconfig.json` for strict type checking:
**Essential Strict Options:**
```json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true
}
}
```
## Best Practices
### 1. Prefer Type Inference Over Explicit Types
Let TypeScript infer types when they're obvious from context.
### 2. Use Strict Mode
Enable strict type checking to catch more errors at compile time.
### 3. Avoid `any` Type
Use `unknown` for truly unknown types, then narrow with type guards.
### 4. Use Const Assertions
Use `as const` for immutable values and narrow literal types.
### 5. Leverage Discriminated Unions
Use for state machines and variant types for better type safety.
### 6. Create Reusable Generic Types
Extract common type patterns into reusable generics.
### 7. Use Branded Types for Nominal Typing
Create distinct types for values with same structure but different meaning.
### 8. Document Complex Types
Add JSDoc comments to explain non-obvious type decisions.
### 9. Use Type-Only Imports
Use `import type` for type-only imports to aid tree-shaking.
### 10. Handle Errors with Type Guards
Use type guards to safely work with error objects.
## Common Patterns
### React Component Props
```typescript
// Use interface for component props
interface ButtonProps {
variant?: 'primary' | 'secondary'
size?: 'sm' | 'md' | 'lg'
onClick?: () => void
children: React.ReactNode
}
export function Button({ variant = 'primary', size = 'md', onClick, children }: ButtonProps) {
// implementation
}
```
### API Response Types
```typescript
// Use discriminated unions for API responses
type ApiResponse<T> =
| { success: true; data: T }
| { success: false; error: string }
// Helper for safe API calls
async function fetchData<T>(url: string): Promise<ApiResponse<T>> {
try {
const response = await fetch(url)
const data = await response.json()
return { success: true, data }
} catch (error) {
return { success: false, error: String(error) }
}
}
```
### Store/State Types
```typescript
// Use interfaces for state objects
interface AppState {
user: User | null
isAuthenticated: boolean
theme: 'light' | 'dark'
}
// Use type for actions (discriminated union)
type AppAction =
| { type: 'LOGIN'; payload: User }
| { type: 'LOGOUT' }
| { type: 'SET_THEME'; payload: 'light' | 'dark' }
```
## References
For detailed information on specific topics, refer to:
- `references/type-system.md` - Deep dive into TypeScript's type system
- `references/utility-types.md` - Complete guide to built-in utility types
- `references/advanced-types.md` - Advanced type patterns and techniques
- `references/tsconfig-reference.md` - Comprehensive tsconfig.json reference
- `references/common-patterns.md` - Common TypeScript patterns and idioms
- `examples/` - Practical code examples
## Troubleshooting
### Common Type Errors
**Type 'X' is not assignable to type 'Y':**
- Check if types are compatible
- Use type assertions when you know better than the compiler
- Consider using union types or widening the target type
**Object is possibly 'null' or 'undefined':**
- Use optional chaining: `object?.property`
- Use nullish coalescing: `value ?? defaultValue`
- Add type guards or null checks
**Type 'any' implicitly has...**
- Enable strict mode and fix type definitions
- Add explicit type annotations
- Use `unknown` instead of `any` when appropriate
**Cannot find module or its type declarations:**
- Install type definitions: `@types/package-name`
- Create custom `.d.ts` declaration file
- Add to `types` array in tsconfig.json
## Integration with Project Stack
### React 19
Use TypeScript with React 19 features:
- Type component props with interfaces
- Use generic types for hooks
- Type context providers properly
- Use `React.FC` sparingly (prefer explicit typing)
### TanStack Ecosystem
Type TanStack libraries properly:
- TanStack Query: Type query keys and data
- TanStack Router: Use typed route definitions
- TanStack Form: Type form values and validation
- TanStack Store: Type state and actions
### Zod Integration
Combine Zod with TypeScript:
- Use `z.infer<typeof schema>` to extract types from schemas
- Let Zod handle runtime validation
- Use TypeScript for compile-time type checking
## Resources
The TypeScript documentation provides comprehensive information:
- Handbook: https://www.typescriptlang.org/docs/handbook/
- Type manipulation: https://www.typescriptlang.org/docs/handbook/2/types-from-types.html
- Utility types: https://www.typescriptlang.org/docs/handbook/utility-types.html
- TSConfig reference: https://www.typescriptlang.org/tsconfig

View File

@@ -0,0 +1,45 @@
# TypeScript Examples
This directory contains practical TypeScript examples demonstrating various patterns and features.
## Examples
1. **type-system-basics.ts** - Fundamental TypeScript types and features
2. **advanced-types.ts** - Generics, conditional types, and mapped types
3. **react-patterns.ts** - Type-safe React components and hooks
4. **api-patterns.ts** - API response handling with type safety
5. **validation.ts** - Runtime validation with Zod and TypeScript
## How to Use
Each example file is self-contained and demonstrates specific TypeScript concepts. They're based on real-world patterns used in the Plebeian Market application and follow best practices for:
- Type safety
- Error handling
- Code organization
- Reusability
- Maintainability
## Running Examples
These examples are TypeScript files that can be:
- Copied into your project
- Used as reference for patterns
- Modified for your specific needs
- Run with `ts-node` for testing
```bash
# Run an example
npx ts-node examples/type-system-basics.ts
```
## Learning Path
1. Start with `type-system-basics.ts` to understand fundamentals
2. Move to `advanced-types.ts` for complex type patterns
3. Explore `react-patterns.ts` for component typing
4. Study `api-patterns.ts` for type-safe API handling
5. Review `validation.ts` for runtime safety
Each example builds on previous concepts, so following this order is recommended for learners.

View File

@@ -0,0 +1,478 @@
/**
* Advanced TypeScript Types
*
* This file demonstrates advanced TypeScript features including:
* - Generics with constraints
* - Conditional types
* - Mapped types
* - Template literal types
* - Recursive types
* - Utility type implementations
*/
// ============================================================================
// Generics Basics
// ============================================================================
// Generic function
function identity<T>(value: T): T {
return value
}
const stringValue = identity('hello') // Type: string
const numberValue = identity(42) // Type: number
// Generic interface
interface Box<T> {
value: T
}
const stringBox: Box<string> = { value: 'hello' }
const numberBox: Box<number> = { value: 42 }
// Generic class
class Stack<T> {
private items: T[] = []
push(item: T): void {
this.items.push(item)
}
pop(): T | undefined {
return this.items.pop()
}
peek(): T | undefined {
return this.items[this.items.length - 1]
}
isEmpty(): boolean {
return this.items.length === 0
}
}
const numberStack = new Stack<number>()
numberStack.push(1)
numberStack.push(2)
numberStack.pop() // Type: number | undefined
// ============================================================================
// Generic Constraints
// ============================================================================
// Constrain to specific type
interface HasLength {
length: number
}
function logLength<T extends HasLength>(item: T): void {
console.log(item.length)
}
logLength('string') // OK
logLength([1, 2, 3]) // OK
logLength({ length: 10 }) // OK
// logLength(42) // Error: number doesn't have length
// Constrain to object keys
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
interface User {
id: string
name: string
age: number
}
const user: User = { id: '1', name: 'Alice', age: 30 }
const userName = getProperty(user, 'name') // Type: string
// const invalid = getProperty(user, 'invalid') // Error
// Multiple type parameters with constraints
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 }
}
const merged = merge({ a: 1 }, { b: 2 }) // Type: { a: number } & { b: number }
// ============================================================================
// Conditional Types
// ============================================================================
// Basic conditional type
type IsString<T> = T extends string ? true : false
type A = IsString<string> // true
type B = IsString<number> // false
// Nested conditional types
type TypeName<T> = T extends string
? 'string'
: T extends number
? 'number'
: T extends boolean
? 'boolean'
: T extends undefined
? 'undefined'
: T extends Function
? 'function'
: 'object'
type T1 = TypeName<string> // "string"
type T2 = TypeName<number> // "number"
type T3 = TypeName<() => void> // "function"
// Distributive conditional types
type ToArray<T> = T extends any ? T[] : never
type StrArrOrNumArr = ToArray<string | number> // string[] | number[]
// infer keyword
type Flatten<T> = T extends Array<infer U> ? U : T
type Str = Flatten<string[]> // string
type Num = Flatten<number> // number
// Return type extraction
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never
function exampleFn(): string {
return 'hello'
}
type ExampleReturn = MyReturnType<typeof exampleFn> // string
// Parameters extraction
type MyParameters<T> = T extends (...args: infer P) => any ? P : never
function createUser(name: string, age: number): User {
return { id: '1', name, age }
}
type CreateUserParams = MyParameters<typeof createUser> // [string, number]
// ============================================================================
// Mapped Types
// ============================================================================
// Make all properties optional
type MyPartial<T> = {
[K in keyof T]?: T[K]
}
interface Person {
name: string
age: number
email: string
}
type PartialPerson = MyPartial<Person>
// {
// name?: string
// age?: number
// email?: string
// }
// Make all properties required
type MyRequired<T> = {
[K in keyof T]-?: T[K]
}
// Make all properties readonly
type MyReadonly<T> = {
readonly [K in keyof T]: T[K]
}
// Pick specific properties
type MyPick<T, K extends keyof T> = {
[P in K]: T[P]
}
type UserProfile = MyPick<User, 'id' | 'name'>
// { id: string; name: string }
// Omit specific properties
type MyOmit<T, K extends keyof T> = {
[P in keyof T as P extends K ? never : P]: T[P]
}
type UserWithoutAge = MyOmit<User, 'age'>
// { id: string; name: string }
// Transform property types
type Nullable<T> = {
[K in keyof T]: T[K] | null
}
type NullablePerson = Nullable<Person>
// {
// name: string | null
// age: number | null
// email: string | null
// }
// ============================================================================
// Key Remapping
// ============================================================================
// Add prefix to keys
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
type PersonGetters = Getters<Person>
// {
// getName: () => string
// getAge: () => number
// getEmail: () => string
// }
// Filter keys by type
type PickByType<T, U> = {
[K in keyof T as T[K] extends U ? K : never]: T[K]
}
interface Model {
id: number
name: string
description: string
price: number
}
type StringFields = PickByType<Model, string>
// { name: string; description: string }
// Remove specific key
type RemoveKindField<T> = {
[K in keyof T as Exclude<K, 'kind'>]: T[K]
}
// ============================================================================
// Template Literal Types
// ============================================================================
// Event name generation
type EventName<T extends string> = `on${Capitalize<T>}`
type ClickEvent = EventName<'click'> // "onClick"
type SubmitEvent = EventName<'submit'> // "onSubmit"
// Combining literals
type Color = 'red' | 'green' | 'blue'
type Shade = 'light' | 'dark'
type ColorShade = `${Shade}-${Color}`
// "light-red" | "light-green" | "light-blue" | "dark-red" | "dark-green" | "dark-blue"
// CSS properties
type CSSProperty = 'margin' | 'padding'
type Side = 'top' | 'right' | 'bottom' | 'left'
type CSSPropertyWithSide = `${CSSProperty}-${Side}`
// "margin-top" | "margin-right" | ... | "padding-left"
// Route generation
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type Endpoint = '/users' | '/products' | '/orders'
type ApiRoute = `${HttpMethod} ${Endpoint}`
// "GET /users" | "POST /users" | ... | "DELETE /orders"
// ============================================================================
// Recursive Types
// ============================================================================
// JSON value type
type JSONValue = string | number | boolean | null | JSONObject | JSONArray
interface JSONObject {
[key: string]: JSONValue
}
interface JSONArray extends Array<JSONValue> {}
// Tree structure
interface TreeNode<T> {
value: T
children?: TreeNode<T>[]
}
const tree: TreeNode<number> = {
value: 1,
children: [
{ value: 2, children: [{ value: 4 }, { value: 5 }] },
{ value: 3, children: [{ value: 6 }] },
],
}
// Deep readonly
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]
}
interface NestedConfig {
api: {
url: string
timeout: number
}
features: {
darkMode: boolean
}
}
type ImmutableConfig = DeepReadonly<NestedConfig>
// All properties at all levels are readonly
// Deep partial
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
}
// ============================================================================
// Advanced Utility Types
// ============================================================================
// Exclude types from union
type MyExclude<T, U> = T extends U ? never : T
type T4 = MyExclude<'a' | 'b' | 'c', 'a'> // "b" | "c"
// Extract types from union
type MyExtract<T, U> = T extends U ? T : never
type T5 = MyExtract<'a' | 'b' | 'c', 'a' | 'f'> // "a"
// NonNullable
type MyNonNullable<T> = T extends null | undefined ? never : T
type T6 = MyNonNullable<string | null | undefined> // string
// Record
type MyRecord<K extends keyof any, T> = {
[P in K]: T
}
type PageInfo = MyRecord<string, number>
// Awaited
type MyAwaited<T> = T extends Promise<infer U> ? MyAwaited<U> : T
type T7 = MyAwaited<Promise<string>> // string
type T8 = MyAwaited<Promise<Promise<number>>> // number
// ============================================================================
// Branded Types
// ============================================================================
type Brand<K, T> = K & { __brand: T }
type USD = Brand<number, 'USD'>
type EUR = Brand<number, 'EUR'>
type UserId = Brand<string, 'UserId'>
type ProductId = Brand<string, 'ProductId'>
function makeUSD(amount: number): USD {
return amount as USD
}
function makeUserId(id: string): UserId {
return id as UserId
}
const usd = makeUSD(100)
const userId = makeUserId('user-123')
// Type-safe operations
function addMoney(a: USD, b: USD): USD {
return (a + b) as USD
}
// Prevents mixing different branded types
// const total = addMoney(usd, eur) // Error
// ============================================================================
// Union to Intersection
// ============================================================================
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
k: infer I,
) => void
? I
: never
type Union = { a: string } | { b: number }
type Intersection = UnionToIntersection<Union>
// { a: string } & { b: number }
// ============================================================================
// Advanced Generic Patterns
// ============================================================================
// Constraining multiple related types
function merge<
T extends Record<string, any>,
U extends Record<string, any>,
K extends keyof T & keyof U,
>(obj1: T, obj2: U, conflictKeys: K[]): T & U {
const result = { ...obj1, ...obj2 }
conflictKeys.forEach((key) => {
// Handle conflicts
})
return result as T & U
}
// Builder pattern with fluent API
class QueryBuilder<T, Selected extends keyof T = never> {
private selectFields: Set<keyof T> = new Set()
select<K extends keyof T>(
...fields: K[]
): QueryBuilder<T, Selected | K> {
fields.forEach((field) => this.selectFields.add(field))
return this as any
}
execute(): Pick<T, Selected> {
// Execute query
return {} as Pick<T, Selected>
}
}
// Usage
interface Product {
id: string
name: string
price: number
description: string
}
const result = new QueryBuilder<Product>()
.select('id', 'name')
.select('price')
.execute()
// Type: { id: string; name: string; price: number }
// ============================================================================
// Exports
// ============================================================================
export type {
Box,
HasLength,
IsString,
Flatten,
MyPartial,
MyRequired,
MyReadonly,
Nullable,
DeepReadonly,
DeepPartial,
Brand,
USD,
EUR,
UserId,
ProductId,
JSONValue,
TreeNode,
}
export { Stack, identity, getProperty, merge, makeUSD, makeUserId }

View File

@@ -0,0 +1,555 @@
/**
* TypeScript React Patterns
*
* This file demonstrates type-safe React patterns including:
* - Component props typing
* - Hooks with TypeScript
* - Context with type safety
* - Generic components
* - Event handlers
* - Ref types
*/
import { createContext, useContext, useEffect, useReducer, useRef, useState } from 'react'
import type { ReactNode, InputHTMLAttributes, FormEvent, ChangeEvent } from 'react'
// ============================================================================
// Component Props Patterns
// ============================================================================
// Basic component with props
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'tertiary'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
onClick?: () => void
children: ReactNode
}
export function Button({
variant = 'primary',
size = 'md',
disabled = false,
onClick,
children,
}: ButtonProps) {
return (
<button
className={`btn-${variant} btn-${size}`}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
)
}
// Props extending HTML attributes
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
helperText?: string
}
export function Input({ label, error, helperText, ...inputProps }: InputProps) {
return (
<div className="input-wrapper">
{label && <label>{label}</label>}
<input className={error ? 'input-error' : ''} {...inputProps} />
{error && <span className="error">{error}</span>}
{helperText && <span className="helper">{helperText}</span>}
</div>
)
}
// Generic component
interface ListProps<T> {
items: T[]
renderItem: (item: T, index: number) => ReactNode
keyExtractor: (item: T, index: number) => string
emptyMessage?: string
}
export function List<T>({
items,
renderItem,
keyExtractor,
emptyMessage = 'No items',
}: ListProps<T>) {
if (items.length === 0) {
return <div>{emptyMessage}</div>
}
return (
<ul>
{items.map((item, index) => (
<li key={keyExtractor(item, index)}>{renderItem(item, index)}</li>
))}
</ul>
)
}
// Component with children render prop
interface ContainerProps {
isLoading: boolean
error: Error | null
children: (props: { retry: () => void }) => ReactNode
}
export function Container({ isLoading, error, children }: ContainerProps) {
const retry = () => {
// Retry logic
}
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return <>{children({ retry })}</>
}
// ============================================================================
// Hooks Patterns
// ============================================================================
// useState with explicit type
function useCounter(initialValue: number = 0) {
const [count, setCount] = useState<number>(initialValue)
const increment = () => setCount((c) => c + 1)
const decrement = () => setCount((c) => c - 1)
const reset = () => setCount(initialValue)
return { count, increment, decrement, reset }
}
// useState with union type
type LoadingState = 'idle' | 'loading' | 'success' | 'error'
function useLoadingState() {
const [state, setState] = useState<LoadingState>('idle')
const startLoading = () => setState('loading')
const setSuccess = () => setState('success')
const setError = () => setState('error')
const reset = () => setState('idle')
return { state, startLoading, setSuccess, setError, reset }
}
// Custom hook with options
interface UseFetchOptions<T> {
initialData?: T
onSuccess?: (data: T) => void
onError?: (error: Error) => void
}
interface UseFetchReturn<T> {
data: T | undefined
loading: boolean
error: Error | null
refetch: () => Promise<void>
}
function useFetch<T>(url: string, options?: UseFetchOptions<T>): UseFetchReturn<T> {
const [data, setData] = useState<T | undefined>(options?.initialData)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const fetchData = async () => {
setLoading(true)
setError(null)
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const json = await response.json()
setData(json)
options?.onSuccess?.(json)
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err))
setError(error)
options?.onError?.(error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchData()
}, [url])
return { data, loading, error, refetch: fetchData }
}
// useReducer with discriminated unions
interface User {
id: string
name: string
email: string
}
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
type FetchAction<T> =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: T }
| { type: 'FETCH_ERROR'; error: Error }
| { type: 'RESET' }
function fetchReducer<T>(state: FetchState<T>, action: FetchAction<T>): FetchState<T> {
switch (action.type) {
case 'FETCH_START':
return { status: 'loading' }
case 'FETCH_SUCCESS':
return { status: 'success', data: action.payload }
case 'FETCH_ERROR':
return { status: 'error', error: action.error }
case 'RESET':
return { status: 'idle' }
}
}
function useFetchWithReducer<T>(url: string) {
const [state, dispatch] = useReducer(fetchReducer<T>, { status: 'idle' })
useEffect(() => {
let isCancelled = false
const fetchData = async () => {
dispatch({ type: 'FETCH_START' })
try {
const response = await fetch(url)
const data = await response.json()
if (!isCancelled) {
dispatch({ type: 'FETCH_SUCCESS', payload: data })
}
} catch (error) {
if (!isCancelled) {
dispatch({
type: 'FETCH_ERROR',
error: error instanceof Error ? error : new Error(String(error)),
})
}
}
}
fetchData()
return () => {
isCancelled = true
}
}, [url])
return state
}
// ============================================================================
// Context Patterns
// ============================================================================
// Type-safe context
interface AuthContextType {
user: User | null
isAuthenticated: boolean
login: (email: string, password: string) => Promise<void>
logout: () => void
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const login = async (email: string, password: string) => {
// Login logic
const userData = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
}).then((r) => r.json())
setUser(userData)
}
const logout = () => {
setUser(null)
}
const value: AuthContextType = {
user,
isAuthenticated: user !== null,
login,
logout,
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
// Custom hook with error handling
export function useAuth(): AuthContextType {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}
// ============================================================================
// Event Handler Patterns
// ============================================================================
interface FormData {
name: string
email: string
message: string
}
function ContactForm() {
const [formData, setFormData] = useState<FormData>({
name: '',
email: '',
message: '',
})
// Type-safe change handler
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setFormData((prev) => ({
...prev,
[name]: value,
}))
}
// Type-safe submit handler
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
console.log('Submitting:', formData)
}
// Specific field handler
const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({ ...prev, name: e.target.value }))
}
return (
<form onSubmit={handleSubmit}>
<input
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Name"
/>
<input
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Email"
/>
<textarea
name="message"
value={formData.message}
onChange={handleChange}
placeholder="Message"
/>
<button type="submit">Submit</button>
</form>
)
}
// ============================================================================
// Ref Patterns
// ============================================================================
function FocusInput() {
// useRef with DOM element
const inputRef = useRef<HTMLInputElement>(null)
const focusInput = () => {
inputRef.current?.focus()
}
return (
<div>
<input ref={inputRef} />
<button onClick={focusInput}>Focus Input</button>
</div>
)
}
function Timer() {
// useRef for mutable value
const countRef = useRef<number>(0)
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const startTimer = () => {
intervalRef.current = setInterval(() => {
countRef.current += 1
console.log(countRef.current)
}, 1000)
}
const stopTimer = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
return (
<div>
<button onClick={startTimer}>Start</button>
<button onClick={stopTimer}>Stop</button>
</div>
)
}
// ============================================================================
// Generic Component Patterns
// ============================================================================
// Select component with generic options
interface SelectProps<T> {
options: T[]
value: T
onChange: (value: T) => void
getLabel: (option: T) => string
getValue: (option: T) => string
}
export function Select<T>({
options,
value,
onChange,
getLabel,
getValue,
}: SelectProps<T>) {
return (
<select
value={getValue(value)}
onChange={(e) => {
const selectedValue = e.target.value
const option = options.find((opt) => getValue(opt) === selectedValue)
if (option) {
onChange(option)
}
}}
>
{options.map((option) => (
<option key={getValue(option)} value={getValue(option)}>
{getLabel(option)}
</option>
))}
</select>
)
}
// Data table component
interface Column<T> {
key: keyof T
header: string
render?: (value: T[keyof T], row: T) => ReactNode
}
interface TableProps<T> {
data: T[]
columns: Column<T>[]
keyExtractor: (row: T) => string
}
export function Table<T>({ data, columns, keyExtractor }: TableProps<T>) {
return (
<table>
<thead>
<tr>
{columns.map((col) => (
<th key={String(col.key)}>{col.header}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={keyExtractor(row)}>
{columns.map((col) => (
<td key={String(col.key)}>
{col.render ? col.render(row[col.key], row) : String(row[col.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}
// ============================================================================
// Higher-Order Component Pattern
// ============================================================================
interface WithLoadingProps {
isLoading: boolean
}
function withLoading<P extends object>(
Component: React.ComponentType<P>,
): React.FC<P & WithLoadingProps> {
return ({ isLoading, ...props }: WithLoadingProps & P) => {
if (isLoading) {
return <div>Loading...</div>
}
return <Component {...(props as P)} />
}
}
// Usage
interface UserListProps {
users: User[]
}
const UserList: React.FC<UserListProps> = ({ users }) => (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
const UserListWithLoading = withLoading(UserList)
// ============================================================================
// Exports
// ============================================================================
export {
useCounter,
useLoadingState,
useFetch,
useFetchWithReducer,
ContactForm,
FocusInput,
Timer,
}
export type {
ButtonProps,
InputProps,
ListProps,
UseFetchOptions,
UseFetchReturn,
FetchState,
FetchAction,
AuthContextType,
SelectProps,
Column,
TableProps,
}

View File

@@ -0,0 +1,361 @@
/**
* TypeScript Type System Basics
*
* This file demonstrates fundamental TypeScript concepts including:
* - Primitive types
* - Object types (interfaces, type aliases)
* - Union and intersection types
* - Type inference and narrowing
* - Function types
*/
// ============================================================================
// Primitive Types
// ============================================================================
const message: string = 'Hello, TypeScript!'
const count: number = 42
const isActive: boolean = true
const nothing: null = null
const notDefined: undefined = undefined
// ============================================================================
// Object Types
// ============================================================================
// Interface definition
interface User {
id: string
name: string
email: string
age?: number // Optional property
readonly createdAt: Date // Readonly property
}
// Type alias definition
type Product = {
id: string
name: string
price: number
category: string
}
// Creating objects
const user: User = {
id: '1',
name: 'Alice',
email: 'alice@example.com',
createdAt: new Date(),
}
const product: Product = {
id: 'p1',
name: 'Laptop',
price: 999,
category: 'electronics',
}
// ============================================================================
// Union Types
// ============================================================================
type Status = 'idle' | 'loading' | 'success' | 'error'
type ID = string | number
function formatId(id: ID): string {
if (typeof id === 'string') {
return id.toUpperCase()
}
return id.toString()
}
// Discriminated unions
type ApiResponse =
| { success: true; data: User }
| { success: false; error: string }
function handleResponse(response: ApiResponse) {
if (response.success) {
// TypeScript knows response.data exists here
console.log(response.data.name)
} else {
// TypeScript knows response.error exists here
console.error(response.error)
}
}
// ============================================================================
// Intersection Types
// ============================================================================
type Timestamped = {
createdAt: Date
updatedAt: Date
}
type TimestampedUser = User & Timestamped
const timestampedUser: TimestampedUser = {
id: '1',
name: 'Bob',
email: 'bob@example.com',
createdAt: new Date(),
updatedAt: new Date(),
}
// ============================================================================
// Array Types
// ============================================================================
const numbers: number[] = [1, 2, 3, 4, 5]
const strings: Array<string> = ['a', 'b', 'c']
const users: User[] = [user, timestampedUser]
// Readonly arrays
const immutableNumbers: readonly number[] = [1, 2, 3]
// immutableNumbers.push(4) // Error: push does not exist on readonly array
// ============================================================================
// Tuple Types
// ============================================================================
type Point = [number, number]
type NamedPoint = [x: number, y: number, z?: number]
const point: Point = [10, 20]
const namedPoint: NamedPoint = [10, 20, 30]
// ============================================================================
// Function Types
// ============================================================================
// Function declaration
function add(a: number, b: number): number {
return a + b
}
// Arrow function
const subtract = (a: number, b: number): number => a - b
// Function type alias
type MathOperation = (a: number, b: number) => number
const multiply: MathOperation = (a, b) => a * b
// Optional parameters
function greet(name: string, greeting?: string): string {
return `${greeting ?? 'Hello'}, ${name}!`
}
// Default parameters
function createUser(name: string, role: string = 'user'): User {
return {
id: Math.random().toString(),
name,
email: `${name.toLowerCase()}@example.com`,
createdAt: new Date(),
}
}
// Rest parameters
function sum(...numbers: number[]): number {
return numbers.reduce((acc, n) => acc + n, 0)
}
// ============================================================================
// Type Inference
// ============================================================================
// Type is inferred as string
let inferredString = 'hello'
// Type is inferred as number
let inferredNumber = 42
// Type is inferred as { name: string; age: number }
let inferredObject = {
name: 'Alice',
age: 30,
}
// Return type is inferred as number
function inferredReturn(a: number, b: number) {
return a + b
}
// ============================================================================
// Type Narrowing
// ============================================================================
// typeof guard
function processValue(value: string | number) {
if (typeof value === 'string') {
// value is string here
return value.toUpperCase()
}
// value is number here
return value.toFixed(2)
}
// Truthiness narrowing
function printName(name: string | null | undefined) {
if (name) {
// name is string here
console.log(name.toUpperCase())
}
}
// Equality narrowing
function example(x: string | number, y: string | boolean) {
if (x === y) {
// x and y are both string here
console.log(x.toUpperCase(), y.toLowerCase())
}
}
// in operator narrowing
type Fish = { swim: () => void }
type Bird = { fly: () => void }
function move(animal: Fish | Bird) {
if ('swim' in animal) {
// animal is Fish here
animal.swim()
} else {
// animal is Bird here
animal.fly()
}
}
// instanceof narrowing
function processError(error: Error | string) {
if (error instanceof Error) {
// error is Error here
console.error(error.message)
} else {
// error is string here
console.error(error)
}
}
// ============================================================================
// Type Predicates (Custom Type Guards)
// ============================================================================
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
'email' in value
)
}
function processData(data: unknown) {
if (isUser(data)) {
// data is User here
console.log(data.name)
}
}
// ============================================================================
// Const Assertions
// ============================================================================
// Without const assertion
const mutableConfig = {
host: 'localhost',
port: 8080,
}
// mutableConfig.host = 'example.com' // OK
// With const assertion
const immutableConfig = {
host: 'localhost',
port: 8080,
} as const
// immutableConfig.host = 'example.com' // Error: cannot assign to readonly property
// Array with const assertion
const directions = ['north', 'south', 'east', 'west'] as const
// Type: readonly ["north", "south", "east", "west"]
// ============================================================================
// Literal Types
// ============================================================================
type Direction = 'north' | 'south' | 'east' | 'west'
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type DiceValue = 1 | 2 | 3 | 4 | 5 | 6
function move(direction: Direction, steps: number) {
console.log(`Moving ${direction} by ${steps} steps`)
}
move('north', 10) // OK
// move('up', 10) // Error: "up" is not assignable to Direction
// ============================================================================
// Index Signatures
// ============================================================================
interface StringMap {
[key: string]: string
}
const translations: StringMap = {
hello: 'Hola',
goodbye: 'Adiós',
thanks: 'Gracias',
}
// ============================================================================
// Utility Functions
// ============================================================================
// Type-safe object keys
function getObjectKeys<T extends object>(obj: T): Array<keyof T> {
return Object.keys(obj) as Array<keyof T>
}
// Type-safe property access
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
const userName = getProperty(user, 'name') // Type: string
const userAge = getProperty(user, 'age') // Type: number | undefined
// ============================================================================
// Named Return Values (Go-style)
// ============================================================================
function parseJSON(json: string): { data: unknown | null; err: Error | null } {
let data: unknown | null = null
let err: Error | null = null
try {
data = JSON.parse(json)
} catch (error) {
err = error instanceof Error ? error : new Error(String(error))
}
return { data, err }
}
// Usage
const { data, err } = parseJSON('{"name": "Alice"}')
if (err) {
console.error('Failed to parse JSON:', err.message)
} else {
console.log('Parsed data:', data)
}
// ============================================================================
// Exports
// ============================================================================
export type { User, Product, Status, ID, ApiResponse, TimestampedUser }
export { formatId, handleResponse, processValue, isUser, getProperty, parseJSON }

View File

@@ -0,0 +1,395 @@
# TypeScript Quick Reference
Quick lookup guide for common TypeScript patterns and syntax.
## Basic Types
```typescript
// Primitives
string, number, boolean, null, undefined, symbol, bigint
// Special types
any // Avoid - disables type checking
unknown // Type-safe alternative to any
void // No return value
never // Never returns
// Arrays
number[]
Array<string>
readonly number[]
// Tuples
[string, number]
[x: number, y: number]
// Objects
{ name: string; age: number }
Record<string, number>
```
## Type Declarations
```typescript
// Interface
interface User {
id: string
name: string
age?: number // Optional
readonly createdAt: Date // Readonly
}
// Type alias
type Status = 'idle' | 'loading' | 'success' | 'error'
type ID = string | number
type Point = { x: number; y: number }
// Function type
type Callback = (data: string) => void
type MathOp = (a: number, b: number) => number
```
## Union & Intersection
```typescript
// Union (OR)
string | number
type Result = Success | Error
// Intersection (AND)
A & B
type Combined = User & Timestamped
// Discriminated union
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: Data }
| { status: 'error'; error: Error }
```
## Generics
```typescript
// Generic function
function identity<T>(value: T): T
// Generic interface
interface Box<T> { value: T }
// Generic with constraint
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K]
// Multiple type parameters
function merge<T, U>(a: T, b: U): T & U
// Default type parameter
interface Response<T = unknown> { data: T }
```
## Utility Types
```typescript
Partial<T> // Make all optional
Required<T> // Make all required
Readonly<T> // Make all readonly
Pick<T, K> // Select properties
Omit<T, K> // Exclude properties
Record<K, T> // Object with specific keys
Exclude<T, U> // Remove from union
Extract<T, U> // Extract from union
NonNullable<T> // Remove null/undefined
ReturnType<T> // Get function return type
Parameters<T> // Get function parameters
Awaited<T> // Unwrap Promise
```
## Type Guards
```typescript
// typeof
if (typeof value === 'string') { }
// instanceof
if (error instanceof Error) { }
// in operator
if ('property' in object) { }
// Custom type guard
function isUser(value: unknown): value is User {
return typeof value === 'object' && value !== null && 'id' in value
}
// Assertion function
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') throw new Error()
}
```
## Advanced Types
```typescript
// Conditional types
type IsString<T> = T extends string ? true : false
// Mapped types
type Nullable<T> = { [K in keyof T]: T[K] | null }
// Template literal types
type EventName<T extends string> = `on${Capitalize<T>}`
// Key remapping
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
// infer keyword
type Flatten<T> = T extends Array<infer U> ? U : T
```
## Functions
```typescript
// Function declaration
function add(a: number, b: number): number { return a + b }
// Arrow function
const subtract = (a: number, b: number): number => a - b
// Optional parameters
function greet(name: string, greeting?: string): string { }
// Default parameters
function create(name: string, role = 'user'): User { }
// Rest parameters
function sum(...numbers: number[]): number { }
// Overloads
function format(value: string): string
function format(value: number): string
function format(value: string | number): string { }
```
## Classes
```typescript
class User {
// Properties
private id: string
public name: string
protected age: number
readonly createdAt: Date
// Constructor
constructor(name: string) {
this.name = name
this.createdAt = new Date()
}
// Methods
greet(): string {
return `Hello, ${this.name}`
}
// Static
static create(name: string): User {
return new User(name)
}
// Getters/Setters
get displayName(): string {
return this.name.toUpperCase()
}
}
// Inheritance
class Admin extends User {
constructor(name: string, public permissions: string[]) {
super(name)
}
}
// Abstract class
abstract class Animal {
abstract makeSound(): void
}
```
## React Patterns
```typescript
// Component props
interface ButtonProps {
variant?: 'primary' | 'secondary'
onClick?: () => void
children: React.ReactNode
}
export function Button({ variant = 'primary', onClick, children }: ButtonProps) { }
// Generic component
interface ListProps<T> {
items: T[]
renderItem: (item: T) => React.ReactNode
}
export function List<T>({ items, renderItem }: ListProps<T>) { }
// Hooks
const [state, setState] = useState<string>('')
const [data, setData] = useState<User | null>(null)
// Context
interface AuthContextType {
user: User | null
login: () => Promise<void>
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function useAuth(): AuthContextType {
const context = useContext(AuthContext)
if (!context) throw new Error('useAuth must be used within AuthProvider')
return context
}
```
## Common Patterns
### Result Type
```typescript
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E }
```
### Option Type
```typescript
type Option<T> = Some<T> | None
interface Some<T> { _tag: 'Some'; value: T }
interface None { _tag: 'None' }
```
### Branded Types
```typescript
type Brand<K, T> = K & { __brand: T }
type UserId = Brand<string, 'UserId'>
```
### Named Returns (Go-style)
```typescript
function parseJSON(json: string): { data: unknown | null; err: Error | null } {
let data: unknown | null = null
let err: Error | null = null
try {
data = JSON.parse(json)
} catch (error) {
err = error instanceof Error ? error : new Error(String(error))
}
return { data, err }
}
```
## Type Assertions
```typescript
// as syntax (preferred)
const value = input as string
// Angle bracket syntax (not in JSX)
const value = <string>input
// as const
const config = { host: 'localhost' } as const
// Non-null assertion (use sparingly)
const element = document.getElementById('app')!
```
## Type Narrowing
```typescript
// Control flow
if (value !== null) {
// value is non-null here
}
// Switch with discriminated unions
switch (state.status) {
case 'success':
console.log(state.data) // TypeScript knows data exists
break
case 'error':
console.log(state.error) // TypeScript knows error exists
break
}
// Optional chaining
user?.profile?.name
// Nullish coalescing
const name = user?.name ?? 'Anonymous'
```
## Module Syntax
```typescript
// Named exports
export function helper() { }
export const CONFIG = { }
// Default export
export default class App { }
// Type-only imports/exports
import type { User } from './types'
export type { User }
// Namespace imports
import * as utils from './utils'
```
## TSConfig Essentials
```json
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
}
}
```
## Common Errors & Fixes
| Error | Fix |
|-------|-----|
| Type 'X' is not assignable to type 'Y' | Check type compatibility, use type assertion if needed |
| Object is possibly 'null' | Use optional chaining `?.` or null check |
| Cannot find module | Install `@types/package-name` |
| Implicit any | Add type annotation or enable strict mode |
| Property does not exist | Check object shape, use type guard |
## Best Practices
1. Enable `strict` mode in tsconfig.json
2. Avoid `any`, use `unknown` instead
3. Use discriminated unions for state
4. Leverage type inference
5. Use `const` assertions for immutable data
6. Create custom type guards for runtime safety
7. Use utility types instead of recreating
8. Document complex types with JSDoc
9. Prefer interfaces for objects, types for unions
10. Use branded types for domain-specific primitives

View File

@@ -0,0 +1,756 @@
# TypeScript Common Patterns Reference
This document contains commonly used TypeScript patterns and idioms from real-world applications.
## React Patterns
### Component Props
```typescript
// Basic props with children
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'tertiary'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
onClick?: () => void
children: React.ReactNode
}
export function Button({
variant = 'primary',
size = 'md',
disabled = false,
onClick,
children,
}: ButtonProps) {
return (
<button className={`btn-${variant} btn-${size}`} disabled={disabled} onClick={onClick}>
{children}
</button>
)
}
// Props extending HTML attributes
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
}
export function Input({ label, error, ...inputProps }: InputProps) {
return (
<div>
{label && <label>{label}</label>}
<input {...inputProps} />
{error && <span>{error}</span>}
</div>
)
}
// Generic component props
interface ListProps<T> {
items: T[]
renderItem: (item: T) => React.ReactNode
keyExtractor: (item: T) => string
}
export function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
)
}
```
### Hooks
```typescript
// Custom hook with return type
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch (error) {
return initialValue
}
})
const setValue = (value: T) => {
setStoredValue(value)
window.localStorage.setItem(key, JSON.stringify(value))
}
return [storedValue, setValue]
}
// Hook with options object
interface UseFetchOptions<T> {
initialData?: T
onSuccess?: (data: T) => void
onError?: (error: Error) => void
}
function useFetch<T>(url: string, options?: UseFetchOptions<T>) {
const [data, setData] = useState<T | undefined>(options?.initialData)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let isCancelled = false
const fetchData = async () => {
setLoading(true)
try {
const response = await fetch(url)
const json = await response.json()
if (!isCancelled) {
setData(json)
options?.onSuccess?.(json)
}
} catch (err) {
if (!isCancelled) {
const error = err instanceof Error ? err : new Error(String(err))
setError(error)
options?.onError?.(error)
}
} finally {
if (!isCancelled) {
setLoading(false)
}
}
}
fetchData()
return () => {
isCancelled = true
}
}, [url])
return { data, loading, error }
}
```
### Context
```typescript
// Type-safe context
interface AuthContextType {
user: User | null
login: (email: string, password: string) => Promise<void>
logout: () => void
isAuthenticated: boolean
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const login = async (email: string, password: string) => {
// Login logic
const user = await api.login(email, password)
setUser(user)
}
const logout = () => {
setUser(null)
}
const value: AuthContextType = {
user,
login,
logout,
isAuthenticated: user !== null,
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
// Custom hook with proper error handling
export function useAuth(): AuthContextType {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}
```
## API Response Patterns
### Result Type Pattern
```typescript
// Discriminated union for API responses
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E }
// Helper functions
function success<T>(data: T): Result<T> {
return { success: true, data }
}
function failure<E = Error>(error: E): Result<never, E> {
return { success: false, error }
}
// Usage
async function fetchUser(id: string): Promise<Result<User>> {
try {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) {
return failure(new Error(`HTTP ${response.status}`))
}
const data = await response.json()
return success(data)
} catch (error) {
return failure(error instanceof Error ? error : new Error(String(error)))
}
}
// Consuming the result
const result = await fetchUser('123')
if (result.success) {
console.log(result.data.name) // Type-safe access
} else {
console.error(result.error.message) // Type-safe error handling
}
```
### Option Type Pattern
```typescript
// Option/Maybe type for nullable values
type Option<T> = Some<T> | None
interface Some<T> {
readonly _tag: 'Some'
readonly value: T
}
interface None {
readonly _tag: 'None'
}
// Constructors
function some<T>(value: T): Option<T> {
return { _tag: 'Some', value }
}
function none(): Option<never> {
return { _tag: 'None' }
}
// Helper functions
function isSome<T>(option: Option<T>): option is Some<T> {
return option._tag === 'Some'
}
function isNone<T>(option: Option<T>): option is None {
return option._tag === 'None'
}
function map<T, U>(option: Option<T>, fn: (value: T) => U): Option<U> {
return isSome(option) ? some(fn(option.value)) : none()
}
function getOrElse<T>(option: Option<T>, defaultValue: T): T {
return isSome(option) ? option.value : defaultValue
}
// Usage
function findUser(id: string): Option<User> {
const user = users.find((u) => u.id === id)
return user ? some(user) : none()
}
const user = findUser('123')
const userName = getOrElse(map(user, (u) => u.name), 'Unknown')
```
## State Management Patterns
### Discriminated Union for State
```typescript
// State machine using discriminated unions
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
// Reducer pattern
type FetchAction<T> =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: T }
| { type: 'FETCH_ERROR'; error: Error }
| { type: 'RESET' }
function fetchReducer<T>(state: FetchState<T>, action: FetchAction<T>): FetchState<T> {
switch (action.type) {
case 'FETCH_START':
return { status: 'loading' }
case 'FETCH_SUCCESS':
return { status: 'success', data: action.payload }
case 'FETCH_ERROR':
return { status: 'error', error: action.error }
case 'RESET':
return { status: 'idle' }
}
}
// Usage in component
function UserProfile({ userId }: { userId: string }) {
const [state, dispatch] = useReducer(fetchReducer<User>, { status: 'idle' })
useEffect(() => {
dispatch({ type: 'FETCH_START' })
fetchUser(userId)
.then((user) => dispatch({ type: 'FETCH_SUCCESS', payload: user }))
.catch((error) => dispatch({ type: 'FETCH_ERROR', error }))
}, [userId])
switch (state.status) {
case 'idle':
return <div>Ready to load</div>
case 'loading':
return <div>Loading...</div>
case 'success':
return <div>{state.data.name}</div>
case 'error':
return <div>Error: {state.error.message}</div>
}
}
```
### Store Pattern
```typescript
// Type-safe store implementation
interface Store<T> {
getState: () => T
setState: (partial: Partial<T>) => void
subscribe: (listener: (state: T) => void) => () => void
}
function createStore<T>(initialState: T): Store<T> {
let state = initialState
const listeners = new Set<(state: T) => void>()
return {
getState: () => state,
setState: (partial) => {
state = { ...state, ...partial }
listeners.forEach((listener) => listener(state))
},
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
}
}
// Usage
interface AppState {
user: User | null
theme: 'light' | 'dark'
}
const store = createStore<AppState>({
user: null,
theme: 'light',
})
// React hook integration
function useStore<T, U>(store: Store<T>, selector: (state: T) => U): U {
const [value, setValue] = useState(() => selector(store.getState()))
useEffect(() => {
const unsubscribe = store.subscribe((state) => {
setValue(selector(state))
})
return unsubscribe
}, [store, selector])
return value
}
// Usage in component
function ThemeToggle() {
const theme = useStore(store, (state) => state.theme)
return (
<button
onClick={() => store.setState({ theme: theme === 'light' ? 'dark' : 'light' })}
>
Toggle Theme
</button>
)
}
```
## Form Patterns
### Form State Management
```typescript
// Generic form state
interface FormState<T> {
values: T
errors: Partial<Record<keyof T, string>>
touched: Partial<Record<keyof T, boolean>>
isSubmitting: boolean
}
// Form hook
function useForm<T extends Record<string, any>>(
initialValues: T,
validate: (values: T) => Partial<Record<keyof T, string>>,
) {
const [state, setState] = useState<FormState<T>>({
values: initialValues,
errors: {},
touched: {},
isSubmitting: false,
})
const handleChange = <K extends keyof T>(field: K, value: T[K]) => {
setState((prev) => ({
...prev,
values: { ...prev.values, [field]: value },
errors: { ...prev.errors, [field]: undefined },
}))
}
const handleBlur = <K extends keyof T>(field: K) => {
setState((prev) => ({
...prev,
touched: { ...prev.touched, [field]: true },
}))
}
const handleSubmit = async (onSubmit: (values: T) => Promise<void>) => {
const errors = validate(state.values)
if (Object.keys(errors).length > 0) {
setState((prev) => ({
...prev,
errors,
touched: Object.keys(state.values).reduce(
(acc, key) => ({ ...acc, [key]: true }),
{},
),
}))
return
}
setState((prev) => ({ ...prev, isSubmitting: true }))
try {
await onSubmit(state.values)
} finally {
setState((prev) => ({ ...prev, isSubmitting: false }))
}
}
return {
values: state.values,
errors: state.errors,
touched: state.touched,
isSubmitting: state.isSubmitting,
handleChange,
handleBlur,
handleSubmit,
}
}
// Usage
interface LoginFormValues {
email: string
password: string
}
function LoginForm() {
const form = useForm<LoginFormValues>(
{ email: '', password: '' },
(values) => {
const errors: Partial<Record<keyof LoginFormValues, string>> = {}
if (!values.email) {
errors.email = 'Email is required'
}
if (!values.password) {
errors.password = 'Password is required'
}
return errors
},
)
return (
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit(async (values) => {
await login(values.email, values.password)
})
}}
>
<input
value={form.values.email}
onChange={(e) => form.handleChange('email', e.target.value)}
onBlur={() => form.handleBlur('email')}
/>
{form.touched.email && form.errors.email && <span>{form.errors.email}</span>}
<input
type="password"
value={form.values.password}
onChange={(e) => form.handleChange('password', e.target.value)}
onBlur={() => form.handleBlur('password')}
/>
{form.touched.password && form.errors.password && (
<span>{form.errors.password}</span>
)}
<button type="submit" disabled={form.isSubmitting}>
Login
</button>
</form>
)
}
```
## Validation Patterns
### Zod Integration
```typescript
import { z } from 'zod'
// Schema definition
const userSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(120),
role: z.enum(['admin', 'user', 'guest']),
})
// Extract type from schema
type User = z.infer<typeof userSchema>
// Validation function
function validateUser(data: unknown): Result<User> {
const result = userSchema.safeParse(data)
if (result.success) {
return { success: true, data: result.data }
}
return {
success: false,
error: new Error(result.error.errors.map((e) => e.message).join(', ')),
}
}
// API integration
async function createUser(data: unknown): Promise<Result<User>> {
const validation = validateUser(data)
if (!validation.success) {
return validation
}
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(validation.data),
})
if (!response.ok) {
return failure(new Error(`HTTP ${response.status}`))
}
const user = await response.json()
return success(user)
} catch (error) {
return failure(error instanceof Error ? error : new Error(String(error)))
}
}
```
## Builder Pattern
```typescript
// Fluent builder pattern
class QueryBuilder<T> {
private filters: Array<(item: T) => boolean> = []
private sortFn?: (a: T, b: T) => number
private limitValue?: number
where(predicate: (item: T) => boolean): this {
this.filters.push(predicate)
return this
}
sortBy(compareFn: (a: T, b: T) => number): this {
this.sortFn = compareFn
return this
}
limit(count: number): this {
this.limitValue = count
return this
}
execute(data: T[]): T[] {
let result = data
// Apply filters
this.filters.forEach((filter) => {
result = result.filter(filter)
})
// Apply sorting
if (this.sortFn) {
result = result.sort(this.sortFn)
}
// Apply limit
if (this.limitValue !== undefined) {
result = result.slice(0, this.limitValue)
}
return result
}
}
// Usage
interface Product {
id: string
name: string
price: number
category: string
}
const products: Product[] = [
/* ... */
]
const query = new QueryBuilder<Product>()
.where((p) => p.category === 'electronics')
.where((p) => p.price < 1000)
.sortBy((a, b) => a.price - b.price)
.limit(10)
.execute(products)
```
## Factory Pattern
```typescript
// Abstract factory pattern with TypeScript
interface Button {
render: () => string
onClick: () => void
}
interface ButtonFactory {
createButton: (label: string, onClick: () => void) => Button
}
class PrimaryButton implements Button {
constructor(private label: string, private clickHandler: () => void) {}
render() {
return `<button class="primary">${this.label}</button>`
}
onClick() {
this.clickHandler()
}
}
class SecondaryButton implements Button {
constructor(private label: string, private clickHandler: () => void) {}
render() {
return `<button class="secondary">${this.label}</button>`
}
onClick() {
this.clickHandler()
}
}
class PrimaryButtonFactory implements ButtonFactory {
createButton(label: string, onClick: () => void): Button {
return new PrimaryButton(label, onClick)
}
}
class SecondaryButtonFactory implements ButtonFactory {
createButton(label: string, onClick: () => void): Button {
return new SecondaryButton(label, onClick)
}
}
// Usage
function createUI(factory: ButtonFactory) {
const button = factory.createButton('Click me', () => console.log('Clicked!'))
return button.render()
}
```
## Named Return Variables Pattern
```typescript
// Following Go-style named returns
function parseUser(data: unknown): { user: User | null; err: Error | null } {
let user: User | null = null
let err: Error | null = null
try {
user = userSchema.parse(data)
} catch (error) {
err = error instanceof Error ? error : new Error(String(error))
}
return { user, err }
}
// With explicit naming
function fetchData(url: string): {
data: unknown | null
status: number
err: Error | null
} {
let data: unknown | null = null
let status = 0
let err: Error | null = null
try {
const response = fetch(url)
// Process response
} catch (error) {
err = error instanceof Error ? error : new Error(String(error))
}
return { data, status, err }
}
```
## Best Practices
1. **Use discriminated unions** for type-safe state management
2. **Leverage generic types** for reusable components and hooks
3. **Extract types from Zod schemas** for runtime + compile-time safety
4. **Use Result/Option types** for explicit error handling
5. **Create builder patterns** for complex object construction
6. **Use factory patterns** for flexible object creation
7. **Type context properly** to catch usage errors at compile time
8. **Prefer const assertions** for immutable configurations
9. **Use branded types** for domain-specific primitives
10. **Document patterns** with JSDoc for team knowledge sharing

View File

@@ -0,0 +1,804 @@
# TypeScript Type System Reference
## Overview
TypeScript's type system is structural (duck-typed) rather than nominal. Two types are compatible if their structure matches, regardless of their names.
## Primitive Types
### Basic Primitives
```typescript
let str: string = 'hello'
let num: number = 42
let bool: boolean = true
let nul: null = null
let undef: undefined = undefined
let sym: symbol = Symbol('key')
let big: bigint = 100n
```
### Special Types
**any** - Disables type checking (avoid when possible):
```typescript
let anything: any = 'string'
anything = 42 // OK
anything.nonExistent() // OK at compile time, error at runtime
```
**unknown** - Type-safe alternative to any (requires type checking):
```typescript
let value: unknown = 'string'
// value.toUpperCase() // Error: must narrow type first
if (typeof value === 'string') {
value.toUpperCase() // OK after narrowing
}
```
**void** - Absence of a value (function return type):
```typescript
function log(message: string): void {
console.log(message)
}
```
**never** - Value that never occurs (exhaustive checks, infinite loops):
```typescript
function throwError(message: string): never {
throw new Error(message)
}
function exhaustiveCheck(value: never): never {
throw new Error(`Unhandled case: ${value}`)
}
```
## Object Types
### Interfaces
```typescript
// Basic interface
interface User {
id: string
name: string
email: string
}
// Optional properties
interface Product {
id: string
name: string
description?: string // Optional
}
// Readonly properties
interface Config {
readonly apiUrl: string
readonly timeout: number
}
// Index signatures
interface Dictionary {
[key: string]: string
}
// Method signatures
interface Calculator {
add(a: number, b: number): number
subtract(a: number, b: number): number
}
// Extending interfaces
interface Employee extends User {
role: string
department: string
}
// Multiple inheritance
interface Admin extends User, Employee {
permissions: string[]
}
```
### Type Aliases
```typescript
// Basic type alias
type ID = string | number
// Object type
type Point = {
x: number
y: number
}
// Union type
type Status = 'idle' | 'loading' | 'success' | 'error'
// Intersection type
type Timestamped = {
createdAt: Date
updatedAt: Date
}
type TimestampedUser = User & Timestamped
// Function type
type Callback = (data: string) => void
// Generic type alias
type Result<T> = { success: true; data: T } | { success: false; error: string }
```
### Interface vs Type Alias
**Use interface when:**
- Defining object shapes
- Need declaration merging
- Building public API types that others might extend
**Use type when:**
- Creating unions or intersections
- Working with mapped types
- Need conditional types
- Defining primitive aliases
## Array and Tuple Types
### Arrays
```typescript
// Array syntax
let numbers: number[] = [1, 2, 3]
let strings: Array<string> = ['a', 'b', 'c']
// Readonly arrays
let immutable: readonly number[] = [1, 2, 3]
let alsoImmutable: ReadonlyArray<string> = ['a', 'b']
```
### Tuples
```typescript
// Fixed-length, mixed-type arrays
type Point = [number, number]
type NamedPoint = [x: number, y: number]
// Optional elements
type OptionalTuple = [string, number?]
// Rest elements
type StringNumberBooleans = [string, number, ...boolean[]]
// Readonly tuples
type ReadonlyPair = readonly [string, number]
```
## Union and Intersection Types
### Union Types
```typescript
// Value can be one of several types
type StringOrNumber = string | number
function format(value: StringOrNumber): string {
if (typeof value === 'string') {
return value
}
return value.toString()
}
// Discriminated unions
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; size: number }
| { kind: 'rectangle'; width: number; height: number }
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2
case 'square':
return shape.size ** 2
case 'rectangle':
return shape.width * shape.height
}
}
```
### Intersection Types
```typescript
// Combine multiple types
type Draggable = {
drag: () => void
}
type Resizable = {
resize: () => void
}
type UIWidget = Draggable & Resizable
const widget: UIWidget = {
drag: () => console.log('dragging'),
resize: () => console.log('resizing'),
}
```
## Literal Types
### String Literal Types
```typescript
type Direction = 'north' | 'south' | 'east' | 'west'
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
function move(direction: Direction) {
// direction can only be one of the four values
}
```
### Number Literal Types
```typescript
type DiceValue = 1 | 2 | 3 | 4 | 5 | 6
type PowerOfTwo = 1 | 2 | 4 | 8 | 16 | 32
```
### Boolean Literal Types
```typescript
type Yes = true
type No = false
```
### Template Literal Types
```typescript
// String manipulation at type level
type EventName<T extends string> = `on${Capitalize<T>}`
type ClickEvent = EventName<'click'> // "onClick"
// Combining literals
type Color = 'red' | 'blue' | 'green'
type Shade = 'light' | 'dark'
type ColorShade = `${Shade}-${Color}` // "light-red" | "light-blue" | ...
// Extract patterns
type EmailLocaleIDs = 'welcome_email' | 'email_heading'
type FooterLocaleIDs = 'footer_title' | 'footer_sendoff'
type AllLocaleIDs = `${EmailLocaleIDs | FooterLocaleIDs}_id`
```
## Type Inference
### Automatic Inference
```typescript
// Type inferred as string
let message = 'hello'
// Type inferred as number[]
let numbers = [1, 2, 3]
// Type inferred as { name: string; age: number }
let person = {
name: 'Alice',
age: 30,
}
// Return type inferred
function add(a: number, b: number) {
return a + b // Returns number
}
```
### Const Assertions
```typescript
// Without const assertion
let colors1 = ['red', 'green', 'blue'] // Type: string[]
// With const assertion
let colors2 = ['red', 'green', 'blue'] as const // Type: readonly ["red", "green", "blue"]
// Object with const assertion
const config = {
host: 'localhost',
port: 8080,
} as const // All properties become readonly with literal types
```
### Type Inference in Generics
```typescript
// Generic type inference from usage
function identity<T>(value: T): T {
return value
}
let str = identity('hello') // T inferred as string
let num = identity(42) // T inferred as number
// Multiple type parameters
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second]
}
let p = pair('hello', 42) // [string, number]
```
## Type Narrowing
### typeof Guards
```typescript
function padLeft(value: string, padding: string | number) {
if (typeof padding === 'number') {
// padding is number here
return ' '.repeat(padding) + value
}
// padding is string here
return padding + value
}
```
### instanceof Guards
```typescript
class Dog {
bark() {
console.log('Woof!')
}
}
class Cat {
meow() {
console.log('Meow!')
}
}
function makeSound(animal: Dog | Cat) {
if (animal instanceof Dog) {
animal.bark()
} else {
animal.meow()
}
}
```
### in Operator
```typescript
type Fish = { swim: () => void }
type Bird = { fly: () => void }
function move(animal: Fish | Bird) {
if ('swim' in animal) {
animal.swim()
} else {
animal.fly()
}
}
```
### Equality Narrowing
```typescript
function example(x: string | number, y: string | boolean) {
if (x === y) {
// x and y are both string here
x.toUpperCase()
y.toLowerCase()
}
}
```
### Control Flow Analysis
```typescript
function example(value: string | null) {
if (value === null) {
return
}
// value is string here (null eliminated)
console.log(value.toUpperCase())
}
```
### Type Predicates (Custom Type Guards)
```typescript
function isString(value: unknown): value is string {
return typeof value === 'string'
}
function example(value: unknown) {
if (isString(value)) {
// value is string here
console.log(value.toUpperCase())
}
}
// More complex example
interface User {
id: string
name: string
}
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
typeof (value as User).id === 'string' &&
typeof (value as User).name === 'string'
)
}
```
### Assertion Functions
```typescript
function assert(condition: unknown, message?: string): asserts condition {
if (!condition) {
throw new Error(message || 'Assertion failed')
}
}
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new Error('Value must be a string')
}
}
function example(value: unknown) {
assertIsString(value)
// value is string here
console.log(value.toUpperCase())
}
```
## Generic Types
### Basic Generics
```typescript
// Generic function
function first<T>(items: T[]): T | undefined {
return items[0]
}
// Generic interface
interface Box<T> {
value: T
}
// Generic type alias
type Result<T> = { success: true; data: T } | { success: false; error: string }
// Generic class
class Stack<T> {
private items: T[] = []
push(item: T) {
this.items.push(item)
}
pop(): T | undefined {
return this.items.pop()
}
}
```
### Generic Constraints
```typescript
// Constrain to specific type
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
// Constrain to interface
interface HasLength {
length: number
}
function logLength<T extends HasLength>(item: T): void {
console.log(item.length)
}
logLength('string') // OK
logLength([1, 2, 3]) // OK
logLength({ length: 10 }) // OK
// logLength(42) // Error: number doesn't have length
```
### Default Generic Parameters
```typescript
interface Response<T = unknown> {
data: T
status: number
}
// Uses default
let response1: Response = { data: 'anything', status: 200 }
// Explicitly typed
let response2: Response<User> = { data: user, status: 200 }
```
### Generic Utility Functions
```typescript
// Pick specific properties
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>
keys.forEach((key) => {
result[key] = obj[key]
})
return result
}
// Map array
function map<T, U>(items: T[], fn: (item: T) => U): U[] {
return items.map(fn)
}
```
## Advanced Type Features
### Conditional Types
```typescript
// Basic conditional type
type IsString<T> = T extends string ? true : false
type A = IsString<string> // true
type B = IsString<number> // false
// Distributive conditional types
type ToArray<T> = T extends any ? T[] : never
type StrArrOrNumArr = ToArray<string | number> // string[] | number[]
// Infer keyword
type Flatten<T> = T extends Array<infer U> ? U : T
type Str = Flatten<string[]> // string
type Num = Flatten<number> // number
// ReturnType implementation
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never
```
### Mapped Types
```typescript
// Make all properties optional
type Partial<T> = {
[K in keyof T]?: T[K]
}
// Make all properties required
type Required<T> = {
[K in keyof T]-?: T[K]
}
// Make all properties readonly
type Readonly<T> = {
readonly [K in keyof T]: T[K]
}
// Transform keys
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
interface Person {
name: string
age: number
}
type PersonGetters = Getters<Person>
// {
// getName: () => string
// getAge: () => number
// }
```
### Key Remapping
```typescript
// Filter keys
type RemoveKindField<T> = {
[K in keyof T as Exclude<K, 'kind'>]: T[K]
}
// Conditional key inclusion
type PickByType<T, U> = {
[K in keyof T as T[K] extends U ? K : never]: T[K]
}
interface Model {
id: number
name: string
age: number
email: string
}
type StringFields = PickByType<Model, string> // { name: string, email: string }
```
### Recursive Types
```typescript
// JSON value type
type JSONValue = string | number | boolean | null | JSONObject | JSONArray
interface JSONObject {
[key: string]: JSONValue
}
interface JSONArray extends Array<JSONValue> {}
// Tree structure
interface TreeNode<T> {
value: T
children?: TreeNode<T>[]
}
// Deep readonly
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]
}
```
## Type Compatibility
### Structural Typing
```typescript
interface Point {
x: number
y: number
}
interface Named {
name: string
}
// Compatible if structure matches
let point: Point = { x: 0, y: 0 }
let namedPoint = { x: 0, y: 0, name: 'origin' }
point = namedPoint // OK: namedPoint has x and y
```
### Variance
**Covariance** (return types):
```typescript
interface Animal {
name: string
}
interface Dog extends Animal {
breed: string
}
let getDog: () => Dog
let getAnimal: () => Animal
getAnimal = getDog // OK: Dog is assignable to Animal
```
**Contravariance** (parameter types):
```typescript
let handleAnimal: (animal: Animal) => void
let handleDog: (dog: Dog) => void
handleDog = handleAnimal // OK: can pass Dog to function expecting Animal
```
## Index Types
### Index Signatures
```typescript
// String index
interface StringMap {
[key: string]: string
}
// Number index
interface NumberArray {
[index: number]: number
}
// Combine with named properties
interface MixedInterface {
length: number
[index: number]: string
}
```
### keyof Operator
```typescript
interface Person {
name: string
age: number
}
type PersonKeys = keyof Person // "name" | "age"
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
```
### Indexed Access Types
```typescript
interface Person {
name: string
age: number
address: {
street: string
city: string
}
}
type Name = Person['name'] // string
type Age = Person['age'] // number
type Address = Person['address'] // { street: string; city: string }
type AddressCity = Person['address']['city'] // string
// Access multiple keys
type NameOrAge = Person['name' | 'age'] // string | number
```
## Branded Types
```typescript
// Create nominal types from structural types
type Brand<K, T> = K & { __brand: T }
type USD = Brand<number, 'USD'>
type EUR = Brand<number, 'EUR'>
function makeUSD(amount: number): USD {
return amount as USD
}
function makeEUR(amount: number): EUR {
return amount as EUR
}
let usd = makeUSD(100)
let eur = makeEUR(100)
// usd = eur // Error: different brands
```
## Best Practices
1. **Prefer type inference** - Let TypeScript infer types when obvious
2. **Use strict null checks** - Enable strictNullChecks for better safety
3. **Avoid `any`** - Use `unknown` and narrow with type guards
4. **Use discriminated unions** - Better than loose unions for state
5. **Leverage const assertions** - Get narrow literal types
6. **Use branded types** - When structural typing isn't enough
7. **Document complex types** - Add JSDoc comments
8. **Extract reusable types** - DRY principle applies to types too
9. **Use utility types** - Leverage built-in transformation types
10. **Test your types** - Use type assertions to verify type correctness

View File

@@ -0,0 +1,666 @@
# TypeScript Utility Types Reference
TypeScript provides several built-in utility types that help transform and manipulate types. These are implemented using advanced type features like mapped types and conditional types.
## Property Modifiers
### Partial\<T\>
Makes all properties in `T` optional.
```typescript
interface User {
id: string
name: string
email: string
age: number
}
type PartialUser = Partial<User>
// {
// id?: string
// name?: string
// email?: string
// age?: number
// }
// Useful for update operations
function updateUser(id: string, updates: Partial<User>) {
// Only update provided fields
}
updateUser('123', { name: 'Alice' }) // OK
updateUser('123', { name: 'Alice', age: 30 }) // OK
```
### Required\<T\>
Makes all properties in `T` required (removes optionality).
```typescript
interface Config {
host?: string
port?: number
timeout?: number
}
type RequiredConfig = Required<Config>
// {
// host: string
// port: number
// timeout: number
// }
function initServer(config: RequiredConfig) {
// All properties are guaranteed to exist
console.log(config.host, config.port, config.timeout)
}
```
### Readonly\<T\>
Makes all properties in `T` readonly.
```typescript
interface MutablePoint {
x: number
y: number
}
type ImmutablePoint = Readonly<MutablePoint>
// {
// readonly x: number
// readonly y: number
// }
const point: ImmutablePoint = { x: 0, y: 0 }
// point.x = 10 // Error: Cannot assign to 'x' because it is a read-only property
```
### Mutable\<T\> (Custom)
Removes readonly modifiers (not built-in, but useful pattern).
```typescript
type Mutable<T> = {
-readonly [K in keyof T]: T[K]
}
interface ReadonlyPerson {
readonly name: string
readonly age: number
}
type MutablePerson = Mutable<ReadonlyPerson>
// {
// name: string
// age: number
// }
```
## Property Selection
### Pick\<T, K\>
Creates a type by picking specific properties from `T`.
```typescript
interface User {
id: string
name: string
email: string
password: string
createdAt: Date
}
type UserProfile = Pick<User, 'id' | 'name' | 'email'>
// {
// id: string
// name: string
// email: string
// }
// Useful for API responses
function getUserProfile(id: string): UserProfile {
// Return only safe properties
}
```
### Omit\<T, K\>
Creates a type by omitting specific properties from `T`.
```typescript
interface User {
id: string
name: string
email: string
password: string
}
type UserWithoutPassword = Omit<User, 'password'>
// {
// id: string
// name: string
// email: string
// }
// Useful for public user data
function publishUser(user: User): UserWithoutPassword {
const { password, ...publicData } = user
return publicData
}
```
## Union Type Utilities
### Exclude\<T, U\>
Excludes types from `T` that are assignable to `U`.
```typescript
type T1 = Exclude<'a' | 'b' | 'c', 'a'> // "b" | "c"
type T2 = Exclude<string | number | boolean, boolean> // string | number
type EventType = 'click' | 'scroll' | 'mousemove' | 'keypress'
type UIEvent = Exclude<EventType, 'scroll'> // "click" | "mousemove" | "keypress"
```
### Extract\<T, U\>
Extracts types from `T` that are assignable to `U`.
```typescript
type T1 = Extract<'a' | 'b' | 'c', 'a' | 'f'> // "a"
type T2 = Extract<string | number | boolean, boolean> // boolean
type Shape = 'circle' | 'square' | 'triangle' | 'rectangle'
type RoundedShape = Extract<Shape, 'circle'> // "circle"
```
### NonNullable\<T\>
Excludes `null` and `undefined` from `T`.
```typescript
type T1 = NonNullable<string | null | undefined> // string
type T2 = NonNullable<string | number | null> // string | number
function processValue(value: string | null | undefined) {
if (value !== null && value !== undefined) {
const nonNull: NonNullable<typeof value> = value
// nonNull is guaranteed to be string
}
}
```
## Object Construction
### Record\<K, T\>
Constructs an object type with keys of type `K` and values of type `T`.
```typescript
type PageInfo = Record<string, number>
// { [key: string]: number }
const pages: PageInfo = {
home: 1,
about: 2,
contact: 3,
}
// Useful for mapped objects
type UserRole = 'admin' | 'user' | 'guest'
type RolePermissions = Record<UserRole, string[]>
const permissions: RolePermissions = {
admin: ['read', 'write', 'delete'],
user: ['read', 'write'],
guest: ['read'],
}
// With specific keys
type ThemeColors = Record<'primary' | 'secondary' | 'accent', string>
const colors: ThemeColors = {
primary: '#007bff',
secondary: '#6c757d',
accent: '#28a745',
}
```
## Function Utilities
### Parameters\<T\>
Extracts the parameter types of a function type as a tuple.
```typescript
function createUser(name: string, age: number, email: string) {
// ...
}
type CreateUserParams = Parameters<typeof createUser>
// [name: string, age: number, email: string]
// Useful for higher-order functions
function withLogging<T extends (...args: any[]) => any>(
fn: T,
...args: Parameters<T>
): ReturnType<T> {
console.log('Calling with:', args)
return fn(...args)
}
```
### ConstructorParameters\<T\>
Extracts the parameter types of a constructor function type.
```typescript
class User {
constructor(public name: string, public age: number) {}
}
type UserConstructorParams = ConstructorParameters<typeof User>
// [name: string, age: number]
function createUser(...args: UserConstructorParams): User {
return new User(...args)
}
```
### ReturnType\<T\>
Extracts the return type of a function type.
```typescript
function createUser() {
return {
id: '123',
name: 'Alice',
email: 'alice@example.com',
}
}
type User = ReturnType<typeof createUser>
// {
// id: string
// name: string
// email: string
// }
// Useful with async functions
async function fetchData() {
return { success: true, data: [1, 2, 3] }
}
type FetchResult = ReturnType<typeof fetchData>
// Promise<{ success: boolean; data: number[] }>
type UnwrappedResult = Awaited<FetchResult>
// { success: boolean; data: number[] }
```
### InstanceType\<T\>
Extracts the instance type of a constructor function type.
```typescript
class User {
name: string
constructor(name: string) {
this.name = name
}
}
type UserInstance = InstanceType<typeof User>
// User
function processUser(user: UserInstance) {
console.log(user.name)
}
```
### ThisParameterType\<T\>
Extracts the type of the `this` parameter for a function type.
```typescript
function toHex(this: Number) {
return this.toString(16)
}
type ThisType = ThisParameterType<typeof toHex> // Number
```
### OmitThisParameter\<T\>
Removes the `this` parameter from a function type.
```typescript
function toHex(this: Number) {
return this.toString(16)
}
type PlainFunction = OmitThisParameter<typeof toHex>
// () => string
```
## String Manipulation
### Uppercase\<S\>
Converts string literal type to uppercase.
```typescript
type Greeting = 'hello'
type LoudGreeting = Uppercase<Greeting> // "HELLO"
// Useful for constants
type HttpMethod = 'get' | 'post' | 'put' | 'delete'
type HttpMethodUppercase = Uppercase<HttpMethod>
// "GET" | "POST" | "PUT" | "DELETE"
```
### Lowercase\<S\>
Converts string literal type to lowercase.
```typescript
type Greeting = 'HELLO'
type QuietGreeting = Lowercase<Greeting> // "hello"
```
### Capitalize\<S\>
Capitalizes the first letter of a string literal type.
```typescript
type Event = 'click' | 'scroll' | 'mousemove'
type EventHandler = `on${Capitalize<Event>}`
// "onClick" | "onScroll" | "onMousemove"
```
### Uncapitalize\<S\>
Uncapitalizes the first letter of a string literal type.
```typescript
type Greeting = 'Hello'
type LowerGreeting = Uncapitalize<Greeting> // "hello"
```
## Async Utilities
### Awaited\<T\>
Unwraps the type of a Promise (recursively).
```typescript
type T1 = Awaited<Promise<string>> // string
type T2 = Awaited<Promise<Promise<number>>> // number
type T3 = Awaited<boolean | Promise<string>> // boolean | string
// Useful with async functions
async function fetchUser() {
return { id: '123', name: 'Alice' }
}
type User = Awaited<ReturnType<typeof fetchUser>>
// { id: string; name: string }
```
## Custom Utility Types
### DeepPartial\<T\>
Makes all properties and nested properties optional.
```typescript
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
}
interface User {
id: string
profile: {
name: string
address: {
street: string
city: string
}
}
}
type PartialUser = DeepPartial<User>
// All properties at all levels are optional
```
### DeepReadonly\<T\>
Makes all properties and nested properties readonly.
```typescript
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]
}
interface User {
id: string
profile: {
name: string
address: {
street: string
city: string
}
}
}
type ImmutableUser = DeepReadonly<User>
// All properties at all levels are readonly
```
### PartialBy\<T, K\>
Makes specific properties optional.
```typescript
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
interface User {
id: string
name: string
email: string
age: number
}
type UserWithOptionalEmail = PartialBy<User, 'email' | 'age'>
// {
// id: string
// name: string
// email?: string
// age?: number
// }
```
### RequiredBy\<T, K\>
Makes specific properties required.
```typescript
type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>
interface User {
id?: string
name?: string
email?: string
}
type UserWithRequiredId = RequiredBy<User, 'id'>
// {
// id: string
// name?: string
// email?: string
// }
```
### PickByType\<T, U\>
Picks properties by their value type.
```typescript
type PickByType<T, U> = {
[K in keyof T as T[K] extends U ? K : never]: T[K]
}
interface User {
id: string
name: string
age: number
active: boolean
}
type StringProperties = PickByType<User, string>
// { id: string; name: string }
type NumberProperties = PickByType<User, number>
// { age: number }
```
### OmitByType\<T, U\>
Omits properties by their value type.
```typescript
type OmitByType<T, U> = {
[K in keyof T as T[K] extends U ? never : K]: T[K]
}
interface User {
id: string
name: string
age: number
active: boolean
}
type NonStringProperties = OmitByType<User, string>
// { age: number; active: boolean }
```
### Prettify\<T\>
Flattens intersections for better IDE tooltips.
```typescript
type Prettify<T> = {
[K in keyof T]: T[K]
} & {}
type A = { a: string }
type B = { b: number }
type C = A & B
type PrettyC = Prettify<C>
// Displays as: { a: string; b: number }
// Instead of: A & B
```
### ValueOf\<T\>
Gets the union of all value types.
```typescript
type ValueOf<T> = T[keyof T]
interface Colors {
red: '#ff0000'
green: '#00ff00'
blue: '#0000ff'
}
type ColorValue = ValueOf<Colors>
// "#ff0000" | "#00ff00" | "#0000ff"
```
### Nullable\<T\>
Makes type nullable.
```typescript
type Nullable<T> = T | null
type NullableString = Nullable<string> // string | null
```
### Maybe\<T\>
Makes type nullable or undefined.
```typescript
type Maybe<T> = T | null | undefined
type MaybeString = Maybe<string> // string | null | undefined
```
### UnionToIntersection\<U\>
Converts union to intersection (advanced).
```typescript
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
k: infer I,
) => void
? I
: never
type Union = { a: string } | { b: number }
type Intersection = UnionToIntersection<Union>
// { a: string } & { b: number }
```
## Combining Utility Types
Utility types can be composed for powerful transformations:
```typescript
// Make specific properties optional and readonly
type PartialReadonly<T, K extends keyof T> = Readonly<Pick<T, K>> &
Partial<Omit<T, K>>
interface User {
id: string
name: string
email: string
password: string
}
type SafeUser = PartialReadonly<User, 'id' | 'name'>
// {
// readonly id: string
// readonly name: string
// email?: string
// password?: string
// }
// Pick and make readonly
type ReadonlyPick<T, K extends keyof T> = Readonly<Pick<T, K>>
// Omit and make required
type RequiredOmit<T, K extends keyof T> = Required<Omit<T, K>>
```
## Best Practices
1. **Use built-in utilities first** - They're well-tested and optimized
2. **Compose utilities** - Combine utilities for complex transformations
3. **Create custom utilities** - For patterns you use frequently
4. **Name utilities clearly** - Make intent obvious from the name
5. **Document complex utilities** - Add JSDoc for non-obvious transformations
6. **Test utility types** - Use type assertions to verify behavior
7. **Avoid over-engineering** - Don't create utilities for one-off uses
8. **Consider readability** - Sometimes explicit types are clearer
9. **Use Prettify** - For better IDE tooltips with intersections
10. **Leverage keyof** - For type-safe property selection

3
.gitignore vendored
View File

@@ -76,7 +76,6 @@ cmd/benchmark/data
!*.css
!*.ts
!*.html
!contrib/stella/Dockerfile
!*.lock
!*.nix
!license
@@ -88,10 +87,8 @@ cmd/benchmark/data
!.gitignore
!version
!out.jsonl
!contrib/stella/Dockerfile
!strfry.conf
!config.toml
!contrib/stella/.dockerignore
!*.jsx
!*.tsx
!bun.lock

395
CLAUDE.md Normal file
View File

@@ -0,0 +1,395 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
ORLY is a high-performance Nostr relay written in Go, designed for personal relays, small communities, and business deployments. It emphasizes low latency, custom cryptography optimizations, and embedded database performance.
**Key Technologies:**
- **Language**: Go 1.25.3+
- **Database**: Badger v4 (embedded key-value store)
- **Cryptography**: Custom p8k library using purego for secp256k1 operations (no CGO)
- **Web UI**: Svelte frontend embedded in the binary
- **WebSocket**: gorilla/websocket for Nostr protocol
- **Performance**: SIMD-accelerated SHA256 and hex encoding
## Build Commands
### Basic Build
```bash
# Build relay binary only
go build -o orly
# Pure Go build (no CGO) - this is the standard approach
CGO_ENABLED=0 go build -o orly
```
### Build with Web UI
```bash
# Recommended: Use the provided script
./scripts/update-embedded-web.sh
# Manual build
cd app/web
bun install
bun run build
cd ../../
go build -o orly
```
### Development Mode (Web UI Hot Reload)
```bash
# Terminal 1: Start relay with dev proxy
export ORLY_WEB_DISABLE_EMBEDDED=true
export ORLY_WEB_DEV_PROXY_URL=localhost:5000
./orly &
# Terminal 2: Start dev server
cd app/web && bun run dev
```
## Testing
### Run All Tests
```bash
# Standard test run
./scripts/test.sh
# Or manually with purego setup
CGO_ENABLED=0 go test ./...
# Note: libsecp256k1.so must be available for crypto tests
export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}$(pwd)/pkg/crypto/p8k"
```
### Run Specific Package Tests
```bash
# Test database package
cd pkg/database && go test -v ./...
# Test protocol package
cd pkg/protocol && go test -v ./...
# Test with specific test function
go test -v -run TestSaveEvent ./pkg/database
```
### Relay Protocol Testing
```bash
# Test relay protocol compliance
go run cmd/relay-tester/main.go -url ws://localhost:3334
# List available tests
go run cmd/relay-tester/main.go -list
# Run specific test
go run cmd/relay-tester/main.go -url ws://localhost:3334 -test "Basic Event"
```
### Benchmarking
```bash
# Run benchmarks in specific package
go test -bench=. -benchmem ./pkg/database
# Crypto benchmarks
cd pkg/crypto/p8k && make bench
```
## Running the Relay
### Basic Run
```bash
# Build and run
go build -o orly && ./orly
# With environment variables
export ORLY_LOG_LEVEL=debug
export ORLY_PORT=3334
./orly
```
### Get Relay Identity
```bash
# Print relay identity secret and pubkey
./orly identity
```
### Common Configuration
```bash
# TLS with Let's Encrypt
export ORLY_TLS_DOMAINS=relay.example.com
# Admin configuration
export ORLY_ADMINS=npub1...
# Follows ACL mode
export ORLY_ACL_MODE=follows
# Enable sprocket event processing
export ORLY_SPROCKET_ENABLED=true
# Enable policy system
export ORLY_POLICY_ENABLED=true
```
## Code Architecture
### Repository Structure
**Root Entry Point:**
- `main.go` - Application entry point with signal handling, profiling setup, and database initialization
- `app/main.go` - Core relay server initialization and lifecycle management
**Core Packages:**
**`app/`** - HTTP/WebSocket server and handlers
- `server.go` - Main Server struct and HTTP request routing
- `handle-*.go` - Nostr protocol message handlers (EVENT, REQ, COUNT, CLOSE, AUTH, DELETE)
- `handle-websocket.go` - WebSocket connection lifecycle and frame handling
- `listener.go` - Network listener setup
- `sprocket.go` - External event processing script manager
- `publisher.go` - Event broadcast to active subscriptions
- `payment_processor.go` - NWC integration for subscription payments
- `blossom.go` - Blob storage service initialization
- `web.go` - Embedded web UI serving and dev proxy
- `config/` - Environment variable configuration using go-simpler.org/env
**`pkg/database/`** - Badger-based event storage
- `database.go` - Database initialization with cache tuning
- `save-event.go` - Event storage with index updates
- `query-events.go` - Main query execution engine
- `query-for-*.go` - Specialized query builders for different filter patterns
- `indexes/` - Index key construction for efficient lookups
- `export.go` / `import.go` - Event export/import in JSONL format
- `subscriptions.go` - Active subscription tracking
- `identity.go` - Relay identity key management
- `migrations.go` - Database schema migration runner
**`pkg/protocol/`** - Nostr protocol implementation
- `ws/` - WebSocket message framing and parsing
- `auth/` - NIP-42 authentication challenge/response
- `publish/` - Event publisher for broadcasting to subscriptions
- `relayinfo/` - NIP-11 relay information document
- `directory/` - Distributed directory service (NIP-XX)
- `nwc/` - Nostr Wallet Connect client
- `blossom/` - Blob storage protocol
**`pkg/encoders/`** - Optimized Nostr data encoding/decoding
- `event/` - Event JSON marshaling/unmarshaling with buffer pooling
- `filter/` - Filter parsing and validation
- `bech32encoding/` - npub/nsec/note encoding
- `hex/` - SIMD-accelerated hex encoding using templexxx/xhex
- `timestamp/`, `kind/`, `tag/` - Specialized field encoders
**`pkg/crypto/`** - Cryptographic operations
- `p8k/` - Pure Go secp256k1 using purego (no CGO) to dynamically load libsecp256k1.so
- `secp.go` - Dynamic library loading and function binding
- `schnorr.go` - Schnorr signature operations (NIP-01)
- `ecdh.go` - ECDH for encrypted DMs (NIP-04, NIP-44)
- `recovery.go` - Public key recovery from signatures
- `libsecp256k1.so` - Pre-compiled secp256k1 library
- `keys/` - Key derivation and conversion utilities
- `sha256/` - SIMD-accelerated SHA256 using minio/sha256-simd
**`pkg/acl/`** - Access control systems
- `acl.go` - ACL registry and interface
- `follows.go` - Follows-based whitelist (admins + their follows can write)
- `managed.go` - NIP-86 managed relay with role-based permissions
- `none.go` - Open relay (no restrictions)
**`pkg/policy/`** - Event filtering and validation policies
- Policy configuration loaded from `~/.config/ORLY/policy.json`
- Per-kind size limits, age restrictions, custom scripts
- See `docs/POLICY_USAGE_GUIDE.md` for configuration examples
**`pkg/sync/`** - Distributed synchronization
- `cluster_manager.go` - Active replication between relay peers
- `relay_group_manager.go` - Relay group configuration (NIP-XX)
- `manager.go` - Distributed directory consensus
**`pkg/spider/`** - Event syncing from other relays
- `spider.go` - Spider manager for "follows" mode
- Fetches events from admin relays for followed pubkeys
**`pkg/utils/`** - Shared utilities
- `atomic/` - Extended atomic operations
- `interrupt/` - Signal handling and graceful shutdown
- `apputil/` - Application-level utilities
**Web UI (`app/web/`):**
- Svelte-based admin interface
- Embedded in binary via `go:embed`
- Features: event browser, sprocket management, user admin, settings
**Command-line Tools (`cmd/`):**
- `relay-tester/` - Nostr protocol compliance testing
- `benchmark/` - Multi-relay performance comparison
- `stresstest/` - Load testing tool
- `aggregator/` - Event aggregation utility
- `convert/` - Data format conversion
- `policytest/` - Policy validation testing
### Important Patterns
**Pure Go with Purego:**
- All builds use `CGO_ENABLED=0`
- The p8k crypto library uses `github.com/ebitengine/purego` to dynamically load `libsecp256k1.so` at runtime
- This avoids CGO complexity while maintaining C library performance
- `libsecp256k1.so` must be in `LD_LIBRARY_PATH` or same directory as binary
**Database Query Pattern:**
- Filters are analyzed in `get-indexes-from-filter.go` to determine optimal query strategy
- Different query builders (`query-for-kinds.go`, `query-for-authors.go`, etc.) handle specific filter patterns
- All queries return event serials (uint64) for efficient joining
- Final events fetched via `fetch-events-by-serials.go`
**WebSocket Message Flow:**
1. `handle-websocket.go` accepts connection and spawns goroutine
2. Incoming frames parsed by `pkg/protocol/ws/`
3. Routed to handlers: `handle-event.go`, `handle-req.go`, `handle-count.go`, etc.
4. Events stored via `database.SaveEvent()`
5. Active subscriptions notified via `publishers.Publish()`
**Configuration System:**
- Uses `go-simpler.org/env` for struct tags
- All config in `app/config/config.go` with `ORLY_` prefix
- Supports XDG directories via `github.com/adrg/xdg`
- Default data directory: `~/.local/share/ORLY`
**Event Publishing:**
- `pkg/protocol/publish/` manages publisher registry
- Each WebSocket connection registers its subscriptions
- `publishers.Publish(event)` broadcasts to matching subscribers
- Efficient filter matching without re-querying database
**Embedded Assets:**
- Web UI built to `app/web/dist/`
- Embedded via `//go:embed` directive in `app/web.go`
- Served at root path `/` with API at `/api/*`
## Development Workflow
### Making Changes to Web UI
1. Edit files in `app/web/src/`
2. For hot reload: `cd app/web && bun run dev` (with `ORLY_WEB_DISABLE_EMBEDDED=true`)
3. For production build: `./scripts/update-embedded-web.sh`
### Adding New Nostr Protocol Handlers
1. Create `app/handle-<message-type>.go`
2. Add case in `app/handle-message.go` message router
3. Implement handler following existing patterns
4. Add tests in `app/<handler>_test.go`
### Adding Database Indexes
1. Define index in `pkg/database/indexes/`
2. Add migration in `pkg/database/migrations.go`
3. Update `save-event.go` to populate index
4. Add query builder in `pkg/database/query-for-<index>.go`
5. Update `get-indexes-from-filter.go` to use new index
### Environment Variables for Development
```bash
# Verbose logging
export ORLY_LOG_LEVEL=trace
export ORLY_DB_LOG_LEVEL=debug
# Enable profiling
export ORLY_PPROF=cpu
export ORLY_PPROF_HTTP=true # Serves on :6060
# Health check endpoint
export ORLY_HEALTH_PORT=8080
```
### Profiling
```bash
# CPU profiling
export ORLY_PPROF=cpu
./orly
# Profile written on shutdown
# HTTP pprof server
export ORLY_PPROF_HTTP=true
./orly
# Visit http://localhost:6060/debug/pprof/
# Memory profiling
export ORLY_PPROF=memory
export ORLY_PPROF_PATH=/tmp/profiles
```
## Deployment
### Automated Deployment
```bash
# Deploy with systemd service
./scripts/deploy.sh
```
This script:
1. Installs Go 1.25.0 if needed
2. Builds relay with embedded web UI
3. Installs to `~/.local/bin/orly`
4. Creates systemd service
5. Sets capabilities for port 443 binding
### systemd Service Management
```bash
# Start/stop/restart
sudo systemctl start orly
sudo systemctl stop orly
sudo systemctl restart orly
# Enable on boot
sudo systemctl enable orly
# View logs
sudo journalctl -u orly -f
```
### Manual Deployment
```bash
# Build for production
./scripts/update-embedded-web.sh
# Or build all platforms
./scripts/build-all-platforms.sh
```
## Key Dependencies
- `github.com/dgraph-io/badger/v4` - Embedded database
- `github.com/gorilla/websocket` - WebSocket server
- `github.com/minio/sha256-simd` - SIMD SHA256
- `github.com/templexxx/xhex` - SIMD hex encoding
- `github.com/ebitengine/purego` - CGO-free C library loading
- `go-simpler.org/env` - Environment variable configuration
- `lol.mleku.dev` - Custom logging library
## Testing Guidelines
- Test files use `_test.go` suffix
- Use `github.com/stretchr/testify` for assertions
- Database tests require temporary database setup (see `pkg/database/testmain_test.go`)
- WebSocket tests should use `relay-tester` package
- Always clean up resources in tests (database, connections, goroutines)
## Performance Considerations
- **Database Caching**: Tune `ORLY_DB_BLOCK_CACHE_MB` and `ORLY_DB_INDEX_CACHE_MB` for workload
- **Query Optimization**: Add indexes for common filter patterns
- **Memory Pooling**: Use buffer pools in encoders (see `pkg/encoders/event/`)
- **SIMD Operations**: Leverage minio/sha256-simd and templexxx/xhex
- **Goroutine Management**: Each WebSocket connection runs in its own goroutine
## Release Process
1. Update version in `pkg/version/version` file (e.g., v1.2.3)
2. Create and push tag:
```bash
git tag v1.2.3
git push origin v1.2.3
```
3. GitHub Actions workflow builds binaries for multiple platforms
4. Release created automatically with binaries and checksums

357
INDEX.md Normal file
View File

@@ -0,0 +1,357 @@
# Strfry WebSocket Implementation Analysis - Document Index
## Overview
This collection provides a comprehensive, in-depth analysis of the strfry Nostr relay implementation, specifically focusing on its WebSocket handling architecture and performance optimizations.
**Total Documentation:** 2,416 lines across 4 documents
**Source:** https://github.com/hoytech/strfry
**Analysis Date:** November 6, 2025
---
## Document Guide
### 1. README_STRFRY_ANALYSIS.md (277 lines)
**Start here for context**
Provides:
- Overview of all analysis documents
- Key findings summary (architecture, library, message flow)
- Critical optimizations list (8 major techniques)
- File structure and organization
- Configuration reference
- Performance metrics table
- Nostr protocol support summary
- 10 key insights
- Building and testing instructions
**Reading Time:** 10-15 minutes
**Best For:** Getting oriented, understanding the big picture
---
### 2. strfry_websocket_quick_reference.md (270 lines)
**Quick lookup for specific topics**
Contains:
- Architecture points with file references
- Critical data structures table
- Thread pool architecture
- Event batching optimization details
- Connection lifecycle (4 stages with line numbers)
- 8 performance techniques with locations
- Configuration parameters (relay.conf)
- Bandwidth tracking code
- Nostr message types
- Filter processing pipeline
- File sizes and complexity table
- Error handling strategies
- 15 scalability features
**Use When:** Looking for specific implementation details, file locations, or configuration options
**Best For:**
- Developers implementing similar systems
- Performance tuning reference
- Quick lookup by topic
---
### 3. strfry_websocket_code_flow.md (731 lines)
**Step-by-step code execution traces**
Provides complete flow documentation for:
1. **Connection Establishment** - IP resolution, metadata allocation
2. **Incoming Message Processing** - Reception through ingestion
3. **Event Submission** - Validation, duplicate checking, queueing
4. **Subscription Requests (REQ)** - Filter parsing, query scheduling
5. **Event Broadcasting** - The critical batching optimization
6. **Connection Disconnection** - Statistics, cleanup, thread notification
7. **Thread Pool Dispatch** - Deterministic routing pattern
8. **Message Type Dispatch** - std::variant pattern
9. **Subscription Lifecycle** - Complete visual diagram
10. **Error Handling** - Exception propagation patterns
Each section includes:
- Exact file paths and line numbers
- Full code examples with inline comments
- Step-by-step numbered execution trace
- Performance impact analysis
**Code Examples:** 250+ lines of actual source code
**Use When:** Understanding how specific operations work
**Best For:**
- Learning the complete message lifecycle
- Understanding threading model
- Studying performance optimization techniques
- Code review and auditing
---
### 4. strfry_websocket_analysis.md (1138 lines)
**Complete reference guide**
Comprehensive coverage of:
**Section 1: WebSocket Library & Connection Setup**
- Library choice (uWebSockets fork)
- Event multiplexing (epoll/IOCP)
- Server connection setup (compression, PING, binding)
- Individual connection management
- Client connection wrapper (WSConnection.h)
- Configuration parameters
**Section 2: Message Parsing and Serialization**
- Incoming message reception
- JSON parsing and command routing
- Event processing and serialization
- REQ (subscription) request parsing
- Nostr protocol message structures
**Section 3: Event Handling and Subscription Management**
- Subscription data structure
- ReqWorker (initial query processing)
- ReqMonitor (live event streaming)
- ActiveMonitors (indexed subscription tracking)
**Section 4: Connection Management and Cleanup**
- Graceful connection disconnection
- Connection statistics tracking
- Thread-safe closure flow
**Section 5: Performance Optimizations Specific to C++**
- Event batching for broadcast (memory layout analysis)
- String view usage for zero-copy
- Move semantics for message queues
- Variant-based polymorphism (no virtual dispatch)
- Memory pre-allocation and buffer reuse
- Protected queues with batch operations
- Lazy initialization and caching
- Compression with dictionary support
- Single-threaded event loop
- Lock-free inter-thread communication
- Template-based HTTP response caching
- Ring buffer implementation
**Section 6-8:** Architecture diagrams, configuration reference, file complexity analysis
**Code Examples:** 350+ lines with detailed annotations
**Use When:** Building a complete understanding
**Best For:**
- Implementation reference for similar systems
- Performance optimization inspiration
- Architecture study
- Educational resource
- Production code patterns
---
## Quick Navigation
### By Topic
**Architecture & Design**
- README_STRFRY_ANALYSIS.md - "Architecture" section
- strfry_websocket_code_flow.md - Section 9 (Lifecycle diagram)
**WebSocket/Network**
- strfry_websocket_analysis.md - Section 1
- strfry_websocket_quick_reference.md - Sections 1, 8
**Message Processing**
- strfry_websocket_analysis.md - Section 2
- strfry_websocket_code_flow.md - Sections 1-3
**Subscriptions & Filtering**
- strfry_websocket_analysis.md - Section 3
- strfry_websocket_quick_reference.md - Section 12
**Performance Optimization**
- strfry_websocket_analysis.md - Section 5 (most detailed)
- strfry_websocket_quick_reference.md - Section 8
- README_STRFRY_ANALYSIS.md - "Critical Optimizations" section
**Connection Management**
- strfry_websocket_analysis.md - Section 4
- strfry_websocket_code_flow.md - Section 6
**Error Handling**
- strfry_websocket_code_flow.md - Section 10
- strfry_websocket_quick_reference.md - Section 14
**Configuration**
- README_STRFRY_ANALYSIS.md - "Configuration" section
- strfry_websocket_quick_reference.md - Section 9
### By Audience
**System Designers**
1. Start: README_STRFRY_ANALYSIS.md
2. Deep dive: strfry_websocket_analysis.md sections 1, 3, 4
3. Reference: strfry_websocket_code_flow.md section 9
**Performance Engineers**
1. Start: strfry_websocket_quick_reference.md section 8
2. Deep dive: strfry_websocket_analysis.md section 5
3. Code examples: strfry_websocket_code_flow.md section 5
**Implementers (building similar systems)**
1. Overview: README_STRFRY_ANALYSIS.md
2. Architecture: strfry_websocket_code_flow.md
3. Reference: strfry_websocket_analysis.md
4. Tuning: strfry_websocket_quick_reference.md
**Students/Learning**
1. Start: README_STRFRY_ANALYSIS.md
2. Code flows: strfry_websocket_code_flow.md (sections 1-4)
3. Deep dive: strfry_websocket_analysis.md (one section at a time)
4. Reference: strfry_websocket_quick_reference.md
---
## Key Statistics
### Code Coverage
- **Total Source Files Analyzed:** 13 C++ files
- **Total Lines of Source Code:** 3,274 lines
- **Code Examples Provided:** 600+ lines
- **File:Line References:** 100+
### Documentation Volume
- **Total Documentation:** 2,416 lines
- **Code Examples:** 600+ lines (25% of total)
- **Diagrams:** 4 ASCII architecture diagrams
### Performance Optimizations Documented
- **Thread Pool Patterns:** 2 (deterministic dispatch, batch dispatch)
- **Memory Optimization Techniques:** 5 (move semantics, string_view, pre-allocation, etc.)
- **Synchronization Patterns:** 3 (batched queues, lock-free, hash-based)
- **Dispatch Patterns:** 2 (variant-based, callback-based)
---
## Source Code Files Referenced
**WebSocket & Connection (4 files)**
- WSConnection.h (175 lines) - Client wrapper
- RelayWebsocket.cpp (327 lines) - Server implementation
- RelayServer.h (231 lines) - Message definitions
**Message Processing (3 files)**
- RelayIngester.cpp (170 lines) - Parsing & validation
- RelayReqWorker.cpp (45 lines) - Query processing
- RelayReqMonitor.cpp (62 lines) - Live filtering
**Data Structures & Support (6 files)**
- Subscription.h (69 lines)
- ThreadPool.h (61 lines)
- ActiveMonitors.h (235 lines)
- Decompressor.h (68 lines)
- WriterPipeline.h (209 lines)
**Additional Components (2 files)**
- RelayWriter.cpp (113 lines) - DB writes
- RelayNegentropy.cpp (264 lines) - Sync protocol
---
## Key Takeaways
### Architecture Principles
1. Single-threaded I/O with epoll for connection multiplexing
2. Actor model with message-passing between threads
3. Deterministic routing for lock-free message dispatch
4. Separation of concerns (I/O, validation, storage, filtering)
### Performance Techniques
1. Event batching: serialize once, reuse for thousands
2. Move semantics: zero-copy thread communication
3. std::variant: type-safe dispatch without virtual functions
4. Pre-allocation: avoid hot-path allocations
5. Compression: built-in with custom dictionaries
### Scalability Features
1. Handles thousands of concurrent connections
2. Lock-free message passing (or very low contention)
3. CPU time budgeting for long queries
4. Graceful degradation and shutdown
5. Per-connection observability
---
## How to Use This Documentation
### For Quick Answers
```
Use strfry_websocket_quick_reference.md
- Index by section number
- Find file:line references
- Look up specific techniques
```
### For Understanding a Feature
```
1. Find reference in strfry_websocket_quick_reference.md
2. Read corresponding section in strfry_websocket_analysis.md
3. Study code flow in strfry_websocket_code_flow.md
4. Review source code at exact file:line locations
```
### For Building Similar Systems
```
1. Read README_STRFRY_ANALYSIS.md - Key Findings
2. Study strfry_websocket_analysis.md - Section 5 (Optimizations)
3. Implement patterns from strfry_websocket_code_flow.md
4. Reference strfry_websocket_quick_reference.md during implementation
```
---
## File Locations in This Repository
All analysis documents are in `/home/mleku/src/next.orly.dev/`:
```
├── README_STRFRY_ANALYSIS.md (277 lines) - Start here
├── strfry_websocket_quick_reference.md (270 lines) - Quick lookup
├── strfry_websocket_code_flow.md (731 lines) - Code flows
├── strfry_websocket_analysis.md (1138 lines) - Complete reference
└── INDEX.md (this file)
```
Original source cloned from: `https://github.com/hoytech/strfry`
Local clone location: `/tmp/strfry/`
---
## Document Integrity
All code examples are:
- Taken directly from source files
- Include exact line number references
- Annotated with execution flow
- Verified against original code
All file paths are absolute paths to the cloned repository.
---
## Additional Resources
**Nostr Protocol:** https://github.com/nostr-protocol/nostr
**uWebSockets:** https://github.com/uNetworking/uWebSockets
**LMDB:** http://www.lmdb.tech/doc/
**secp256k1:** https://github.com/bitcoin-core/secp256k1
**Negentropy:** https://github.com/hoytech/negentropy
---
**Analysis Completeness:** Comprehensive
**Last Updated:** November 6, 2025
**Coverage:** All WebSocket and connection handling code
Questions or corrections? Refer to the source code at `/tmp/strfry/` for the definitive reference.

View File

@@ -70,6 +70,12 @@ type C struct {
PolicyEnabled bool `env:"ORLY_POLICY_ENABLED" default:"false" usage:"enable policy-based event processing (configuration found in $HOME/.config/ORLY/policy.json)"`
// NIP-43 Relay Access Metadata and Requests
NIP43Enabled bool `env:"ORLY_NIP43_ENABLED" default:"false" usage:"enable NIP-43 relay access metadata and invite system"`
NIP43PublishEvents bool `env:"ORLY_NIP43_PUBLISH_EVENTS" default:"true" usage:"publish kind 8000/8001 events when members are added/removed"`
NIP43PublishMemberList bool `env:"ORLY_NIP43_PUBLISH_MEMBER_LIST" default:"true" usage:"publish kind 13534 membership list events"`
NIP43InviteExpiry time.Duration `env:"ORLY_NIP43_INVITE_EXPIRY" default:"24h" usage:"how long invite codes remain valid"`
// TLS configuration
TLSDomains []string `env:"ORLY_TLS_DOMAINS" usage:"comma-separated list of domains to respond to for TLS"`
Certs []string `env:"ORLY_CERTS" usage:"comma-separated list of paths to certificate root names (e.g., /path/to/cert will load /path/to/cert.pem and /path/to/cert.key)"`

View File

@@ -23,13 +23,30 @@ func (l *Listener) HandleClose(req []byte) (err error) {
if len(env.ID) == 0 {
return errors.New("CLOSE has no <id>")
}
subID := string(env.ID)
// Cancel the subscription goroutine by calling its cancel function
l.subscriptionsMu.Lock()
if cancelFunc, exists := l.subscriptions[subID]; exists {
log.D.F("cancelling subscription %s for %s", subID, l.remote)
cancelFunc()
delete(l.subscriptions, subID)
} else {
log.D.F("subscription %s not found for %s (already closed?)", subID, l.remote)
}
l.subscriptionsMu.Unlock()
// Also remove from publisher's tracking
l.publishers.Receive(
&W{
Cancel: true,
remote: l.remote,
Conn: l.conn,
Id: string(env.ID),
Id: subID,
},
)
log.D.F("CLOSE processed for subscription %s @ %s", subID, l.remote)
return
}

View File

@@ -15,6 +15,7 @@ import (
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/encoders/kind"
"next.orly.dev/pkg/encoders/reason"
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/utils"
)
@@ -207,6 +208,23 @@ func (l *Listener) HandleEvent(msg []byte) (err error) {
}
return
}
// Handle NIP-43 special events before ACL checks
switch env.E.Kind {
case nip43.KindJoinRequest:
// Process join request and return early
if err = l.HandleNIP43JoinRequest(env.E); chk.E(err) {
log.E.F("failed to process NIP-43 join request: %v", err)
}
return
case nip43.KindLeaveRequest:
// Process leave request and return early
if err = l.HandleNIP43LeaveRequest(env.E); chk.E(err) {
log.E.F("failed to process NIP-43 leave request: %v", err)
}
return
}
// check permissions of user
log.I.F(
"HandleEvent: checking ACL permissions for pubkey: %s",

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"strings"
"time"
"unicode"
"unicode/utf8"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
@@ -18,36 +18,22 @@ import (
)
// validateJSONMessage checks if a message contains invalid control characters
// that would cause JSON parsing to fail
// that would cause JSON parsing to fail. It also validates UTF-8 encoding.
func validateJSONMessage(msg []byte) (err error) {
for i, b := range msg {
// Check for invalid control characters in JSON strings
// First, validate that the message is valid UTF-8
if !utf8.Valid(msg) {
return fmt.Errorf("invalid UTF-8 encoding")
}
// Check for invalid control characters in JSON strings
for i := 0; i < len(msg); i++ {
b := msg[i]
// Check for invalid control characters (< 32) except tab, newline, carriage return
if b < 32 && b != '\t' && b != '\n' && b != '\r' {
// Allow some control characters that might be valid in certain contexts
// but reject form feed (\f), backspace (\b), and other problematic ones
switch b {
case '\b', '\f', 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F:
return fmt.Errorf("invalid control character 0x%02X at position %d", b, i)
}
}
// Check for non-printable characters that might indicate binary data
if b > 127 && !unicode.IsPrint(rune(b)) {
// Allow valid UTF-8 sequences, but be suspicious of random binary data
if i < len(msg)-1 {
// Quick check: if we see a lot of high-bit characters in sequence,
// it might be binary data masquerading as text
highBitCount := 0
for j := i; j < len(msg) && j < i+10; j++ {
if msg[j] > 127 {
highBitCount++
}
}
if highBitCount > 7 { // More than 70% high-bit chars in a 10-byte window
return fmt.Errorf("suspicious binary data detected at position %d", i)
}
}
return fmt.Errorf(
"invalid control character 0x%02X at position %d", b, i,
)
}
}
return
@@ -58,12 +44,17 @@ func (l *Listener) HandleMessage(msg []byte, remote string) {
if l.isBlacklisted {
// Check if timeout has been reached
if time.Now().After(l.blacklistTimeout) {
log.W.F("blacklisted IP %s timeout reached, closing connection", remote)
log.W.F(
"blacklisted IP %s timeout reached, closing connection", remote,
)
// Close the connection by cancelling the context
// The websocket handler will detect this and close the connection
return
}
log.D.F("discarding message from blacklisted IP %s (timeout in %v)", remote, time.Until(l.blacklistTimeout))
log.D.F(
"discarding message from blacklisted IP %s (timeout in %v)", remote,
time.Until(l.blacklistTimeout),
)
return
}
@@ -71,13 +62,22 @@ func (l *Listener) HandleMessage(msg []byte, remote string) {
if len(msgPreview) > 150 {
msgPreview = msgPreview[:150] + "..."
}
// log.D.F("%s processing message (len=%d): %s", remote, len(msg), msgPreview)
log.D.F("%s processing message (len=%d): %s", remote, len(msg), msgPreview)
// Validate message for invalid characters before processing
if err := validateJSONMessage(msg); err != nil {
log.E.F("%s message validation FAILED (len=%d): %v", remote, len(msg), err)
if noticeErr := noticeenvelope.NewFrom(fmt.Sprintf("invalid message format: contains invalid characters: %s", msg)).Write(l); noticeErr != nil {
log.E.F("%s failed to send validation error notice: %v", remote, noticeErr)
log.E.F(
"%s message validation FAILED (len=%d): %v", remote, len(msg), err,
)
if noticeErr := noticeenvelope.NewFrom(
fmt.Sprintf(
"invalid message format: contains invalid characters: %s", msg,
),
).Write(l); noticeErr != nil {
log.E.F(
"%s failed to send validation error notice: %v", remote,
noticeErr,
)
}
return
}
@@ -140,9 +140,10 @@ func (l *Listener) HandleMessage(msg []byte, remote string) {
if err != nil {
// Don't log context cancellation errors as they're expected during shutdown
if !strings.Contains(err.Error(), "context canceled") {
log.E.F("%s message processing FAILED (type=%s): %v", remote, t, err)
log.E.F(
"%s message processing FAILED (type=%s): %v", remote, t, err,
)
// Don't log message preview as it may contain binary data
// Send error notice to client (use generic message to avoid control chars in errors)
noticeMsg := fmt.Sprintf("%s processing failed", t)
if noticeErr := noticeenvelope.NewFrom(noticeMsg).Write(l); noticeErr != nil {

254
app/handle-nip43.go Normal file
View File

@@ -0,0 +1,254 @@
package app
import (
"context"
"fmt"
"strings"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/encoders/envelopes/okenvelope"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/protocol/nip43"
)
// HandleNIP43JoinRequest processes a kind 28934 join request
func (l *Listener) HandleNIP43JoinRequest(ev *event.E) error {
log.I.F("handling NIP-43 join request from %s", hex.Enc(ev.Pubkey))
// Validate the join request
inviteCode, valid, reason := nip43.ValidateJoinRequest(ev)
if !valid {
log.W.F("invalid join request: %s", reason)
return l.sendOKResponse(ev.ID, false, fmt.Sprintf("restricted: %s", reason))
}
// Check if user is already a member
isMember, err := l.D.IsNIP43Member(ev.Pubkey)
if chk.E(err) {
log.E.F("error checking membership: %v", err)
return l.sendOKResponse(ev.ID, false, "error: internal server error")
}
if isMember {
log.I.F("user %s is already a member", hex.Enc(ev.Pubkey))
return l.sendOKResponse(ev.ID, true, "duplicate: you are already a member of this relay")
}
// Validate the invite code
validCode, reason := l.Server.InviteManager.ValidateAndConsume(inviteCode, ev.Pubkey)
if !validCode {
log.W.F("invalid or expired invite code: %s - %s", inviteCode, reason)
return l.sendOKResponse(ev.ID, false, fmt.Sprintf("restricted: %s", reason))
}
// Add the member
if err = l.D.AddNIP43Member(ev.Pubkey, inviteCode); chk.E(err) {
log.E.F("error adding member: %v", err)
return l.sendOKResponse(ev.ID, false, "error: failed to add member")
}
log.I.F("successfully added member %s via invite code", hex.Enc(ev.Pubkey))
// Publish kind 8000 "add member" event if configured
if l.Config.NIP43PublishEvents {
if err = l.publishAddUserEvent(ev.Pubkey); chk.E(err) {
log.W.F("failed to publish add user event: %v", err)
}
}
// Update membership list if configured
if l.Config.NIP43PublishMemberList {
if err = l.publishMembershipList(); chk.E(err) {
log.W.F("failed to publish membership list: %v", err)
}
}
relayURL := l.Config.RelayURL
if relayURL == "" {
relayURL = fmt.Sprintf("wss://%s:%d", l.Config.Listen, l.Config.Port)
}
return l.sendOKResponse(ev.ID, true, fmt.Sprintf("welcome to %s!", relayURL))
}
// HandleNIP43LeaveRequest processes a kind 28936 leave request
func (l *Listener) HandleNIP43LeaveRequest(ev *event.E) error {
log.I.F("handling NIP-43 leave request from %s", hex.Enc(ev.Pubkey))
// Validate the leave request
valid, reason := nip43.ValidateLeaveRequest(ev)
if !valid {
log.W.F("invalid leave request: %s", reason)
return l.sendOKResponse(ev.ID, false, fmt.Sprintf("error: %s", reason))
}
// Check if user is a member
isMember, err := l.D.IsNIP43Member(ev.Pubkey)
if chk.E(err) {
log.E.F("error checking membership: %v", err)
return l.sendOKResponse(ev.ID, false, "error: internal server error")
}
if !isMember {
log.I.F("user %s is not a member", hex.Enc(ev.Pubkey))
return l.sendOKResponse(ev.ID, true, "you are not a member of this relay")
}
// Remove the member
if err = l.D.RemoveNIP43Member(ev.Pubkey); chk.E(err) {
log.E.F("error removing member: %v", err)
return l.sendOKResponse(ev.ID, false, "error: failed to remove member")
}
log.I.F("successfully removed member %s", hex.Enc(ev.Pubkey))
// Publish kind 8001 "remove member" event if configured
if l.Config.NIP43PublishEvents {
if err = l.publishRemoveUserEvent(ev.Pubkey); chk.E(err) {
log.W.F("failed to publish remove user event: %v", err)
}
}
// Update membership list if configured
if l.Config.NIP43PublishMemberList {
if err = l.publishMembershipList(); chk.E(err) {
log.W.F("failed to publish membership list: %v", err)
}
}
return l.sendOKResponse(ev.ID, true, "you have been removed from this relay")
}
// HandleNIP43InviteRequest processes a kind 28935 invite request (REQ subscription)
func (s *Server) HandleNIP43InviteRequest(pubkey []byte) (*event.E, error) {
log.I.F("generating NIP-43 invite for pubkey %s", hex.Enc(pubkey))
// Check if requester has permission to request invites
// This could be based on ACL, admins, etc.
accessLevel := acl.Registry.GetAccessLevel(pubkey, "")
if accessLevel != "admin" && accessLevel != "owner" {
log.W.F("unauthorized invite request from %s (level: %s)", hex.Enc(pubkey), accessLevel)
return nil, fmt.Errorf("unauthorized: only admins can request invites")
}
// Generate a new invite code
code, err := s.InviteManager.GenerateCode()
if chk.E(err) {
return nil, err
}
// Get relay identity
relaySecret, err := s.db.GetOrCreateRelayIdentitySecret()
if chk.E(err) {
return nil, err
}
// Build the invite event
inviteEvent, err := nip43.BuildInviteEvent(relaySecret, code)
if chk.E(err) {
return nil, err
}
log.I.F("generated invite code for %s", hex.Enc(pubkey))
return inviteEvent, nil
}
// publishAddUserEvent publishes a kind 8000 add user event
func (l *Listener) publishAddUserEvent(userPubkey []byte) error {
relaySecret, err := l.D.GetOrCreateRelayIdentitySecret()
if chk.E(err) {
return err
}
ev, err := nip43.BuildAddUserEvent(relaySecret, userPubkey)
if chk.E(err) {
return err
}
// Save to database
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err = l.SaveEvent(ctx, ev); chk.E(err) {
return err
}
// Publish to subscribers
l.publishers.Deliver(ev)
log.I.F("published kind 8000 add user event for %s", hex.Enc(userPubkey))
return nil
}
// publishRemoveUserEvent publishes a kind 8001 remove user event
func (l *Listener) publishRemoveUserEvent(userPubkey []byte) error {
relaySecret, err := l.D.GetOrCreateRelayIdentitySecret()
if chk.E(err) {
return err
}
ev, err := nip43.BuildRemoveUserEvent(relaySecret, userPubkey)
if chk.E(err) {
return err
}
// Save to database
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err = l.SaveEvent(ctx, ev); chk.E(err) {
return err
}
// Publish to subscribers
l.publishers.Deliver(ev)
log.I.F("published kind 8001 remove user event for %s", hex.Enc(userPubkey))
return nil
}
// publishMembershipList publishes a kind 13534 membership list event
func (l *Listener) publishMembershipList() error {
// Get all members
members, err := l.D.GetAllNIP43Members()
if chk.E(err) {
return err
}
relaySecret, err := l.D.GetOrCreateRelayIdentitySecret()
if chk.E(err) {
return err
}
ev, err := nip43.BuildMemberListEvent(relaySecret, members)
if chk.E(err) {
return err
}
// Save to database
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err = l.SaveEvent(ctx, ev); chk.E(err) {
return err
}
// Publish to subscribers
l.publishers.Deliver(ev)
log.I.F("published kind 13534 membership list event with %d members", len(members))
return nil
}
// sendOKResponse sends an OK envelope response
func (l *Listener) sendOKResponse(eventID []byte, accepted bool, message string) error {
// Ensure message doesn't have "restricted: " prefix if already present
if accepted && strings.HasPrefix(message, "restricted: ") {
message = strings.TrimPrefix(message, "restricted: ")
}
env := okenvelope.NewFrom(eventID, accepted, []byte(message))
return env.Write(l)
}

570
app/handle-nip43_test.go Normal file
View File

@@ -0,0 +1,570 @@
package app
import (
"context"
"os"
"testing"
"time"
"next.orly.dev/app/config"
"next.orly.dev/pkg/crypto/keys"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/interfaces/signer/p8k"
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/protocol/publish"
)
// setupTestListener creates a test listener with NIP-43 enabled
func setupTestListener(t *testing.T) (*Listener, *database.D, func()) {
tempDir, err := os.MkdirTemp("", "nip43_handler_test_*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
db, err := database.New(ctx, cancel, tempDir, "info")
if err != nil {
os.RemoveAll(tempDir)
t.Fatalf("failed to open database: %v", err)
}
cfg := &config.C{
NIP43Enabled: true,
NIP43PublishEvents: true,
NIP43PublishMemberList: true,
NIP43InviteExpiry: 24 * time.Hour,
RelayURL: "wss://test.relay",
Listen: "localhost",
Port: 3334,
}
server := &Server{
Ctx: ctx,
Config: cfg,
D: db,
publishers: publish.New(NewPublisher(ctx)),
InviteManager: nip43.NewInviteManager(cfg.NIP43InviteExpiry),
cfg: cfg,
db: db,
}
listener := &Listener{
Server: server,
ctx: ctx,
}
cleanup := func() {
db.Close()
os.RemoveAll(tempDir)
}
return listener, db, cleanup
}
// TestHandleNIP43JoinRequest_ValidRequest tests a successful join request
func TestHandleNIP43JoinRequest_ValidRequest(t *testing.T) {
listener, db, cleanup := setupTestListener(t)
defer cleanup()
// Generate test user
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
userPubkey := userSigner.Pub()
// Generate invite code
code, err := listener.Server.InviteManager.GenerateCode()
if err != nil {
t.Fatalf("failed to generate invite code: %v", err)
}
// Create join request event
ev := event.New()
ev.Kind = nip43.KindJoinRequest
copy(ev.Pubkey, userPubkey)
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.Tags.Append(tag.NewFromAny("claim", code))
ev.CreatedAt = time.Now().Unix()
ev.Content = []byte("")
// Sign event
if err = ev.Sign(userSigner); err != nil {
t.Fatalf("failed to sign event: %v", err)
}
// Handle join request
err = listener.HandleNIP43JoinRequest(ev)
if err != nil {
t.Fatalf("failed to handle join request: %v", err)
}
// Verify user was added to database
isMember, err := db.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if !isMember {
t.Error("user was not added as member")
}
// Verify membership details
membership, err := db.GetNIP43Membership(userPubkey)
if err != nil {
t.Fatalf("failed to get membership: %v", err)
}
if membership.InviteCode != code {
t.Errorf("wrong invite code stored: got %s, want %s", membership.InviteCode, code)
}
}
// TestHandleNIP43JoinRequest_InvalidCode tests join request with invalid code
func TestHandleNIP43JoinRequest_InvalidCode(t *testing.T) {
listener, db, cleanup := setupTestListener(t)
defer cleanup()
// Generate test user
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
userPubkey := userSigner.Pub()
// Create join request with invalid code
ev := event.New()
ev.Kind = nip43.KindJoinRequest
copy(ev.Pubkey, userPubkey)
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.Tags.Append(tag.NewFromAny("claim", "invalid-code-123"))
ev.CreatedAt = time.Now().Unix()
ev.Content = []byte("")
if err = ev.Sign(userSigner); err != nil {
t.Fatalf("failed to sign event: %v", err)
}
// Handle join request - should succeed but not add member
err = listener.HandleNIP43JoinRequest(ev)
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
// Verify user was NOT added
isMember, err := db.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if isMember {
t.Error("user was incorrectly added as member with invalid code")
}
}
// TestHandleNIP43JoinRequest_DuplicateMember tests join request from existing member
func TestHandleNIP43JoinRequest_DuplicateMember(t *testing.T) {
listener, db, cleanup := setupTestListener(t)
defer cleanup()
// Generate test user
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
userPubkey := userSigner.Pub()
// Add user directly to database
err = db.AddNIP43Member(userPubkey, "original-code")
if err != nil {
t.Fatalf("failed to add member: %v", err)
}
// Generate new invite code
code, err := listener.Server.InviteManager.GenerateCode()
if err != nil {
t.Fatalf("failed to generate invite code: %v", err)
}
// Create join request
ev := event.New()
ev.Kind = nip43.KindJoinRequest
copy(ev.Pubkey, userPubkey)
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.Tags.Append(tag.NewFromAny("claim", code))
ev.CreatedAt = time.Now().Unix()
ev.Content = []byte("")
if err = ev.Sign(userSigner); err != nil {
t.Fatalf("failed to sign event: %v", err)
}
// Handle join request - should handle gracefully
err = listener.HandleNIP43JoinRequest(ev)
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
// Verify original membership is unchanged
membership, err := db.GetNIP43Membership(userPubkey)
if err != nil {
t.Fatalf("failed to get membership: %v", err)
}
if membership.InviteCode != "original-code" {
t.Errorf("invite code was changed: got %s, want original-code", membership.InviteCode)
}
}
// TestHandleNIP43LeaveRequest_ValidRequest tests a successful leave request
func TestHandleNIP43LeaveRequest_ValidRequest(t *testing.T) {
listener, db, cleanup := setupTestListener(t)
defer cleanup()
// Generate test user
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
userPubkey := userSigner.Pub()
// Add user as member
err = db.AddNIP43Member(userPubkey, "test-code")
if err != nil {
t.Fatalf("failed to add member: %v", err)
}
// Create leave request
ev := event.New()
ev.Kind = nip43.KindLeaveRequest
copy(ev.Pubkey, userPubkey)
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.CreatedAt = time.Now().Unix()
ev.Content = []byte("")
if err = ev.Sign(userSigner); err != nil {
t.Fatalf("failed to sign event: %v", err)
}
// Handle leave request
err = listener.HandleNIP43LeaveRequest(ev)
if err != nil {
t.Fatalf("failed to handle leave request: %v", err)
}
// Verify user was removed
isMember, err := db.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if isMember {
t.Error("user was not removed")
}
}
// TestHandleNIP43LeaveRequest_NonMember tests leave request from non-member
func TestHandleNIP43LeaveRequest_NonMember(t *testing.T) {
listener, _, cleanup := setupTestListener(t)
defer cleanup()
// Generate test user (not a member)
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
userPubkey := userSigner.Pub()
// Create leave request
ev := event.New()
ev.Kind = nip43.KindLeaveRequest
copy(ev.Pubkey, userPubkey)
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.CreatedAt = time.Now().Unix()
ev.Content = []byte("")
if err = ev.Sign(userSigner); err != nil {
t.Fatalf("failed to sign event: %v", err)
}
// Handle leave request - should handle gracefully
err = listener.HandleNIP43LeaveRequest(ev)
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
}
// TestHandleNIP43InviteRequest_ValidRequest tests invite request from admin
func TestHandleNIP43InviteRequest_ValidRequest(t *testing.T) {
listener, _, cleanup := setupTestListener(t)
defer cleanup()
// Generate admin user
adminSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate admin secret: %v", err)
}
adminSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = adminSigner.InitSec(adminSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
adminPubkey := adminSigner.Pub()
// Add admin to server (simulating admin config)
listener.Server.Admins = [][]byte{adminPubkey}
// Handle invite request
inviteEvent, err := listener.Server.HandleNIP43InviteRequest(adminPubkey)
if err != nil {
t.Fatalf("failed to handle invite request: %v", err)
}
// Verify invite event
if inviteEvent == nil {
t.Fatal("invite event is nil")
}
if inviteEvent.Kind != nip43.KindInviteReq {
t.Errorf("wrong event kind: got %d, want %d", inviteEvent.Kind, nip43.KindInviteReq)
}
// Verify claim tag
claimTag := inviteEvent.Tags.GetFirst([]byte("claim"))
if claimTag == nil {
t.Fatal("missing claim tag")
}
if claimTag.Len() < 2 {
t.Fatal("claim tag has no value")
}
}
// TestHandleNIP43InviteRequest_Unauthorized tests invite request from non-admin
func TestHandleNIP43InviteRequest_Unauthorized(t *testing.T) {
listener, _, cleanup := setupTestListener(t)
defer cleanup()
// Generate regular user (not admin)
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
userPubkey := userSigner.Pub()
// Handle invite request - should fail
_, err = listener.Server.HandleNIP43InviteRequest(userPubkey)
if err == nil {
t.Fatal("expected error for unauthorized user")
}
}
// TestJoinAndLeaveFlow tests the complete join and leave flow
func TestJoinAndLeaveFlow(t *testing.T) {
listener, db, cleanup := setupTestListener(t)
defer cleanup()
// Generate test user
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
userPubkey := userSigner.Pub()
// Step 1: Generate invite code
code, err := listener.Server.InviteManager.GenerateCode()
if err != nil {
t.Fatalf("failed to generate invite code: %v", err)
}
// Step 2: User sends join request
joinEv := event.New()
joinEv.Kind = nip43.KindJoinRequest
copy(joinEv.Pubkey, userPubkey)
joinEv.Tags = tag.NewS()
joinEv.Tags.Append(tag.NewFromAny("-"))
joinEv.Tags.Append(tag.NewFromAny("claim", code))
joinEv.CreatedAt = time.Now().Unix()
joinEv.Content = []byte("")
if err = joinEv.Sign(userSigner); err != nil {
t.Fatalf("failed to sign join event: %v", err)
}
err = listener.HandleNIP43JoinRequest(joinEv)
if err != nil {
t.Fatalf("failed to handle join request: %v", err)
}
// Verify user is member
isMember, err := db.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership after join: %v", err)
}
if !isMember {
t.Fatal("user is not a member after join")
}
// Step 3: User sends leave request
leaveEv := event.New()
leaveEv.Kind = nip43.KindLeaveRequest
copy(leaveEv.Pubkey, userPubkey)
leaveEv.Tags = tag.NewS()
leaveEv.Tags.Append(tag.NewFromAny("-"))
leaveEv.CreatedAt = time.Now().Unix()
leaveEv.Content = []byte("")
if err = leaveEv.Sign(userSigner); err != nil {
t.Fatalf("failed to sign leave event: %v", err)
}
err = listener.HandleNIP43LeaveRequest(leaveEv)
if err != nil {
t.Fatalf("failed to handle leave request: %v", err)
}
// Verify user is no longer member
isMember, err = db.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership after leave: %v", err)
}
if isMember {
t.Fatal("user is still a member after leave")
}
}
// TestMultipleUsersJoining tests multiple users joining concurrently
func TestMultipleUsersJoining(t *testing.T) {
listener, db, cleanup := setupTestListener(t)
defer cleanup()
userCount := 10
done := make(chan bool, userCount)
for i := 0; i < userCount; i++ {
go func(index int) {
// Generate user
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Errorf("failed to generate user secret %d: %v", index, err)
done <- false
return
}
userSigner, err := p8k.New()
if err != nil {
t.Errorf("failed to create signer %d: %v", index, err)
done <- false
return
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Errorf("failed to initialize signer %d: %v", index, err)
done <- false
return
}
userPubkey := userSigner.Pub()
// Generate invite code
code, err := listener.Server.InviteManager.GenerateCode()
if err != nil {
t.Errorf("failed to generate invite code %d: %v", index, err)
done <- false
return
}
// Create join request
joinEv := event.New()
joinEv.Kind = nip43.KindJoinRequest
copy(joinEv.Pubkey, userPubkey)
joinEv.Tags = tag.NewS()
joinEv.Tags.Append(tag.NewFromAny("-"))
joinEv.Tags.Append(tag.NewFromAny("claim", code))
joinEv.CreatedAt = time.Now().Unix()
joinEv.Content = []byte("")
if err = joinEv.Sign(userSigner); err != nil {
t.Errorf("failed to sign event %d: %v", index, err)
done <- false
return
}
// Handle join request
if err = listener.HandleNIP43JoinRequest(joinEv); err != nil {
t.Errorf("failed to handle join request %d: %v", index, err)
done <- false
return
}
done <- true
}(i)
}
// Wait for all goroutines
successCount := 0
for i := 0; i < userCount; i++ {
if <-done {
successCount++
}
}
if successCount != userCount {
t.Errorf("not all users joined successfully: %d/%d", successCount, userCount)
}
// Verify member count
members, err := db.GetAllNIP43Members()
if err != nil {
t.Fatalf("failed to get all members: %v", err)
}
if len(members) != successCount {
t.Errorf("wrong member count: got %d, want %d", len(members), successCount)
}
}

View File

@@ -33,7 +33,7 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) {
r.Header.Set("Content-Type", "application/json")
log.D.Ln("handling relay information document")
var info *relayinfo.T
supportedNIPs := relayinfo.GetList(
nips := []relayinfo.NIP{
relayinfo.BasicProtocol,
relayinfo.Authentication,
relayinfo.EncryptedDirectMessage,
@@ -49,9 +49,14 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) {
relayinfo.ProtectedEvents,
relayinfo.RelayListMetadata,
relayinfo.SearchCapability,
)
}
// Add NIP-43 if enabled
if s.Config.NIP43Enabled {
nips = append(nips, relayinfo.RelayAccessMetadata)
}
supportedNIPs := relayinfo.GetList(nips...)
if s.Config.ACLMode != "none" {
supportedNIPs = relayinfo.GetList(
nipsACL := []relayinfo.NIP{
relayinfo.BasicProtocol,
relayinfo.Authentication,
relayinfo.EncryptedDirectMessage,
@@ -67,7 +72,12 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) {
relayinfo.ProtectedEvents,
relayinfo.RelayListMetadata,
relayinfo.SearchCapability,
)
}
// Add NIP-43 if enabled
if s.Config.NIP43Enabled {
nipsACL = append(nipsACL, relayinfo.RelayAccessMetadata)
}
supportedNIPs = relayinfo.GetList(nipsACL...)
}
sort.Sort(supportedNIPs)
log.I.Ln("supported NIPs", supportedNIPs)

View File

@@ -24,6 +24,7 @@ import (
"next.orly.dev/pkg/encoders/kind"
"next.orly.dev/pkg/encoders/reason"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/utils"
"next.orly.dev/pkg/utils/normalize"
"next.orly.dev/pkg/utils/pointers"
@@ -43,7 +44,6 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
}
return normalize.Error.Errorf(err.Error())
}
log.T.C(
func() string {
return fmt.Sprintf(
@@ -108,6 +108,40 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
// user has read access or better, continue
}
}
// Handle NIP-43 invite request (kind 28935) - ephemeral event
// Check if any filter requests kind 28935
for _, f := range *env.Filters {
if f != nil && f.Kinds != nil {
if f.Kinds.Contains(nip43.KindInviteReq) {
// Generate and send invite event
inviteEvent, err := l.Server.HandleNIP43InviteRequest(l.authedPubkey.Load())
if err != nil {
log.W.F("failed to generate NIP-43 invite: %v", err)
// Send EOSE and return
if err = eoseenvelope.NewFrom(env.Subscription).Write(l); chk.E(err) {
return err
}
return nil
}
// Send the invite event
evEnv, _ := eventenvelope.NewResultWith(env.Subscription, inviteEvent)
if err = evEnv.Write(l); chk.E(err) {
return err
}
// Send EOSE
if err = eoseenvelope.NewFrom(env.Subscription).Write(l); chk.E(err) {
return err
}
log.I.F("sent NIP-43 invite event to %s", l.remote)
return nil
}
}
}
var events event.S
// Create a single context for all filter queries, isolated from the connection context
// to prevent query timeouts from affecting the long-lived websocket connection
@@ -533,24 +567,24 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
)
},
)
log.T.C(
func() string {
return fmt.Sprintf("event:\n%s\n", ev.Serialize())
},
)
var res *eventenvelope.Result
if res, err = eventenvelope.NewResultWith(
env.Subscription, ev,
); chk.E(err) {
return
}
if err = res.Write(l); err != nil {
// Don't log context canceled errors as they're expected during shutdown
if !strings.Contains(err.Error(), "context canceled") {
chk.E(err)
log.T.C(
func() string {
return fmt.Sprintf("event:\n%s\n", ev.Serialize())
},
)
var res *eventenvelope.Result
if res, err = eventenvelope.NewResultWith(
env.Subscription, ev,
); chk.E(err) {
return
}
if err = res.Write(l); err != nil {
// Don't log context canceled errors as they're expected during shutdown
if !strings.Contains(err.Error(), "context canceled") {
chk.E(err)
}
return
}
return
}
// track the IDs we've sent (use hex encoding for stable key)
seen[hexenc.Enc(ev.ID)] = struct{}{}
}
@@ -577,7 +611,7 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
limitSatisfied = true
}
}
if f.Ids.Len() < 1 {
// Filter has no IDs - keep subscription open unless limit was satisfied
if !limitSatisfied {
@@ -616,18 +650,81 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
receiver := make(event.C, 32)
// if the subscription should be cancelled, do so
if !cancel {
// Create a dedicated context for this subscription that's independent of query context
// but is child of the listener context so it gets cancelled when connection closes
subCtx, subCancel := context.WithCancel(l.ctx)
// Track this subscription so we can cancel it on CLOSE or connection close
subID := string(env.Subscription)
l.subscriptionsMu.Lock()
l.subscriptions[subID] = subCancel
l.subscriptionsMu.Unlock()
// Register subscription with publisher
l.publishers.Receive(
&W{
Conn: l.conn,
remote: l.remote,
Id: string(env.Subscription),
Id: subID,
Receiver: receiver,
Filters: &subbedFilters,
AuthedPubkey: l.authedPubkey.Load(),
},
)
// Launch goroutine to consume from receiver channel and forward to client
// This is the critical missing piece - without this, the receiver channel fills up
// and the publisher times out trying to send, causing subscription to be removed
go func() {
defer func() {
// Clean up when subscription ends
l.subscriptionsMu.Lock()
delete(l.subscriptions, subID)
l.subscriptionsMu.Unlock()
log.D.F("subscription goroutine exiting for %s @ %s", subID, l.remote)
}()
for {
select {
case <-subCtx.Done():
// Subscription cancelled (CLOSE message or connection closing)
log.D.F("subscription %s cancelled for %s", subID, l.remote)
return
case ev, ok := <-receiver:
if !ok {
// Channel closed - subscription ended
log.D.F("subscription %s receiver channel closed for %s", subID, l.remote)
return
}
// Forward event to client via write channel
var res *eventenvelope.Result
var err error
if res, err = eventenvelope.NewResultWith(subID, ev); chk.E(err) {
log.E.F("failed to create event envelope for subscription %s: %v", subID, err)
continue
}
// Write to client - this goes through the write worker
if err = res.Write(l); err != nil {
if !strings.Contains(err.Error(), "context canceled") {
log.E.F("failed to write event to subscription %s @ %s: %v", subID, l.remote, err)
}
// Don't return here - write errors shouldn't kill the subscription
// The connection cleanup will handle removing the subscription
continue
}
log.D.F("delivered real-time event %s to subscription %s @ %s",
hexenc.Enc(ev.ID), subID, l.remote)
}
}
}()
log.D.F("subscription %s created and goroutine launched for %s", subID, l.remote)
} else {
// suppress server-sent CLOSED; client will close subscription if desired
log.D.F("subscription request cancelled immediately (all IDs found or limit satisfied)")
}
log.T.F("HandleReq: COMPLETED processing from %s", l.remote)
return

View File

@@ -72,19 +72,20 @@ whitelist:
// Set read limit immediately after connection is established
conn.SetReadLimit(DefaultMaxMessageSize)
log.D.F("set read limit to %d bytes (%d MB) for %s", DefaultMaxMessageSize, DefaultMaxMessageSize/units.Mb, remote)
// Set initial read deadline - pong handler will extend it when pongs are received
conn.SetReadDeadline(time.Now().Add(DefaultPongWait))
// Add pong handler to extend read deadline when client responds to pings
conn.SetPongHandler(func(string) error {
log.T.F("received PONG from %s, extending read deadline", remote)
return conn.SetReadDeadline(time.Now().Add(DefaultPongWait))
})
defer conn.Close()
listener := &Listener{
ctx: ctx,
cancel: cancel,
Server: s,
conn: conn,
remote: remote,
@@ -94,6 +95,7 @@ whitelist:
writeDone: make(chan struct{}),
messageQueue: make(chan messageRequest, 100), // Buffered channel for message processing
processingDone: make(chan struct{}),
subscriptions: make(map[string]context.CancelFunc),
}
// Start write worker goroutine
@@ -131,12 +133,21 @@ whitelist:
defer func() {
log.D.F("closing websocket connection from %s", remote)
// Cancel all active subscriptions first
listener.subscriptionsMu.Lock()
for subID, cancelFunc := range listener.subscriptions {
log.D.F("cancelling subscription %s for %s", subID, remote)
cancelFunc()
}
listener.subscriptions = nil
listener.subscriptionsMu.Unlock()
// Cancel context and stop pinger
cancel()
ticker.Stop()
// Cancel all subscriptions for this connection
log.D.F("cancelling subscriptions for %s", remote)
// Cancel all subscriptions for this connection at publisher level
log.D.F("removing subscriptions from publisher for %s", remote)
listener.publishers.Receive(&W{
Cancel: true,
Conn: listener.conn,
@@ -163,6 +174,12 @@ whitelist:
// Wait for message processor to finish
<-listener.processingDone
// Wait for all spawned message handlers to complete
// This is critical to prevent "send on closed channel" panics
log.D.F("ws->%s waiting for message handlers to complete", remote)
listener.handlerWg.Wait()
log.D.F("ws->%s all message handlers completed", remote)
// Close write channel to signal worker to exit
close(listener.writeChan)
// Wait for write worker to finish

View File

@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
@@ -23,6 +24,7 @@ type Listener struct {
*Server
conn *websocket.Conn
ctx context.Context
cancel context.CancelFunc // Cancel function for this listener's context
remote string
req *http.Request
challenge atomicutils.Bytes
@@ -35,12 +37,16 @@ type Listener struct {
// Message processing queue for async handling
messageQueue chan messageRequest // Buffered channel for message processing
processingDone chan struct{} // Closed when message processor exits
handlerWg sync.WaitGroup // Tracks spawned message handler goroutines
// Flow control counters (atomic for concurrent access)
droppedMessages atomic.Int64 // Messages dropped due to full queue
// Diagnostics: per-connection counters
msgCount int
reqCount int
eventCount int
// Subscription tracking for cleanup
subscriptions map[string]context.CancelFunc // Map of subscription ID to cancel function
subscriptionsMu sync.Mutex // Protects subscriptions map
}
type messageRequest struct {
@@ -80,6 +86,15 @@ func (l *Listener) QueueMessage(data []byte, remote string) bool {
func (l *Listener) Write(p []byte) (n int, err error) {
// Defensive: recover from any panic when sending to closed channel
defer func() {
if r := recover(); r != nil {
log.D.F("ws->%s write panic recovered (channel likely closed): %v", l.remote, r)
err = errorf.E("write channel closed")
n = 0
}
}()
// Send write request to channel - non-blocking with timeout
select {
case <-l.ctx.Done():
@@ -94,6 +109,14 @@ func (l *Listener) Write(p []byte) (n int, err error) {
// WriteControl sends a control message through the write channel
func (l *Listener) WriteControl(messageType int, data []byte, deadline time.Time) (err error) {
// Defensive: recover from any panic when sending to closed channel
defer func() {
if r := recover(); r != nil {
log.D.F("ws->%s writeControl panic recovered (channel likely closed): %v", l.remote, r)
err = errorf.E("write channel closed")
}
}()
select {
case <-l.ctx.Done():
return l.ctx.Err()
@@ -189,8 +212,14 @@ func (l *Listener) messageProcessor() {
return
}
// Process the message synchronously in this goroutine
l.HandleMessage(req.data, req.remote)
// Process the message in a separate goroutine to avoid blocking
// This allows multiple messages to be processed concurrently (like khatru does)
// Track the goroutine so we can wait for it during cleanup
l.handlerWg.Add(1)
go func(data []byte, remote string) {
defer l.handlerWg.Done()
l.HandleMessage(data, remote)
}(req.data, req.remote)
}
}
}

View File

@@ -18,6 +18,7 @@ import (
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/encoders/bech32encoding"
"next.orly.dev/pkg/policy"
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/protocol/publish"
"next.orly.dev/pkg/spider"
dsync "next.orly.dev/pkg/sync"
@@ -68,6 +69,14 @@ func Run(
publishers: publish.New(NewPublisher(ctx)),
Admins: adminKeys,
Owners: ownerKeys,
cfg: cfg,
db: db,
}
// Initialize NIP-43 invite manager if enabled
if cfg.NIP43Enabled {
l.InviteManager = nip43.NewInviteManager(cfg.NIP43InviteExpiry)
log.I.F("NIP-43 invite system enabled with %v expiry", cfg.NIP43InviteExpiry)
}
// Initialize sprocket manager

549
app/nip43_e2e_test.go Normal file
View File

@@ -0,0 +1,549 @@
package app
import (
"next.orly.dev/pkg/interfaces/signer/p8k"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"next.orly.dev/app/config"
"next.orly.dev/pkg/crypto/keys"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/protocol/publish"
"next.orly.dev/pkg/protocol/relayinfo"
)
// setupE2ETest creates a full test server for end-to-end testing
func setupE2ETest(t *testing.T) (*Server, *httptest.Server, func()) {
tempDir, err := os.MkdirTemp("", "nip43_e2e_test_*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
db, err := database.New(ctx, cancel, tempDir, "info")
if err != nil {
os.RemoveAll(tempDir)
t.Fatalf("failed to open database: %v", err)
}
cfg := &config.C{
AppName: "TestRelay",
NIP43Enabled: true,
NIP43PublishEvents: true,
NIP43PublishMemberList: true,
NIP43InviteExpiry: 24 * time.Hour,
RelayURL: "wss://test.relay",
Listen: "localhost",
Port: 3334,
ACLMode: "none",
AuthRequired: false,
}
// Generate admin keys
adminSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate admin secret: %v", err)
}
adminSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create admin signer: %v", err)
}
if err = adminSigner.InitSec(adminSecret); err != nil {
t.Fatalf("failed to initialize admin signer: %v", err)
}
adminPubkey := adminSigner.Pub()
server := &Server{
Ctx: ctx,
Config: cfg,
D: db,
publishers: publish.New(NewPublisher(ctx)),
Admins: [][]byte{adminPubkey},
InviteManager: nip43.NewInviteManager(cfg.NIP43InviteExpiry),
cfg: cfg,
db: db,
}
server.mux = http.NewServeMux()
// Set up HTTP handlers
server.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Accept") == "application/nostr+json" {
server.HandleRelayInfo(w, r)
return
}
http.NotFound(w, r)
})
httpServer := httptest.NewServer(server.mux)
cleanup := func() {
httpServer.Close()
db.Close()
os.RemoveAll(tempDir)
}
return server, httpServer, cleanup
}
// TestE2E_RelayInfoIncludesNIP43 tests that NIP-43 is advertised in relay info
func TestE2E_RelayInfoIncludesNIP43(t *testing.T) {
server, httpServer, cleanup := setupE2ETest(t)
defer cleanup()
// Make request to relay info endpoint
req, err := http.NewRequest("GET", httpServer.URL, nil)
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
req.Header.Set("Accept", "application/nostr+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("failed to make request: %v", err)
}
defer resp.Body.Close()
// Parse relay info
var info relayinfo.T
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
t.Fatalf("failed to decode relay info: %v", err)
}
// Verify NIP-43 is in supported NIPs
hasNIP43 := false
for _, nip := range info.Nips {
if nip == 43 {
hasNIP43 = true
break
}
}
if !hasNIP43 {
t.Error("NIP-43 not advertised in supported_nips")
}
// Verify server name
if info.Name != server.Config.AppName {
t.Errorf("wrong relay name: got %s, want %s", info.Name, server.Config.AppName)
}
}
// TestE2E_CompleteJoinFlow tests the complete user join flow
func TestE2E_CompleteJoinFlow(t *testing.T) {
server, _, cleanup := setupE2ETest(t)
defer cleanup()
// Step 1: Admin requests invite code
adminPubkey := server.Admins[0]
inviteEvent, err := server.HandleNIP43InviteRequest(adminPubkey)
if err != nil {
t.Fatalf("failed to generate invite: %v", err)
}
// Extract invite code
claimTag := inviteEvent.Tags.GetFirst([]byte("claim"))
if claimTag == nil || claimTag.Len() < 2 {
t.Fatal("invite event missing claim tag")
}
inviteCode := string(claimTag.T[1])
// Step 2: User creates join request
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userPubkey, err := keys.SecretBytesToPubKeyBytes(userSecret)
if err != nil {
t.Fatalf("failed to get user pubkey: %v", err)
}
signer, err := keys.SecretBytesToSigner(userSecret)
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
joinEv := event.New()
joinEv.Kind = nip43.KindJoinRequest
copy(joinEv.Pubkey, userPubkey)
joinEv.Tags.Append(tag.NewFromAny("-"))
joinEv.Tags.Append(tag.NewFromAny("claim", inviteCode))
joinEv.CreatedAt = time.Now().Unix()
joinEv.Content = []byte("")
if err = joinEv.Sign(signer); err != nil {
t.Fatalf("failed to sign join event: %v", err)
}
// Step 3: Process join request
listener := &Listener{
Server: server,
ctx: server.Ctx,
}
err = listener.HandleNIP43JoinRequest(joinEv)
if err != nil {
t.Fatalf("failed to handle join request: %v", err)
}
// Step 4: Verify membership
isMember, err := server.D.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if !isMember {
t.Error("user was not added as member")
}
membership, err := server.D.GetNIP43Membership(userPubkey)
if err != nil {
t.Fatalf("failed to get membership: %v", err)
}
if membership.InviteCode != inviteCode {
t.Errorf("wrong invite code: got %s, want %s", membership.InviteCode, inviteCode)
}
}
// TestE2E_InviteCodeReuse tests that invite codes can only be used once
func TestE2E_InviteCodeReuse(t *testing.T) {
server, _, cleanup := setupE2ETest(t)
defer cleanup()
// Generate invite code
code, err := server.InviteManager.GenerateCode()
if err != nil {
t.Fatalf("failed to generate invite code: %v", err)
}
listener := &Listener{
Server: server,
ctx: server.Ctx,
}
// First user uses the code
user1Secret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user1 secret: %v", err)
}
user1Pubkey, err := keys.SecretBytesToPubKeyBytes(user1Secret)
if err != nil {
t.Fatalf("failed to get user1 pubkey: %v", err)
}
signer1, err := keys.SecretBytesToSigner(user1Secret)
if err != nil {
t.Fatalf("failed to create signer1: %v", err)
}
joinEv1 := event.New()
joinEv1.Kind = nip43.KindJoinRequest
copy(joinEv1.Pubkey, user1Pubkey)
joinEv1.Tags.Append(tag.NewFromAny("-"))
joinEv1.Tags.Append(tag.NewFromAny("claim", code))
joinEv1.CreatedAt = time.Now().Unix()
joinEv1.Content = []byte("")
if err = joinEv1.Sign(signer1); err != nil {
t.Fatalf("failed to sign join event 1: %v", err)
}
err = listener.HandleNIP43JoinRequest(joinEv1)
if err != nil {
t.Fatalf("failed to handle join request 1: %v", err)
}
// Verify first user is member
isMember, err := server.D.IsNIP43Member(user1Pubkey)
if err != nil {
t.Fatalf("failed to check user1 membership: %v", err)
}
if !isMember {
t.Error("user1 was not added")
}
// Second user tries to use same code
user2Secret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user2 secret: %v", err)
}
user2Pubkey, err := keys.SecretBytesToPubKeyBytes(user2Secret)
if err != nil {
t.Fatalf("failed to get user2 pubkey: %v", err)
}
signer2, err := keys.SecretBytesToSigner(user2Secret)
if err != nil {
t.Fatalf("failed to create signer2: %v", err)
}
joinEv2 := event.New()
joinEv2.Kind = nip43.KindJoinRequest
copy(joinEv2.Pubkey, user2Pubkey)
joinEv2.Tags.Append(tag.NewFromAny("-"))
joinEv2.Tags.Append(tag.NewFromAny("claim", code))
joinEv2.CreatedAt = time.Now().Unix()
joinEv2.Content = []byte("")
if err = joinEv2.Sign(signer2); err != nil {
t.Fatalf("failed to sign join event 2: %v", err)
}
// Should handle without error but not add user
err = listener.HandleNIP43JoinRequest(joinEv2)
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
// Verify second user is NOT member
isMember, err = server.D.IsNIP43Member(user2Pubkey)
if err != nil {
t.Fatalf("failed to check user2 membership: %v", err)
}
if isMember {
t.Error("user2 was incorrectly added with reused code")
}
}
// TestE2E_MembershipListGeneration tests membership list event generation
func TestE2E_MembershipListGeneration(t *testing.T) {
server, _, cleanup := setupE2ETest(t)
defer cleanup()
listener := &Listener{
Server: server,
ctx: server.Ctx,
}
// Add multiple members
memberCount := 5
members := make([][]byte, memberCount)
for i := 0; i < memberCount; i++ {
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret %d: %v", i, err)
}
userPubkey, err := keys.SecretBytesToPubKeyBytes(userSecret)
if err != nil {
t.Fatalf("failed to get user pubkey %d: %v", i, err)
}
members[i] = userPubkey
// Add directly to database for speed
err = server.D.AddNIP43Member(userPubkey, "code")
if err != nil {
t.Fatalf("failed to add member %d: %v", i, err)
}
}
// Generate membership list
err := listener.publishMembershipList()
if err != nil {
t.Fatalf("failed to publish membership list: %v", err)
}
// Note: In a real test, you would verify the event was published
// through the publishers system. For now, we just verify no error.
}
// TestE2E_ExpiredInviteCode tests that expired codes are rejected
func TestE2E_ExpiredInviteCode(t *testing.T) {
tempDir, err := os.MkdirTemp("", "nip43_expired_test_*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
db, err := database.New(ctx, cancel, tempDir, "info")
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
defer db.Close()
cfg := &config.C{
NIP43Enabled: true,
NIP43InviteExpiry: 1 * time.Millisecond, // Very short expiry
}
ctx := context.Background()
server := &Server{
Ctx: ctx,
Config: cfg,
D: db,
publishers: publish.New(NewPublisher(ctx)),
InviteManager: nip43.NewInviteManager(cfg.NIP43InviteExpiry),
cfg: cfg,
db: db,
}
listener := &Listener{
Server: server,
ctx: ctx,
}
// Generate invite code
code, err := server.InviteManager.GenerateCode()
if err != nil {
t.Fatalf("failed to generate invite code: %v", err)
}
// Wait for expiry
time.Sleep(10 * time.Millisecond)
// Try to use expired code
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userPubkey, err := keys.SecretBytesToPubKeyBytes(userSecret)
if err != nil {
t.Fatalf("failed to get user pubkey: %v", err)
}
signer, err := keys.SecretBytesToSigner(userSecret)
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
joinEv := event.New()
joinEv.Kind = nip43.KindJoinRequest
copy(joinEv.Pubkey, userPubkey)
joinEv.Tags.Append(tag.NewFromAny("-"))
joinEv.Tags.Append(tag.NewFromAny("claim", code))
joinEv.CreatedAt = time.Now().Unix()
joinEv.Content = []byte("")
if err = joinEv.Sign(signer); err != nil {
t.Fatalf("failed to sign event: %v", err)
}
err = listener.HandleNIP43JoinRequest(joinEv)
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
// Verify user was NOT added
isMember, err := db.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if isMember {
t.Error("user was added with expired code")
}
}
// TestE2E_InvalidTimestampRejected tests that events with invalid timestamps are rejected
func TestE2E_InvalidTimestampRejected(t *testing.T) {
server, _, cleanup := setupE2ETest(t)
defer cleanup()
listener := &Listener{
Server: server,
ctx: server.Ctx,
}
// Generate invite code
code, err := server.InviteManager.GenerateCode()
if err != nil {
t.Fatalf("failed to generate invite code: %v", err)
}
// Create user
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userPubkey, err := keys.SecretBytesToPubKeyBytes(userSecret)
if err != nil {
t.Fatalf("failed to get user pubkey: %v", err)
}
signer, err := keys.SecretBytesToSigner(userSecret)
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
// Create join request with timestamp far in the past
joinEv := event.New()
joinEv.Kind = nip43.KindJoinRequest
copy(joinEv.Pubkey, userPubkey)
joinEv.Tags.Append(tag.NewFromAny("-"))
joinEv.Tags.Append(tag.NewFromAny("claim", code))
joinEv.CreatedAt = time.Now().Unix() - 700 // More than 10 minutes ago
joinEv.Content = []byte("")
if err = joinEv.Sign(signer); err != nil {
t.Fatalf("failed to sign event: %v", err)
}
// Should handle without error but not add user
err = listener.HandleNIP43JoinRequest(joinEv)
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
// Verify user was NOT added
isMember, err := server.D.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if isMember {
t.Error("user was added with invalid timestamp")
}
}
// BenchmarkJoinRequestProcessing benchmarks join request processing
func BenchmarkJoinRequestProcessing(b *testing.B) {
tempDir, err := os.MkdirTemp("", "nip43_bench_*")
if err != nil {
b.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
db, err := database.Open(filepath.Join(tempDir, "test.db"), "error")
if err != nil {
b.Fatalf("failed to open database: %v", err)
}
defer db.Close()
cfg := &config.C{
NIP43Enabled: true,
NIP43InviteExpiry: 24 * time.Hour,
}
ctx := context.Background()
server := &Server{
Ctx: ctx,
Config: cfg,
D: db,
publishers: publish.New(NewPublisher(ctx)),
InviteManager: nip43.NewInviteManager(cfg.NIP43InviteExpiry),
cfg: cfg,
db: db,
}
listener := &Listener{
Server: server,
ctx: ctx,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Generate unique user and code for each iteration
userSecret, _ := keys.GenerateSecretKey()
userPubkey, _ := keys.SecretBytesToPubKeyBytes(userSecret)
signer, _ := keys.SecretBytesToSigner(userSecret)
code, _ := server.InviteManager.GenerateCode()
joinEv := event.New()
joinEv.Kind = nip43.KindJoinRequest
copy(joinEv.Pubkey, userPubkey)
joinEv.Tags.Append(tag.NewFromAny("-"))
joinEv.Tags.Append(tag.NewFromAny("claim", code))
joinEv.CreatedAt = time.Now().Unix()
joinEv.Content = []byte("")
joinEv.Sign(signer)
listener.HandleNIP43JoinRequest(joinEv)
}
}

View File

@@ -7,10 +7,8 @@ import (
"time"
"github.com/gorilla/websocket"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/encoders/envelopes/eventenvelope"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/filter"
"next.orly.dev/pkg/encoders/hex"
@@ -29,6 +27,7 @@ type WriteChanMap map[*websocket.Conn]chan publish.WriteRequest
type Subscription struct {
remote string
AuthedPubkey []byte
Receiver event.C // Channel for delivering events to this subscription
*filter.S
}
@@ -121,12 +120,12 @@ func (p *P) Receive(msg typer.T) {
if subs, ok := p.Map[m.Conn]; !ok {
subs = make(map[string]Subscription)
subs[m.Id] = Subscription{
S: m.Filters, remote: m.remote, AuthedPubkey: m.AuthedPubkey,
S: m.Filters, remote: m.remote, AuthedPubkey: m.AuthedPubkey, Receiver: m.Receiver,
}
p.Map[m.Conn] = subs
} else {
subs[m.Id] = Subscription{
S: m.Filters, remote: m.remote, AuthedPubkey: m.AuthedPubkey,
S: m.Filters, remote: m.remote, AuthedPubkey: m.AuthedPubkey, Receiver: m.Receiver,
}
}
}
@@ -144,7 +143,6 @@ func (p *P) Receive(msg typer.T) {
// applies authentication checks if required by the server and skips delivery
// for unauthenticated users when events are privileged.
func (p *P) Deliver(ev *event.E) {
var err error
// Snapshot the deliveries under read lock to avoid holding locks during I/O
p.Mx.RLock()
type delivery struct {
@@ -238,52 +236,30 @@ func (p *P) Deliver(ev *event.E) {
}
}
var res *eventenvelope.Result
if res, err = eventenvelope.NewResultWith(d.id, ev); chk.E(err) {
log.E.F("failed to create event envelope for %s to %s: %v",
hex.Enc(ev.ID), d.sub.remote, err)
// Send event to the subscription's receiver channel
// The consumer goroutine (in handle-req.go) will read from this channel
// and forward it to the client via the write channel
log.D.F("attempting delivery of event %s (kind=%d) to subscription %s @ %s",
hex.Enc(ev.ID), ev.Kind, d.id, d.sub.remote)
// Check if receiver channel exists
if d.sub.Receiver == nil {
log.E.F("subscription %s has nil receiver channel for %s", d.id, d.sub.remote)
continue
}
// Log delivery attempt
msgData := res.Marshal(nil)
log.D.F("attempting delivery of event %s (kind=%d, len=%d) to subscription %s @ %s",
hex.Enc(ev.ID), ev.Kind, len(msgData), d.id, d.sub.remote)
// Get write channel for this connection
p.Mx.RLock()
writeChan, hasChan := p.GetWriteChan(d.w)
stillSubscribed := p.Map[d.w] != nil
p.Mx.RUnlock()
if !stillSubscribed {
log.D.F("skipping delivery to %s - connection no longer subscribed", d.sub.remote)
continue
}
if !hasChan {
log.D.F("skipping delivery to %s - no write channel available", d.sub.remote)
continue
}
// Send to write channel - non-blocking with timeout
// Send to receiver channel - non-blocking with timeout
select {
case <-p.c.Done():
continue
case writeChan <- publish.WriteRequest{Data: msgData, MsgType: websocket.TextMessage, IsControl: false}:
log.D.F("subscription delivery QUEUED: event=%s to=%s sub=%s len=%d",
hex.Enc(ev.ID), d.sub.remote, d.id, len(msgData))
case d.sub.Receiver <- ev:
log.D.F("subscription delivery QUEUED: event=%s to=%s sub=%s",
hex.Enc(ev.ID), d.sub.remote, d.id)
case <-time.After(DefaultWriteTimeout):
log.E.F("subscription delivery TIMEOUT: event=%s to=%s sub=%s",
hex.Enc(ev.ID), d.sub.remote, d.id)
// Check if connection is still valid
p.Mx.RLock()
stillSubscribed = p.Map[d.w] != nil
p.Mx.RUnlock()
if !stillSubscribed {
log.D.F("removing failed subscriber connection: %s", d.sub.remote)
p.removeSubscriber(d.w)
}
// Receiver channel is full - subscription consumer is stuck or slow
// The subscription should be removed by the cleanup logic
}
}
}

View File

@@ -25,6 +25,7 @@ import (
"next.orly.dev/pkg/policy"
"next.orly.dev/pkg/protocol/auth"
"next.orly.dev/pkg/protocol/httpauth"
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/protocol/publish"
"next.orly.dev/pkg/spider"
dsync "next.orly.dev/pkg/sync"
@@ -55,6 +56,9 @@ type Server struct {
relayGroupMgr *dsync.RelayGroupManager
clusterManager *dsync.ClusterManager
blossomServer *blossom.Server
InviteManager *nip43.InviteManager
cfg *config.C
db *database.D
}
// isIPBlacklisted checks if an IP address is blacklisted using the managed ACL system

View File

@@ -0,0 +1,449 @@
package app
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gorilla/websocket"
"next.orly.dev/app/config"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/interfaces/signer/p8k"
"next.orly.dev/pkg/protocol/publish"
)
// createSignedTestEvent creates a properly signed test event for use in tests
func createSignedTestEvent(t *testing.T, kind uint16, content string, tags ...*tag.T) *event.E {
t.Helper()
// Create a signer
signer, err := p8k.New()
if err != nil {
t.Fatalf("Failed to create signer: %v", err)
}
defer signer.Zero()
// Generate a keypair
if err := signer.Generate(); err != nil {
t.Fatalf("Failed to generate keypair: %v", err)
}
// Create event
ev := &event.E{
Kind: kind,
Content: []byte(content),
CreatedAt: time.Now().Unix(),
Tags: &tag.S{},
}
// Add any provided tags
for _, tg := range tags {
*ev.Tags = append(*ev.Tags, tg)
}
// Sign the event (this sets Pubkey, ID, and Sig)
if err := ev.Sign(signer); err != nil {
t.Fatalf("Failed to sign event: %v", err)
}
return ev
}
// TestLongRunningSubscriptionStability verifies that subscriptions remain active
// for extended periods and correctly receive real-time events without dropping.
func TestLongRunningSubscriptionStability(t *testing.T) {
// Create test server
server, cleanup := setupTestServer(t)
defer cleanup()
// Start HTTP test server
httpServer := httptest.NewServer(server)
defer httpServer.Close()
// Convert HTTP URL to WebSocket URL
wsURL := strings.Replace(httpServer.URL, "http://", "ws://", 1)
// Connect WebSocket client
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("Failed to connect WebSocket: %v", err)
}
defer conn.Close()
// Subscribe to kind 1 events
subID := "test-long-running"
reqMsg := fmt.Sprintf(`["REQ","%s",{"kinds":[1]}]`, subID)
if err := conn.WriteMessage(websocket.TextMessage, []byte(reqMsg)); err != nil {
t.Fatalf("Failed to send REQ: %v", err)
}
// Read until EOSE
gotEOSE := false
for !gotEOSE {
_, msg, err := conn.ReadMessage()
if err != nil {
t.Fatalf("Failed to read message: %v", err)
}
if strings.Contains(string(msg), `"EOSE"`) && strings.Contains(string(msg), subID) {
gotEOSE = true
t.Logf("Received EOSE for subscription %s", subID)
}
}
// Set up event counter
var receivedCount atomic.Int64
var mu sync.Mutex
receivedEvents := make(map[string]bool)
// Start goroutine to read events
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
readDone := make(chan struct{})
go func() {
defer close(readDone)
defer func() {
// Recover from any panic in read goroutine
if r := recover(); r != nil {
t.Logf("Read goroutine panic (recovered): %v", r)
}
}()
for {
// Check context first before attempting any read
select {
case <-ctx.Done():
return
default:
}
// Use a longer deadline and check context more frequently
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, msg, err := conn.ReadMessage()
if err != nil {
// Immediately check if context is done - if so, just exit without continuing
if ctx.Err() != nil {
return
}
// Check for normal close
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
return
}
// Check if this is a timeout error - those are recoverable
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
// Double-check context before continuing
if ctx.Err() != nil {
return
}
continue
}
// Any other error means connection is broken, exit
t.Logf("Read error (non-timeout): %v", err)
return
}
// Parse message to check if it's an EVENT for our subscription
var envelope []interface{}
if err := json.Unmarshal(msg, &envelope); err != nil {
continue
}
if len(envelope) >= 3 && envelope[0] == "EVENT" && envelope[1] == subID {
// Extract event ID
eventMap, ok := envelope[2].(map[string]interface{})
if !ok {
continue
}
eventID, ok := eventMap["id"].(string)
if !ok {
continue
}
mu.Lock()
if !receivedEvents[eventID] {
receivedEvents[eventID] = true
receivedCount.Add(1)
t.Logf("Received event %s (total: %d)", eventID[:8], receivedCount.Load())
}
mu.Unlock()
}
}
}()
// Publish events at regular intervals over 30 seconds
const numEvents = 30
const publishInterval = 1 * time.Second
publishCtx, publishCancel := context.WithTimeout(context.Background(), 35*time.Second)
defer publishCancel()
for i := 0; i < numEvents; i++ {
select {
case <-publishCtx.Done():
t.Fatalf("Publish timeout exceeded")
default:
}
// Create and sign test event
ev := createSignedTestEvent(t, 1, fmt.Sprintf("Test event %d for long-running subscription", i))
// Save event to database
if _, err := server.D.SaveEvent(context.Background(), ev); err != nil {
t.Errorf("Failed to save event %d: %v", i, err)
continue
}
// Manually trigger publisher to deliver event to subscriptions
server.publishers.Deliver(ev)
t.Logf("Published event %d", i)
// Wait before next publish
if i < numEvents-1 {
time.Sleep(publishInterval)
}
}
// Wait a bit more for all events to be delivered
time.Sleep(3 * time.Second)
// Cancel context and wait for reader to finish
cancel()
<-readDone
// Check results
received := receivedCount.Load()
t.Logf("Test complete: published %d events, received %d events", numEvents, received)
// We should receive at least 90% of events (allowing for some timing edge cases)
minExpected := int64(float64(numEvents) * 0.9)
if received < minExpected {
t.Errorf("Subscription stability issue: expected at least %d events, got %d", minExpected, received)
}
// Close subscription
closeMsg := fmt.Sprintf(`["CLOSE","%s"]`, subID)
if err := conn.WriteMessage(websocket.TextMessage, []byte(closeMsg)); err != nil {
t.Errorf("Failed to send CLOSE: %v", err)
}
t.Logf("Long-running subscription test PASSED: %d/%d events delivered", received, numEvents)
}
// TestMultipleConcurrentSubscriptions verifies that multiple subscriptions
// can coexist on the same connection without interfering with each other.
func TestMultipleConcurrentSubscriptions(t *testing.T) {
// Create test server
server, cleanup := setupTestServer(t)
defer cleanup()
// Start HTTP test server
httpServer := httptest.NewServer(server)
defer httpServer.Close()
// Convert HTTP URL to WebSocket URL
wsURL := strings.Replace(httpServer.URL, "http://", "ws://", 1)
// Connect WebSocket client
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("Failed to connect WebSocket: %v", err)
}
defer conn.Close()
// Create 3 subscriptions for different kinds
subscriptions := []struct {
id string
kind int
}{
{"sub1", 1},
{"sub2", 3},
{"sub3", 7},
}
// Subscribe to all
for _, sub := range subscriptions {
reqMsg := fmt.Sprintf(`["REQ","%s",{"kinds":[%d]}]`, sub.id, sub.kind)
if err := conn.WriteMessage(websocket.TextMessage, []byte(reqMsg)); err != nil {
t.Fatalf("Failed to send REQ for %s: %v", sub.id, err)
}
}
// Read until we get EOSE for all subscriptions
eoseCount := 0
for eoseCount < len(subscriptions) {
_, msg, err := conn.ReadMessage()
if err != nil {
t.Fatalf("Failed to read message: %v", err)
}
if strings.Contains(string(msg), `"EOSE"`) {
eoseCount++
t.Logf("Received EOSE %d/%d", eoseCount, len(subscriptions))
}
}
// Track received events per subscription
var mu sync.Mutex
receivedByKind := make(map[int]int)
// Start reader goroutine
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
readDone := make(chan struct{})
go func() {
defer close(readDone)
defer func() {
// Recover from any panic in read goroutine
if r := recover(); r != nil {
t.Logf("Read goroutine panic (recovered): %v", r)
}
}()
for {
// Check context first before attempting any read
select {
case <-ctx.Done():
return
default:
}
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, msg, err := conn.ReadMessage()
if err != nil {
// Immediately check if context is done - if so, just exit without continuing
if ctx.Err() != nil {
return
}
// Check for normal close
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
return
}
// Check if this is a timeout error - those are recoverable
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
// Double-check context before continuing
if ctx.Err() != nil {
return
}
continue
}
// Any other error means connection is broken, exit
t.Logf("Read error (non-timeout): %v", err)
return
}
// Parse message
var envelope []interface{}
if err := json.Unmarshal(msg, &envelope); err != nil {
continue
}
if len(envelope) >= 3 && envelope[0] == "EVENT" {
eventMap, ok := envelope[2].(map[string]interface{})
if !ok {
continue
}
kindFloat, ok := eventMap["kind"].(float64)
if !ok {
continue
}
kind := int(kindFloat)
mu.Lock()
receivedByKind[kind]++
t.Logf("Received event for kind %d (count: %d)", kind, receivedByKind[kind])
mu.Unlock()
}
}
}()
// Publish events for each kind
for _, sub := range subscriptions {
for i := 0; i < 5; i++ {
// Create and sign test event
ev := createSignedTestEvent(t, uint16(sub.kind), fmt.Sprintf("Test for kind %d event %d", sub.kind, i))
if _, err := server.D.SaveEvent(context.Background(), ev); err != nil {
t.Errorf("Failed to save event: %v", err)
}
// Manually trigger publisher to deliver event to subscriptions
server.publishers.Deliver(ev)
time.Sleep(100 * time.Millisecond)
}
}
// Wait for events to be delivered
time.Sleep(2 * time.Second)
// Cancel and cleanup
cancel()
<-readDone
// Verify each subscription received its events
mu.Lock()
defer mu.Unlock()
for _, sub := range subscriptions {
count := receivedByKind[sub.kind]
if count < 4 { // Allow for some timing issues, expect at least 4/5
t.Errorf("Subscription %s (kind %d) only received %d/5 events", sub.id, sub.kind, count)
}
}
t.Logf("Multiple concurrent subscriptions test PASSED")
}
// setupTestServer creates a test relay server for subscription testing
func setupTestServer(t *testing.T) (*Server, func()) {
// Setup test database
ctx, cancel := context.WithCancel(context.Background())
// Use a temporary directory for the test database
tmpDir := t.TempDir()
db, err := database.New(ctx, cancel, tmpDir, "test.db")
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
// Setup basic config
cfg := &config.C{
AuthRequired: false,
Owners: []string{},
Admins: []string{},
ACLMode: "none",
}
// Setup server
server := &Server{
Config: cfg,
D: db,
Ctx: ctx,
publishers: publish.New(NewPublisher(ctx)),
Admins: [][]byte{},
Owners: [][]byte{},
challenges: make(map[string][]byte),
}
// Cleanup function
cleanup := func() {
db.Close()
cancel()
}
return server, cleanup
}

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 KiB

View File

@@ -1,69 +0,0 @@
html,
body {
position: relative;
width: 100%;
height: 100%;
}
body {
color: #333;
margin: 0;
padding: 8px;
box-sizing: border-box;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu,
Cantarell, "Helvetica Neue", sans-serif;
}
a {
color: rgb(0, 100, 200);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
a:visited {
color: rgb(0, 80, 160);
}
label {
display: block;
}
input,
button,
select,
textarea {
font-family: inherit;
font-size: inherit;
-webkit-padding: 0.4em 0;
padding: 0.4em;
margin: 0 0 0.5em 0;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 2px;
}
input:disabled {
color: #ccc;
}
button {
color: #333;
background-color: #f4f4f4;
outline: none;
}
button:disabled {
color: #999;
}
button:not(:disabled):active {
background-color: #ddd;
}
button:focus {
border-color: #666;
}

View File

@@ -1,17 +1 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>ORLY?</title>
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="stylesheet" href="/global.css" />
<link rel="stylesheet" href="/bundle.css" />
<script defer src="/bundle.js"></script>
</head>
<body></body>
</html>
test

BIN
app/web/dist/orly.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 514 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,273 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
lol "lol.mleku.dev"
"next.orly.dev/app/config"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/interfaces/signer/p8k"
"next.orly.dev/pkg/policy"
"next.orly.dev/pkg/run"
relaytester "next.orly.dev/relay-tester"
)
// TestClusterPeerPolicyFiltering tests cluster peer synchronization with policy filtering.
// This test:
// 1. Starts multiple relays using the test relay launch functionality
// 2. Configures them as peers to each other (though sync managers are not fully implemented in this test)
// 3. Tests policy filtering with a kind whitelist that allows only specific event kinds
// 4. Verifies that the policy correctly allows/denies events based on the whitelist
//
// Note: This test focuses on the policy filtering aspect of cluster peers.
// Full cluster synchronization testing would require implementing the sync manager
// integration, which is beyond the scope of this initial test.
func TestClusterPeerPolicyFiltering(t *testing.T) {
if testing.Short() {
t.Skip("skipping cluster peer integration test")
}
// Number of relays in the cluster
numRelays := 3
// Start multiple test relays
relays, ports, err := startTestRelays(numRelays)
if err != nil {
t.Fatalf("Failed to start test relays: %v", err)
}
defer func() {
for _, relay := range relays {
if tr, ok := relay.(*testRelay); ok {
if stopErr := tr.Stop(); stopErr != nil {
t.Logf("Error stopping relay: %v", stopErr)
}
}
}
}()
// Create relay URLs
relayURLs := make([]string, numRelays)
for i, port := range ports {
relayURLs[i] = fmt.Sprintf("http://127.0.0.1:%d", port)
}
// Wait for all relays to be ready
for _, url := range relayURLs {
wsURL := strings.Replace(url, "http://", "ws://", 1) // Convert http to ws
if err := waitForTestRelay(wsURL, 10*time.Second); err != nil {
t.Fatalf("Relay not ready after timeout: %s, %v", wsURL, err)
}
t.Logf("Relay is ready at %s", wsURL)
}
// Create policy configuration with small kind whitelist
policyJSON := map[string]interface{}{
"kind": map[string]interface{}{
"whitelist": []int{1, 7, 42}, // Allow only text notes, user statuses, and channel messages
},
"default_policy": "allow", // Allow everything not explicitly denied
}
policyJSONBytes, err := json.MarshalIndent(policyJSON, "", " ")
if err != nil {
t.Fatalf("Failed to marshal policy JSON: %v", err)
}
// Create temporary directory for policy config
tempDir := t.TempDir()
configDir := filepath.Join(tempDir, "ORLY_POLICY")
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatalf("Failed to create config directory: %v", err)
}
policyPath := filepath.Join(configDir, "policy.json")
if err := os.WriteFile(policyPath, policyJSONBytes, 0644); err != nil {
t.Fatalf("Failed to write policy file: %v", err)
}
// Create policy from JSON directly for testing
testPolicy, err := policy.New(policyJSONBytes)
if err != nil {
t.Fatalf("Failed to create policy: %v", err)
}
// Generate test keys
signer := p8k.MustNew()
if err := signer.Generate(); err != nil {
t.Fatalf("Failed to generate test signer: %v", err)
}
// Create test events of different kinds
testEvents := []*event.E{
// Kind 1 (text note) - should be allowed by policy
createTestEvent(t, signer, "Text note - should sync", 1),
// Kind 7 (user status) - should be allowed by policy
createTestEvent(t, signer, "User status - should sync", 7),
// Kind 42 (channel message) - should be allowed by policy
createTestEvent(t, signer, "Channel message - should sync", 42),
// Kind 0 (metadata) - should be denied by policy
createTestEvent(t, signer, "Metadata - should NOT sync", 0),
// Kind 3 (follows) - should be denied by policy
createTestEvent(t, signer, "Follows - should NOT sync", 3),
}
t.Logf("Created %d test events", len(testEvents))
// Publish events to the first relay (non-policy relay)
firstRelayWS := fmt.Sprintf("ws://127.0.0.1:%d", ports[0])
client, err := relaytester.NewClient(firstRelayWS)
if err != nil {
t.Fatalf("Failed to connect to first relay: %v", err)
}
defer client.Close()
// Publish all events to the first relay
for i, ev := range testEvents {
if err := client.Publish(ev); err != nil {
t.Fatalf("Failed to publish event %d: %v", i, err)
}
// Wait for OK response
accepted, reason, err := client.WaitForOK(ev.ID, 5*time.Second)
if err != nil {
t.Fatalf("Failed to get OK response for event %d: %v", i, err)
}
if !accepted {
t.Logf("Event %d rejected: %s (kind: %d)", i, reason, ev.Kind)
} else {
t.Logf("Event %d accepted (kind: %d)", i, ev.Kind)
}
}
// Test policy filtering directly
t.Logf("Testing policy filtering...")
// Test that the policy correctly allows/denies events based on the whitelist
// Only kinds 1, 7, and 42 should be allowed
for i, ev := range testEvents {
allowed, err := testPolicy.CheckPolicy("write", ev, signer.Pub(), "127.0.0.1")
if err != nil {
t.Fatalf("Policy check failed for event %d: %v", i, err)
}
expectedAllowed := ev.Kind == 1 || ev.Kind == 7 || ev.Kind == 42
if allowed != expectedAllowed {
t.Errorf("Event %d (kind %d): expected allowed=%v, got %v", i, ev.Kind, expectedAllowed, allowed)
}
}
t.Logf("Policy filtering test completed successfully")
// Note: In a real cluster setup, the sync manager would use this policy
// to filter events during synchronization between peers. This test demonstrates
// that the policy correctly identifies which events should be allowed to sync.
}
// testRelay wraps a run.Relay for testing purposes
type testRelay struct {
*run.Relay
}
// startTestRelays starts multiple test relays with different configurations
func startTestRelays(count int) ([]interface{}, []int, error) {
relays := make([]interface{}, count)
ports := make([]int, count)
for i := 0; i < count; i++ {
cfg := &config.C{
AppName: fmt.Sprintf("ORLY-TEST-%d", i),
DataDir: "", // Use temp dir
Listen: "127.0.0.1",
Port: 0, // Random port
HealthPort: 0,
EnableShutdown: false,
LogLevel: "warn",
DBLogLevel: "warn",
DBBlockCacheMB: 512,
DBIndexCacheMB: 256,
LogToStdout: false,
PprofHTTP: false,
ACLMode: "none",
AuthRequired: false,
AuthToWrite: false,
SubscriptionEnabled: false,
MonthlyPriceSats: 6000,
FollowListFrequency: time.Hour,
WebDisableEmbedded: false,
SprocketEnabled: false,
SpiderMode: "none",
PolicyEnabled: false, // We'll enable it separately for one relay
}
// Find available port
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, nil, fmt.Errorf("failed to find available port for relay %d: %w", i, err)
}
addr := listener.Addr().(*net.TCPAddr)
cfg.Port = addr.Port
listener.Close()
// Set up logging
lol.SetLogLevel(cfg.LogLevel)
opts := &run.Options{
CleanupDataDir: func(b bool) *bool { return &b }(true),
}
relay, err := run.Start(cfg, opts)
if err != nil {
return nil, nil, fmt.Errorf("failed to start relay %d: %w", i, err)
}
relays[i] = &testRelay{Relay: relay}
ports[i] = cfg.Port
}
return relays, ports, nil
}
// waitForTestRelay waits for a relay to be ready by attempting to connect
func waitForTestRelay(url string, timeout time.Duration) error {
// Extract host:port from ws:// URL
addr := url
if len(url) > 5 && url[:5] == "ws://" {
addr = url[5:]
}
deadline := time.Now().Add(timeout)
attempts := 0
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
if err == nil {
conn.Close()
return nil
}
attempts++
time.Sleep(100 * time.Millisecond)
}
return fmt.Errorf("timeout waiting for relay at %s after %d attempts", url, attempts)
}
// createTestEvent creates a test event with proper signing
func createTestEvent(t *testing.T, signer *p8k.Signer, content string, eventKind uint16) *event.E {
ev := event.New()
ev.CreatedAt = time.Now().Unix()
ev.Kind = eventKind
ev.Content = []byte(content)
ev.Tags = tag.NewS()
// Sign the event
if err := ev.Sign(signer); err != nil {
t.Fatalf("Failed to sign test event: %v", err)
}
return ev
}

283
cmd/find/main.go Normal file
View File

@@ -0,0 +1,283 @@
package main
import (
"fmt"
"os"
"time"
"next.orly.dev/pkg/crypto/keys"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/find"
"next.orly.dev/pkg/interfaces/signer"
"next.orly.dev/pkg/interfaces/signer/p8k"
)
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
command := os.Args[1]
switch command {
case "register":
handleRegister()
case "transfer":
handleTransfer()
case "verify-name":
handleVerifyName()
case "generate-key":
handleGenerateKey()
case "issue-cert":
handleIssueCert()
case "help":
printUsage()
default:
fmt.Printf("Unknown command: %s\n\n", command)
printUsage()
os.Exit(1)
}
}
func printUsage() {
fmt.Println("FIND - Free Internet Name Daemon")
fmt.Println("Usage: find <command> [options]")
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" register <name> Create a registration proposal for a name")
fmt.Println(" transfer <name> <new-owner> Transfer a name to a new owner")
fmt.Println(" verify-name <name> Validate a name format")
fmt.Println(" generate-key Generate a new key pair")
fmt.Println(" issue-cert <name> Issue a certificate for a name")
fmt.Println(" help Show this help message")
fmt.Println()
fmt.Println("Examples:")
fmt.Println(" find verify-name example.com")
fmt.Println(" find register myname.nostr")
fmt.Println(" find generate-key")
}
func handleRegister() {
if len(os.Args) < 3 {
fmt.Println("Usage: find register <name>")
os.Exit(1)
}
name := os.Args[2]
// Validate the name
if err := find.ValidateName(name); err != nil {
fmt.Printf("Invalid name: %v\n", err)
os.Exit(1)
}
// Generate a key pair for this example
// In production, this would load from a secure keystore
signer, err := p8k.New()
if err != nil {
fmt.Printf("Failed to create signer: %v\n", err)
os.Exit(1)
}
if err := signer.Generate(); err != nil {
fmt.Printf("Failed to generate key: %v\n", err)
os.Exit(1)
}
// Create registration proposal
proposal, err := find.NewRegistrationProposal(name, find.ActionRegister, signer)
if err != nil {
fmt.Printf("Failed to create proposal: %v\n", err)
os.Exit(1)
}
fmt.Printf("Registration Proposal Created\n")
fmt.Printf("==============================\n")
fmt.Printf("Name: %s\n", name)
fmt.Printf("Pubkey: %s\n", hex.Enc(signer.Pub()))
fmt.Printf("Event ID: %s\n", hex.Enc(proposal.GetIDBytes()))
fmt.Printf("Kind: %d\n", proposal.Kind)
fmt.Printf("Created At: %s\n", time.Unix(proposal.CreatedAt, 0))
fmt.Printf("\nEvent JSON:\n")
json := proposal.Marshal(nil)
fmt.Println(string(json))
}
func handleTransfer() {
if len(os.Args) < 4 {
fmt.Println("Usage: find transfer <name> <new-owner-pubkey>")
os.Exit(1)
}
name := os.Args[2]
newOwnerPubkey := os.Args[3]
// Validate the name
if err := find.ValidateName(name); err != nil {
fmt.Printf("Invalid name: %v\n", err)
os.Exit(1)
}
// Generate current owner key (in production, load from keystore)
currentOwner, err := p8k.New()
if err != nil {
fmt.Printf("Failed to create current owner signer: %v\n", err)
os.Exit(1)
}
if err := currentOwner.Generate(); err != nil {
fmt.Printf("Failed to generate current owner key: %v\n", err)
os.Exit(1)
}
// Authorize the transfer
prevSig, timestamp, err := find.AuthorizeTransfer(name, newOwnerPubkey, currentOwner)
if err != nil {
fmt.Printf("Failed to authorize transfer: %v\n", err)
os.Exit(1)
}
fmt.Printf("Transfer Authorization Created\n")
fmt.Printf("===============================\n")
fmt.Printf("Name: %s\n", name)
fmt.Printf("Current Owner: %s\n", hex.Enc(currentOwner.Pub()))
fmt.Printf("New Owner: %s\n", newOwnerPubkey)
fmt.Printf("Timestamp: %s\n", timestamp)
fmt.Printf("Signature: %s\n", prevSig)
fmt.Printf("\nTo complete the transfer, the new owner must create a proposal with:")
fmt.Printf(" prev_owner: %s\n", hex.Enc(currentOwner.Pub()))
fmt.Printf(" prev_sig: %s\n", prevSig)
}
func handleVerifyName() {
if len(os.Args) < 3 {
fmt.Println("Usage: find verify-name <name>")
os.Exit(1)
}
name := os.Args[2]
// Validate the name
if err := find.ValidateName(name); err != nil {
fmt.Printf("❌ Invalid name: %v\n", err)
os.Exit(1)
}
normalized := find.NormalizeName(name)
isTLD := find.IsTLD(normalized)
parent := find.GetParentDomain(normalized)
fmt.Printf("✓ Valid name\n")
fmt.Printf("==============\n")
fmt.Printf("Original: %s\n", name)
fmt.Printf("Normalized: %s\n", normalized)
fmt.Printf("Is TLD: %v\n", isTLD)
if parent != "" {
fmt.Printf("Parent: %s\n", parent)
}
}
func handleGenerateKey() {
// Generate a new key pair
secKey, err := keys.GenerateSecretKey()
if err != nil {
fmt.Printf("Failed to generate secret key: %v\n", err)
os.Exit(1)
}
secKeyHex := hex.Enc(secKey)
pubKeyHex, err := keys.GetPublicKeyHex(secKeyHex)
if err != nil {
fmt.Printf("Failed to derive public key: %v\n", err)
os.Exit(1)
}
fmt.Println("New Key Pair Generated")
fmt.Println("======================")
fmt.Printf("Secret Key (keep safe!): %s\n", secKeyHex)
fmt.Printf("Public Key: %s\n", pubKeyHex)
fmt.Println()
fmt.Println("⚠️ IMPORTANT: Store the secret key securely. Anyone with access to it can control your names.")
}
func handleIssueCert() {
if len(os.Args) < 3 {
fmt.Println("Usage: find issue-cert <name>")
os.Exit(1)
}
name := os.Args[2]
// Validate the name
if err := find.ValidateName(name); err != nil {
fmt.Printf("Invalid name: %v\n", err)
os.Exit(1)
}
// Generate name owner key
owner, err := p8k.New()
if err != nil {
fmt.Printf("Failed to create owner signer: %v\n", err)
os.Exit(1)
}
if err := owner.Generate(); err != nil {
fmt.Printf("Failed to generate owner key: %v\n", err)
os.Exit(1)
}
// Generate certificate key (different from name owner)
certSigner, err := p8k.New()
if err != nil {
fmt.Printf("Failed to create cert signer: %v\n", err)
os.Exit(1)
}
if err := certSigner.Generate(); err != nil {
fmt.Printf("Failed to generate cert key: %v\n", err)
os.Exit(1)
}
certPubkey := hex.Enc(certSigner.Pub())
// Generate 3 witness signers (in production, these would be separate services)
var witnesses []signer.I
for i := 0; i < 3; i++ {
witness, err := p8k.New()
if err != nil {
fmt.Printf("Failed to create witness %d: %v\n", i, err)
os.Exit(1)
}
if err := witness.Generate(); err != nil {
fmt.Printf("Failed to generate witness %d key: %v\n", i, err)
os.Exit(1)
}
witnesses = append(witnesses, witness)
}
// Issue certificate (90 day validity)
cert, err := find.IssueCertificate(name, certPubkey, find.CertificateValidity, owner, witnesses)
if err != nil {
fmt.Printf("Failed to issue certificate: %v\n", err)
os.Exit(1)
}
fmt.Printf("Certificate Issued\n")
fmt.Printf("==================\n")
fmt.Printf("Name: %s\n", cert.Name)
fmt.Printf("Cert Pubkey: %s\n", cert.CertPubkey)
fmt.Printf("Valid From: %s\n", cert.ValidFrom)
fmt.Printf("Valid Until: %s\n", cert.ValidUntil)
fmt.Printf("Challenge: %s\n", cert.Challenge)
fmt.Printf("Witnesses: %d\n", len(cert.Witnesses))
fmt.Printf("Algorithm: %s\n", cert.Algorithm)
fmt.Printf("Usage: %s\n", cert.Usage)
fmt.Printf("\nWitness Pubkeys:\n")
for i, w := range cert.Witnesses {
fmt.Printf(" %d: %s\n", i+1, w.Pubkey)
}
}

View File

@@ -0,0 +1,268 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/gorilla/websocket"
)
var (
relayURL = flag.String("url", "ws://localhost:3334", "Relay WebSocket URL")
duration = flag.Int("duration", 120, "Test duration in seconds")
)
func main() {
flag.Parse()
log.SetFlags(log.Ltime)
fmt.Println("===================================")
fmt.Println("Simple Subscription Stability Test")
fmt.Println("===================================")
fmt.Printf("Relay: %s\n", *relayURL)
fmt.Printf("Duration: %d seconds\n", *duration)
fmt.Println()
fmt.Println("This test verifies that subscriptions remain")
fmt.Println("active without dropping over the test period.")
fmt.Println()
// Connect to relay
log.Printf("Connecting to %s...", *relayURL)
conn, _, err := websocket.DefaultDialer.Dial(*relayURL, nil)
if err != nil {
log.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
log.Printf("✓ Connected")
// Context for the test
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*duration+10)*time.Second)
defer cancel()
// Handle interrupts
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigChan
log.Println("\nInterrupted, shutting down...")
cancel()
}()
// Subscribe
subID := fmt.Sprintf("stability-test-%d", time.Now().Unix())
reqMsg := []interface{}{"REQ", subID, map[string]interface{}{"kinds": []int{1}}}
reqMsgBytes, _ := json.Marshal(reqMsg)
log.Printf("Sending subscription: %s", subID)
if err := conn.WriteMessage(websocket.TextMessage, reqMsgBytes); err != nil {
log.Fatalf("Failed to send REQ: %v", err)
}
// Track connection health
lastMessageTime := time.Now()
gotEOSE := false
messageCount := 0
pingCount := 0
// Read goroutine
readDone := make(chan struct{})
go func() {
defer close(readDone)
for {
select {
case <-ctx.Done():
return
default:
}
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
msgType, msg, err := conn.ReadMessage()
if err != nil {
if ctx.Err() != nil {
return
}
if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
continue
}
log.Printf("Read error: %v", err)
return
}
lastMessageTime = time.Now()
messageCount++
// Handle PING
if msgType == websocket.PingMessage {
pingCount++
log.Printf("Received PING #%d, sending PONG", pingCount)
conn.WriteMessage(websocket.PongMessage, nil)
continue
}
// Parse message
var envelope []json.RawMessage
if err := json.Unmarshal(msg, &envelope); err != nil {
continue
}
if len(envelope) < 2 {
continue
}
var msgTypeStr string
json.Unmarshal(envelope[0], &msgTypeStr)
switch msgTypeStr {
case "EOSE":
var recvSubID string
json.Unmarshal(envelope[1], &recvSubID)
if recvSubID == subID && !gotEOSE {
gotEOSE = true
log.Printf("✓ Received EOSE - subscription is active")
}
case "EVENT":
var recvSubID string
json.Unmarshal(envelope[1], &recvSubID)
if recvSubID == subID {
log.Printf("Received EVENT (subscription still active)")
}
case "CLOSED":
var recvSubID string
json.Unmarshal(envelope[1], &recvSubID)
if recvSubID == subID {
log.Printf("⚠ Subscription CLOSED by relay!")
cancel()
return
}
case "NOTICE":
var notice string
json.Unmarshal(envelope[1], &notice)
log.Printf("NOTICE: %s", notice)
}
}
}()
// Wait for EOSE
log.Println("Waiting for EOSE...")
for !gotEOSE && ctx.Err() == nil {
time.Sleep(100 * time.Millisecond)
}
if !gotEOSE {
log.Fatal("Did not receive EOSE")
}
// Monitor loop
startTime := time.Now()
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
log.Println()
log.Printf("Subscription is active. Monitoring for %d seconds...", *duration)
log.Println("(Subscription should stay active even without events)")
log.Println()
for {
select {
case <-ctx.Done():
goto done
case <-ticker.C:
elapsed := time.Since(startTime)
timeSinceMessage := time.Since(lastMessageTime)
log.Printf("[%3.0fs/%ds] Messages: %d | Last message: %.0fs ago | Status: %s",
elapsed.Seconds(),
*duration,
messageCount,
timeSinceMessage.Seconds(),
getStatus(timeSinceMessage),
)
// Check if we've reached duration
if elapsed >= time.Duration(*duration)*time.Second {
goto done
}
}
}
done:
cancel()
// Wait for reader
select {
case <-readDone:
case <-time.After(2 * time.Second):
}
// Send CLOSE
closeMsg := []interface{}{"CLOSE", subID}
closeMsgBytes, _ := json.Marshal(closeMsg)
conn.WriteMessage(websocket.TextMessage, closeMsgBytes)
// Results
elapsed := time.Since(startTime)
timeSinceMessage := time.Since(lastMessageTime)
fmt.Println()
fmt.Println("===================================")
fmt.Println("Test Results")
fmt.Println("===================================")
fmt.Printf("Duration: %.1f seconds\n", elapsed.Seconds())
fmt.Printf("Total messages: %d\n", messageCount)
fmt.Printf("Last message: %.0f seconds ago\n", timeSinceMessage.Seconds())
fmt.Println()
// Determine success
if timeSinceMessage < 15*time.Second {
// Recent message - subscription is alive
fmt.Println("✓ TEST PASSED")
fmt.Println("Subscription remained active throughout test period.")
fmt.Println("Recent messages indicate healthy connection.")
} else if timeSinceMessage < 30*time.Second {
// Somewhat recent - probably OK
fmt.Println("✓ TEST LIKELY PASSED")
fmt.Println("Subscription appears active (message received recently).")
fmt.Println("Some delay is normal if relay is idle.")
} else if messageCount > 0 {
// Got EOSE but nothing since
fmt.Println("⚠ INCONCLUSIVE")
fmt.Println("Subscription was established but no activity since.")
fmt.Println("This is expected if relay has no events and doesn't send pings.")
fmt.Println("To properly test, publish events during the test period.")
} else {
// No messages at all
fmt.Println("✗ TEST FAILED")
fmt.Println("No messages received - subscription may have failed.")
}
fmt.Println()
fmt.Println("Note: This test verifies the subscription stays registered.")
fmt.Println("For full testing, publish events while this runs and verify")
fmt.Println("they are received throughout the entire test duration.")
}
func getStatus(timeSince time.Duration) string {
seconds := timeSince.Seconds()
switch {
case seconds < 10:
return "ACTIVE (recent message)"
case seconds < 30:
return "IDLE (normal)"
case seconds < 60:
return "QUIET (possibly normal)"
default:
return "STALE (may have dropped)"
}
}

View File

@@ -0,0 +1,347 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"os/signal"
"sync/atomic"
"syscall"
"time"
"github.com/gorilla/websocket"
)
var (
relayURL = flag.String("url", "ws://localhost:3334", "Relay WebSocket URL")
duration = flag.Int("duration", 60, "Test duration in seconds")
eventKind = flag.Int("kind", 1, "Event kind to subscribe to")
verbose = flag.Bool("v", false, "Verbose output")
subID = flag.String("sub", "", "Subscription ID (default: auto-generated)")
)
type NostrEvent struct {
ID string `json:"id"`
PubKey string `json:"pubkey"`
CreatedAt int64 `json:"created_at"`
Kind int `json:"kind"`
Tags [][]string `json:"tags"`
Content string `json:"content"`
Sig string `json:"sig"`
}
func main() {
flag.Parse()
log.SetFlags(log.Ltime | log.Lmicroseconds)
// Generate subscription ID if not provided
subscriptionID := *subID
if subscriptionID == "" {
subscriptionID = fmt.Sprintf("test-%d", time.Now().Unix())
}
log.Printf("Starting subscription stability test")
log.Printf("Relay: %s", *relayURL)
log.Printf("Duration: %d seconds", *duration)
log.Printf("Event kind: %d", *eventKind)
log.Printf("Subscription ID: %s", subscriptionID)
log.Println()
// Connect to relay
log.Printf("Connecting to %s...", *relayURL)
conn, _, err := websocket.DefaultDialer.Dial(*relayURL, nil)
if err != nil {
log.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
log.Printf("✓ Connected")
log.Println()
// Context for the test
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*duration+10)*time.Second)
defer cancel()
// Handle interrupts
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigChan
log.Println("\nInterrupted, shutting down...")
cancel()
}()
// Counters
var receivedCount atomic.Int64
var lastEventTime atomic.Int64
lastEventTime.Store(time.Now().Unix())
// Subscribe
reqMsg := map[string]interface{}{
"kinds": []int{*eventKind},
}
reqMsgBytes, _ := json.Marshal(reqMsg)
subscribeMsg := []interface{}{"REQ", subscriptionID, json.RawMessage(reqMsgBytes)}
subscribeMsgBytes, _ := json.Marshal(subscribeMsg)
log.Printf("Sending REQ: %s", string(subscribeMsgBytes))
if err := conn.WriteMessage(websocket.TextMessage, subscribeMsgBytes); err != nil {
log.Fatalf("Failed to send REQ: %v", err)
}
// Read messages
gotEOSE := false
readDone := make(chan struct{})
consecutiveTimeouts := 0
maxConsecutiveTimeouts := 20 // Exit if we get too many consecutive timeouts
go func() {
defer close(readDone)
for {
select {
case <-ctx.Done():
return
default:
}
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
_, msg, err := conn.ReadMessage()
if err != nil {
// Check for normal close
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
log.Println("Connection closed normally")
return
}
// Check if context was cancelled
if ctx.Err() != nil {
return
}
// Check for timeout errors (these are expected during idle periods)
if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
consecutiveTimeouts++
if consecutiveTimeouts >= maxConsecutiveTimeouts {
log.Printf("Too many consecutive read timeouts (%d), connection may be dead", consecutiveTimeouts)
return
}
// Only log every 5th timeout to avoid spam
if *verbose && consecutiveTimeouts%5 == 0 {
log.Printf("Read timeout (idle period, %d consecutive)", consecutiveTimeouts)
}
continue
}
// For any other error, log and exit
log.Printf("Read error: %v", err)
return
}
// Reset timeout counter on successful read
consecutiveTimeouts = 0
// Parse message
var envelope []json.RawMessage
if err := json.Unmarshal(msg, &envelope); err != nil {
if *verbose {
log.Printf("Failed to parse message: %v", err)
}
continue
}
if len(envelope) < 2 {
continue
}
var msgType string
json.Unmarshal(envelope[0], &msgType)
// Check message type
switch msgType {
case "EOSE":
var recvSubID string
json.Unmarshal(envelope[1], &recvSubID)
if recvSubID == subscriptionID {
if !gotEOSE {
gotEOSE = true
log.Printf("✓ Received EOSE - subscription is active")
log.Println()
log.Println("Waiting for real-time events...")
log.Println()
}
}
case "EVENT":
var recvSubID string
json.Unmarshal(envelope[1], &recvSubID)
if recvSubID == subscriptionID {
var event NostrEvent
if err := json.Unmarshal(envelope[2], &event); err == nil {
count := receivedCount.Add(1)
lastEventTime.Store(time.Now().Unix())
eventIDShort := event.ID
if len(eventIDShort) > 8 {
eventIDShort = eventIDShort[:8]
}
log.Printf("[EVENT #%d] id=%s kind=%d created=%d",
count, eventIDShort, event.Kind, event.CreatedAt)
if *verbose {
log.Printf(" content: %s", event.Content)
}
}
}
case "NOTICE":
var notice string
json.Unmarshal(envelope[1], &notice)
log.Printf("[NOTICE] %s", notice)
case "CLOSED":
var recvSubID, reason string
json.Unmarshal(envelope[1], &recvSubID)
if len(envelope) > 2 {
json.Unmarshal(envelope[2], &reason)
}
if recvSubID == subscriptionID {
log.Printf("⚠ Subscription CLOSED by relay: %s", reason)
cancel()
return
}
case "OK":
// Ignore OK messages for this test
default:
if *verbose {
log.Printf("Unknown message type: %s", msgType)
}
}
}
}()
// Wait for EOSE with timeout
eoseTimeout := time.After(10 * time.Second)
for !gotEOSE {
select {
case <-eoseTimeout:
log.Printf("⚠ Warning: No EOSE received within 10 seconds")
gotEOSE = true // Continue anyway
case <-ctx.Done():
log.Println("Test cancelled before EOSE")
return
case <-time.After(100 * time.Millisecond):
// Keep waiting
}
}
// Monitor for subscription drops
startTime := time.Now()
endTime := startTime.Add(time.Duration(*duration) * time.Second)
// Start monitoring goroutine
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
elapsed := time.Since(startTime).Seconds()
lastEvent := lastEventTime.Load()
timeSinceLastEvent := time.Now().Unix() - lastEvent
log.Printf("[STATUS] Elapsed: %.0fs/%ds | Events: %d | Last event: %ds ago",
elapsed, *duration, receivedCount.Load(), timeSinceLastEvent)
// Warn if no events for a while (but only if we've seen events before)
if receivedCount.Load() > 0 && timeSinceLastEvent > 30 {
log.Printf("⚠ Warning: No events received for %ds - subscription may have dropped", timeSinceLastEvent)
}
}
}
}()
// Wait for test duration
log.Printf("Test running for %d seconds...", *duration)
log.Println("(You can publish events to the relay in another terminal)")
log.Println()
select {
case <-ctx.Done():
// Test completed or interrupted
case <-time.After(time.Until(endTime)):
// Duration elapsed
}
// Wait a bit for final events
time.Sleep(2 * time.Second)
cancel()
// Wait for reader to finish
select {
case <-readDone:
case <-time.After(5 * time.Second):
log.Println("Reader goroutine didn't exit cleanly")
}
// Send CLOSE
closeMsg := []interface{}{"CLOSE", subscriptionID}
closeMsgBytes, _ := json.Marshal(closeMsg)
conn.WriteMessage(websocket.TextMessage, closeMsgBytes)
// Print results
log.Println()
log.Println("===================================")
log.Println("Test Results")
log.Println("===================================")
log.Printf("Duration: %.1f seconds", time.Since(startTime).Seconds())
log.Printf("Events received: %d", receivedCount.Load())
log.Printf("Subscription ID: %s", subscriptionID)
lastEvent := lastEventTime.Load()
if lastEvent > startTime.Unix() {
log.Printf("Last event: %ds ago", time.Now().Unix()-lastEvent)
}
log.Println()
// Determine pass/fail
received := receivedCount.Load()
testDuration := time.Since(startTime).Seconds()
if received == 0 {
log.Println("⚠ No events received during test")
log.Println("This is expected if no events were published")
log.Println("To test properly, publish events while this is running:")
log.Println()
log.Println(" # In another terminal:")
log.Printf(" ./orly # Make sure relay is running\n")
log.Println()
log.Println(" # Then publish test events (implementation-specific)")
} else {
eventsPerSecond := float64(received) / testDuration
log.Printf("Rate: %.2f events/second", eventsPerSecond)
lastEvent := lastEventTime.Load()
timeSinceLastEvent := time.Now().Unix() - lastEvent
if timeSinceLastEvent < 10 {
log.Println()
log.Println("✓ TEST PASSED - Subscription remained stable")
log.Println("Events were received recently, indicating subscription is still active")
} else {
log.Println()
log.Printf("⚠ Potential issue - Last event was %ds ago", timeSinceLastEvent)
log.Println("Subscription may have dropped if events were still being published")
}
}
}

View File

@@ -0,0 +1,277 @@
# Strfry WebSocket Implementation - Complete Analysis
This directory contains a comprehensive analysis of how strfry implements WebSocket handling for Nostr relays in C++.
## Documents Included
### 1. `strfry_websocket_analysis.md` (1138 lines)
**Complete reference guide covering:**
- WebSocket library selection and connection setup (uWebSockets fork)
- Message parsing and serialization (JSON → binary packed format)
- Event handling and subscription management (filters, indexing)
- Connection management and cleanup (lifecycle, graceful shutdown)
- Performance optimizations specific to C++ (move semantics, batching, etc.)
- Architecture summary with diagrams
- Code complexity analysis
- References and related files
**Key Sections:**
1. WebSocket Library & Connection Setup
2. Message Parsing and Serialization
3. Event Handling and Subscription Management
4. Connection Management and Cleanup
5. Performance Optimizations Specific to C++
6. Architecture Summary Diagram
7. Key Statistics and Tuning
8. Code Complexity Summary
### 2. `strfry_websocket_quick_reference.md`
**Quick lookup guide for:**
- Architecture points and thread pools
- Critical data structures
- Event batching optimization
- Connection lifecycle
- Performance techniques with specific file:line references
- Configuration parameters
- Nostr protocol message types
- Filter processing pipeline
- Bandwidth tracking
- Scalability features
- Key insights (10 actionable takeaways)
### 3. `strfry_websocket_code_flow.md`
**Detailed code flow examples:**
1. Connection Establishment Flow
2. Incoming Message Processing Flow
3. Event Submission Flow (validation → database → acknowledgment)
4. Subscription Request (REQ) Flow
5. Event Broadcasting Flow (critical batching optimization)
6. Connection Disconnection Flow
7. Thread Pool Message Dispatch (deterministic routing)
8. Message Type Dispatch Pattern (std::variant routing)
9. Subscription Lifecycle Summary
10. Error Handling Flow
**Each section includes:**
- Exact file paths and line numbers
- Full code examples with inline comments
- Step-by-step execution trace
- Performance impact analysis
## Repository Information
**Source:** https://github.com/hoytech/strfry
**Local Clone:** `/tmp/strfry/`
## Key Findings Summary
### Architecture
- **Single WebSocket thread** uses epoll for connection multiplexing (thousands of concurrent connections)
- **Multiple worker threads** (Ingester, Writer, ReqWorker, ReqMonitor, Negentropy) communicate via message queues
- **"Shared nothing" design** eliminates lock contention for connection state
### WebSocket Library
- **uWebSockets fork** (custom from hoytech)
- Event-driven architecture (epoll on Linux, IOCP on Windows)
- Built-in permessage-deflate compression with sliding window
- Callbacks for connection, disconnection, message reception
### Message Flow
```
WebSocket Thread (I/O) → Ingester Threads (validation)
→ Writer Thread (DB) → ReqMonitor Threads (filtering)
→ WebSocket Thread (sending)
```
### Critical Optimizations
1. **Event Batching for Broadcast**
- Single event JSON serialization
- Reusable buffer with variable subscription ID offset
- One memcpy per subscriber, not per message
- Huge CPU and memory savings at scale
2. **Move Semantics**
- Messages moved between threads without copying
- Zero-copy thread communication via std::move
- RAII ensures cleanup
3. **std::variant Type Dispatch**
- Type-safe message routing without virtual functions
- Compiler-optimized branching
- All data inline in variant (no heap allocation)
4. **Thread Pool Hash Distribution**
- `connId % numThreads` for deterministic assignment
- Improves cache locality
- Reduces lock contention
5. **Lazy Response Caching**
- NIP-11 HTTP responses pre-generated and cached
- Only regenerated when config changes
- Template system for HTML generation
6. **Compression with Dictionaries**
- ZSTD dictionaries trained on Nostr event format
- Dictionary caching avoids repeated lookups
- Sliding window for better compression ratios
7. **Batched Queue Operations**
- Single lock acquisition per message batch
- Amortizes synchronization overhead
- Improves throughput
8. **Pre-allocated Buffers**
- Avoid allocations in hot path
- Single buffer reused across messages
- Reserve with maximum event size
## File Structure
```
strfry/src/
├── WSConnection.h (175 lines) - Client WebSocket wrapper
├── Subscription.h (69 lines) - Subscription data structure
├── ThreadPool.h (61 lines) - Generic thread pool template
├── Decompressor.h (68 lines) - ZSTD decompression with cache
├── WriterPipeline.h (209 lines) - Batched database writes
├── ActiveMonitors.h (235 lines) - Subscription indexing
├── apps/relay/
│ ├── RelayWebsocket.cpp (327 lines) - Main WebSocket server + event loop
│ ├── RelayIngester.cpp (170 lines) - Message parsing + validation
│ ├── RelayReqWorker.cpp (45 lines) - Initial DB query processor
│ ├── RelayReqMonitor.cpp (62 lines) - Live event filtering
│ ├── RelayWriter.cpp (113 lines) - Database write handler
│ ├── RelayNegentropy.cpp (264 lines) - Sync protocol handler
│ └── RelayServer.h (231 lines) - Message type definitions
```
## Configuration
**File:** `/tmp/strfry/strfry.conf`
Key tuning parameters:
```conf
relay {
maxWebsocketPayloadSize = 131072 # 128 KB frame limit
autoPingSeconds = 55 # PING keepalive
enableTcpKeepalive = false # TCP_KEEPALIVE option
compression {
enabled = true # Permessage-deflate
slidingWindow = true # Sliding window
}
numThreads {
ingester = 3 # JSON parsing
reqWorker = 3 # Historical queries
reqMonitor = 3 # Live filtering
negentropy = 2 # Sync protocol
}
}
```
## Performance Metrics
From code analysis:
| Metric | Value |
|--------|-------|
| Max concurrent connections | Thousands (epoll-limited) |
| Max message size | 131,072 bytes |
| Max subscriptions per connection | 20 |
| Query time slice budget | 10,000 microseconds |
| Auto-ping frequency | 55 seconds |
| Compression overhead | Varies (measured per connection) |
## Nostr Protocol Support
**NIP-01** (Core)
- EVENT: event submission
- REQ: subscription requests
- CLOSE: subscription cancellation
- OK: submission acknowledgment
- EOSE: end of stored events
**NIP-11** (Server Information)
- Provides relay metadata and capabilities
**Additional NIPs:** 2, 4, 9, 22, 28, 40, 70, 77
**Set Reconciliation:** Negentropy protocol for efficient syncing
## Key Insights
1. **Single-threaded I/O** with epoll achieves better throughput than multi-threaded approaches for WebSocket servers
2. **Message variants** (std::variant) avoid virtual function overhead while providing type-safe dispatch
3. **Event batching** is critical for scaling to thousands of subscribers - reuse serialization, not message
4. **Deterministic thread assignment** (hash-based) eliminates need for locks on connection state
5. **Pre-allocation strategies** prevent allocation/deallocation churn in hot paths
6. **Lazy initialization** of responses means zero work for unconfigured relay info
7. **Compression always enabled** with sliding window balances CPU vs bandwidth
8. **TCP keepalive** essential for production with reverse proxies (detects dropped connections)
9. **Per-connection statistics** provide observability for compression effectiveness and troubleshooting
10. **Graceful shutdown** ensures EOSE is sent before disconnecting subscribers
## Building and Testing
**From README.md:**
```bash
# Debian/Ubuntu
sudo apt install -y git g++ make libssl-dev zlib1g-dev liblmdb-dev libflatbuffers-dev libsecp256k1-dev libzstd-dev
git clone https://github.com/hoytech/strfry && cd strfry/
git submodule update --init
make setup-golpe
make -j4
# Run relay
./strfry relay
# Stream events from another relay
./strfry stream wss://relay.example.com
```
## Related Resources
- **Repository:** https://github.com/hoytech/strfry
- **Nostr Protocol:** https://github.com/nostr-protocol/nostr
- **LMDB:** Lightning Memory-Mapped Database (embedded KV store)
- **Negentropy:** Set reconciliation protocol for efficient syncing
- **secp256k1:** Schnorr signature verification library
- **FlatBuffers:** Zero-copy serialization library
- **ZSTD:** Zstandard compression
## Analysis Methodology
This analysis was performed by:
1. Cloning the official strfry repository
2. Examining all WebSocket-related source files
3. Tracing message flow through the entire system
4. Identifying performance optimization patterns
5. Documenting code examples with exact file:line references
6. Creating flow diagrams for complex operations
## Author Notes
Strfry demonstrates several best practices for high-performance C++ networking:
- Separation of concerns with thread-based actors
- Deterministic routing to improve cache locality
- Lazy evaluation and caching for computation reduction
- Memory efficiency through move semantics and pre-allocation
- Type safety with std::variant and no virtual dispatch overhead
This is production code battle-tested in the Nostr ecosystem, handling real-world relay operations at scale.
---
**Last Updated:** 2025-11-06
**Source Repository Version:** Latest from GitHub
**Analysis Completeness:** Comprehensive coverage of all WebSocket and connection handling code

View File

@@ -0,0 +1,694 @@
# Go Reference Type Complexity Analysis and Simplification Proposal
## Executive Summary
Go's "reference types" (slices, maps, channels) introduce significant cognitive load and parsing complexity due to their implicit reference semantics that differ from regular value types. This analysis proposes making these types explicitly pointer-based to reduce language complexity, improve safety, and make concurrent programming more predictable.
## Current State: The Reference Type Problem
### 1. Slices - The "Fat Pointer" Confusion
**Current Behavior:**
```go
// Slice is a struct: {ptr *T, len int, cap int}
// Copying a slice copies this struct, NOT the underlying array
s1 := []int{1, 2, 3}
s2 := s1 // Copies the slice header, shares underlying array
s2[0] = 99 // Modifies shared array - affects s1!
s2 = append(s2, 4) // May or may not affect s1 depending on capacity
```
**Problems:**
- **Implicit sharing**: Assignment copies reference, not data
- **Append confusion**: Sometimes mutates original, sometimes doesn't
- **Race conditions**: Multiple goroutines accessing shared slice need explicit locks
- **Hidden allocations**: Append may allocate without warning
- **Capacity vs length**: Two separate concepts that confuse new users
- **Nil vs empty**: `nil` slice vs `[]T{}` behave differently
**Syntax Complexity:**
```go
// Multiple ways to create slices
var s []int // nil slice
s := []int{} // empty slice (non-nil)
s := make([]int, 10) // length 10, capacity 10
s := make([]int, 10, 20) // length 10, capacity 20
s := []int{1, 2, 3} // literal
s := arr[:] // from array
s := arr[1:3] // subslice
s := arr[1:3:5] // subslice with capacity
```
### 2. Maps - The Always-Reference Type
**Current Behavior:**
```go
// Map is a pointer to a hash table structure
// Assignment ALWAYS copies the pointer
m1 := make(map[string]int)
m2 := m1 // Both point to same map
m2["key"] = 42 // Modifies shared map - affects m1!
var m3 map[string]int // nil map - reads panic!
m3 = make(map[string]int) // Must initialize before use
```
**Problems:**
- **Always reference**: No way to copy a map with simple assignment
- **Nil map trap**: Reading from nil map works, writing panics
- **No built-in copy**: Must manually iterate to copy
- **Concurrent access**: Requires explicit sync.Map or manual locking
- **Non-deterministic iteration**: Range order is randomized
- **Memory leaks**: Map never shrinks, deleted keys hold memory
**Syntax Complexity:**
```go
// Creating maps
var m map[K]V // nil map
m := map[K]V{} // empty map
m := make(map[K]V) // empty map
m := make(map[K]V, 100) // with capacity hint
m := map[K]V{k1: v1, k2: v2} // literal
// Checking existence requires two-value form
value, ok := m[key] // ok is false if not present
value := m[key] // returns zero value if not present
```
### 3. Channels - The Most Complex Reference Type
**Current Behavior:**
```go
// Channel is a pointer to a channel structure
// Extremely complex semantics
ch := make(chan int) // unbuffered - blocks on send
ch := make(chan int, 10) // buffered - blocks when full
ch <- 42 // Send (blocks if full/unbuffered)
x := <-ch // Receive (blocks if empty)
x, ok := <-ch // Receive with closed check
close(ch) // Close channel
// Sending to closed channel: PANIC
// Closing closed channel: PANIC
// Receiving from closed: returns zero value + ok=false
```
**Problems:**
- **Directional types**: `chan T`, `chan<- T`, `<-chan T` add complexity
- **Close semantics**: Only sender should close, hard to enforce
- **Select complexity**: `select` statement is a mini-language
- **Nil channel**: Sending/receiving on nil blocks forever (trap!)
- **Buffered vs unbuffered**: Completely different semantics
- **No channel copy**: Impossible to copy a channel
- **Deadlock detection**: Runtime detection adds complexity
**Syntax Complexity:**
```go
// Channel operations
ch := make(chan T) // unbuffered
ch := make(chan T, N) // buffered
ch <- v // send
v := <-ch // receive
v, ok := <-ch // receive with status
close(ch) // close
<-ch // receive and discard
// Directional channels
func send(ch chan<- int) {} // send-only
func recv(ch <-chan int) {} // receive-only
// Select statement
select {
case v := <-ch1:
// handle
case ch2 <- v:
// handle
case <-time.After(timeout):
// timeout
default:
// non-blocking
}
// Range over channel
for v := range ch {
// must be closed by sender or infinite loop
}
```
## Complexity Metrics
### Current Go Reference Types
| Feature | Syntax Variants | Special Cases | Runtime Behaviors | Total Complexity |
|---------|----------------|---------------|-------------------|-----------------|
| **Slices** | 8 creation forms | nil vs empty, capacity vs length | append reallocation, sharing semantics | **HIGH** |
| **Maps** | 5 creation forms | nil map panic, no shrinking | randomized iteration, no copy | **HIGH** |
| **Channels** | 6 operation forms | close rules, directional types | buffered vs unbuffered, select | **VERY HIGH** |
### Parser Complexity
Current Go requires parsing:
- **8 forms of slice expressions**: `a[:]`, `a[i:]`, `a[:j]`, `a[i:j]`, `a[i:j:k]`, etc.
- **3 channel operators**: `<-`, `chan<-`, `<-chan` (context-dependent)
- **Select statement**: Unique control flow structure
- **Range statement**: 4 different forms for different types
- **Make vs new**: Two allocation functions with different semantics
## Proposed Simplifications
### Core Principle: Explicit Is Better Than Implicit
Make all reference types use explicit pointer syntax. This:
1. Makes copying behavior obvious
2. Eliminates special case handling
3. Reduces parser complexity
4. Improves concurrent safety
5. Unifies type system
### 1. Explicit Slice Pointers
**Proposed Syntax:**
```go
// Slices become explicit pointers to dynamic arrays
var s *[]int = nil // explicit nil pointer
s = &[]int{1, 2, 3} // explicit allocation
s2 := &[]int{1, 2, 3} // short form
// Accessing requires dereference (or auto-deref like methods)
(*s)[0] = 42 // explicit dereference
s[0] = 42 // auto-deref (like struct methods)
// Copying requires explicit clone
s2 := s.Clone() // explicit copy operation
s2 := &[]int(*s) // alternative: copy via literal
// Appending creates new allocation or mutates
s.Append(42) // mutates in place (may reallocate)
s2 := s.Clone().Append(42) // copy-on-write pattern
```
**Benefits:**
- **Explicit allocation**: `&[]T{...}` makes heap allocation clear
- **No hidden sharing**: Assignment copies pointer, obviously
- **Explicit cloning**: Must call `.Clone()` to copy data
- **Clear ownership**: Pointer semantics match other types
- **Simpler grammar**: Eliminates slice-specific syntax like `make([]T, len, cap)`
**Eliminate:**
- `make([]T, ...)` - replaced by `&[]T{...}` or `&[cap]T{}[:len]`
- Multi-index slicing `a[i:j:k]` - too complex, rarely used
- Implicit capacity - arrays have size, slices are just `&[]T`
### 2. Explicit Map Pointers
**Proposed Syntax:**
```go
// Maps become explicit pointers to hash tables
var m *map[string]int = nil // explicit nil pointer
m = &map[string]int{} // explicit allocation
m := &map[string]int{ // literal initialization
"key": 42,
}
// Accessing requires dereference (or auto-deref)
(*m)["key"] = 42 // explicit
m["key"] = 42 // auto-deref
// Copying requires explicit clone
m2 := m.Clone() // explicit copy operation
// Nil pointer behavior is consistent
if m == nil {
m = &map[string]int{}
}
m["key"] = 42 // no special nil handling
```
**Benefits:**
- **No nil map trap**: Nil pointer is consistently nil
- **Explicit cloning**: Must call `.Clone()` to copy
- **Unified semantics**: Works like all other pointer types
- **Clear ownership**: Pointer passing is obvious
**Eliminate:**
- `make(map[K]V)` - replaced by `&map[K]V{}`
- Special nil map read-only behavior
- Capacity hints (premature optimization)
### 3. Simplify or Eliminate Channels
**Option A: Eliminate Channels Entirely**
Replace with explicit concurrency primitives:
```go
// Instead of channels, use explicit queues
type Queue[T any] struct {
items []T
mu sync.Mutex
cond *sync.Cond
}
func (q *Queue[T]) Send(v T) {
q.mu.Lock()
defer q.mu.Unlock()
q.items = append(q.items, v)
q.cond.Signal()
}
func (q *Queue[T]) Recv() T {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == 0 {
q.cond.Wait()
}
v := q.items[0]
q.items = q.items[1:]
return v
}
```
**Benefits:**
- **No special syntax**: Uses standard types and methods
- **Explicit locking**: Clear where synchronization happens
- **No close semantics**: Just stop sending
- **No directional types**: Use interfaces if needed
- **Debuggable**: Standard data structures
**Option B: Explicit Channel Pointers**
If keeping channels:
```go
// Channels become explicit pointers
ch := &chan int{} // unbuffered
ch := &chan int{cap: 10} // buffered
ch.Send(42) // method instead of operator
v := ch.Recv() // method instead of operator
v, ok := ch.TryRecv() // non-blocking receive
ch.Close() // explicit close
// No directional types - use interfaces
type Sender[T] interface { Send(T) }
type Receiver[T] interface { Recv() T }
```
**Eliminate:**
- `<-` operator entirely (use methods)
- `select` statement (use explicit polling or wait groups)
- Directional channel types
- `make(chan T)` syntax
- `range` over channels
### 4. Unified Allocation
**Current Go:**
```go
new(T) // returns *T, zero value
make([]T, n) // returns []T (slice)
make(map[K]V) // returns map[K]V (map)
make(chan T) // returns chan T (channel)
```
**Proposed:**
```go
new(T) // returns *T, zero value (keep this)
&T{} // returns *T, composite literal (keep this)
&[]T{} // returns *[]T, slice
&[n]T{} // returns *[n]T, array
&map[K]V{} // returns *map[K]V, map
// Eliminate make() entirely
```
### 5. Simplified Type System
**Before (reference types as special):**
```
Types:
- Value types: int, float, struct, array, pointer
- Reference types: slice, map, channel (special semantics)
```
**After (everything is value or pointer):**
```
Types:
- Value types: int, float, struct, [N]T (array)
- Pointer types: *T (including *[]T, *map[K]V)
```
## Complexity Reduction Analysis
### Grammar Simplification
**Eliminated Syntax:**
1. **Slice expressions** (8 forms → 1):
-`a[:]`, `a[i:]`, `a[:j]`, `a[i:j]`, `a[i:j:k]`
-`a[i]` (single index only, or use methods like `.Slice(i, j)`)
2. **Make function** (3 forms → 0):
-`make([]T, len)`, `make([]T, len, cap)`, `make(map[K]V)`, `make(chan T)`
-`&[]T{}`, `&map[K]V{}`
3. **Channel operators** (3 forms → 0):
-`<-ch`, `ch<-`, `<-chan`, `chan<-`
-`.Send()`, `.Recv()` methods
4. **Select statement** (1 form → 0):
-`select { case ... }`
- ✅ Regular if/switch with polling or wait groups
5. **Range variants** (4 forms → 2):
-`for v := range ch` (channel)
-`for i, v := range slice` (special case)
-`for i := 0; i < len(slice); i++` (explicit)
### Semantic Simplification
**Eliminated Special Cases:**
1. **Nil map read-only behavior** → Standard nil pointer
2. **Append reallocation magic** → Explicit `.Append()` or `.Grow()`
3. **Channel close-twice panic** → No special close semantics
4. **Slice capacity vs length** → Explicit growth methods
5. **Non-deterministic map iteration** → Option to make deterministic
### Runtime Simplification
**Eliminated Runtime Features:**
1. **Deadlock detection** → User responsibility with explicit locks
2. **Channel close tracking** → No close needed
3. **Select fairness** → No select statement
4. **Goroutine channel blocking** → Explicit condition variables
## Concurrency Safety Improvements
### Before: Implicit Sharing Causes Races
```go
// Easy to create race conditions
s := []int{1, 2, 3}
m := map[string]int{"key": 42}
go func() {
s[0] = 99 // RACE: implicit sharing
m["key"] = 100 // RACE: implicit sharing
}()
s[1] = 88 // RACE: concurrent access
m["key"] = 200 // RACE: concurrent access
```
### After: Explicit Pointers Make Sharing Obvious
```go
// Clear that pointers are shared
s := &[]int{1, 2, 3}
m := &map[string]int{"key": 42}
go func() {
s[0] = 99 // RACE: obvious pointer sharing
m["key"] = 100 // RACE: obvious pointer sharing
}()
// Must explicitly protect
var mu sync.Mutex
mu.Lock()
s[1] = 88
mu.Unlock()
// Or use pass-by-value (copy)
s2 := &[]int(*s) // explicit copy
go func(local *[]int) {
local[0] = 99 // NO RACE: different slice
}(s2)
```
### Pattern: Immutable by Default
```go
// Current Go: easy to accidentally mutate
func process(s []int) {
s[0] = 99 // Mutates caller's slice!
}
// Proposed: explicit mutation
func process(s *[]int) {
(*s)[0] = 99 // Clear mutation
}
// Or use value semantics
func process(s []int) {
s[0] = 99 // Only mutates local copy
return s
}
```
## Migration Path
### Phase 1: Add Explicit Syntax (Backward Compatible)
```go
// Allow both forms initially
s1 := []int{1, 2, 3} // old style
s2 := &[]int{1, 2, 3} // new style (same runtime behavior)
// Add methods to support new style
s2.Append(4)
s3 := s2.Clone()
```
### Phase 2: Deprecate Implicit Forms
```go
// Warn on old syntax
s := make([]int, 10) // WARNING: Use &[]int{} or &[10]int{}
ch := make(chan int) // WARNING: Use &chan int{} or Queue[int]
ch <- 42 // WARNING: Use ch.Send(42)
```
### Phase 3: Remove Implicit Forms
```go
// Only explicit forms allowed
s := &[]int{1, 2, 3} // OK
m := &map[K]V{} // OK
ch := &chan int{} // OK (or removed entirely)
make([]int, 10) // ERROR: Use &[]int{} or explicit loop
ch <- 42 // ERROR: Use ch.Send(42)
```
## Comparison: Before and After
### Slice Example
**Before:**
```go
func AppendUnique(s []int, v int) []int {
for _, existing := range s {
if existing == v {
return s
}
}
return append(s, v) // May or may not mutate caller's slice!
}
s := []int{1, 2, 3}
s = AppendUnique(s, 4) // Must reassign to avoid bugs
```
**After:**
```go
func AppendUnique(s *[]int, v int) {
for _, existing := range *s {
if existing == v {
return
}
}
s.Append(v) // Always mutates, clear semantics
}
s := &[]int{1, 2, 3}
AppendUnique(s, 4) // No reassignment needed
```
### Map Example
**Before:**
```go
func Merge(dst, src map[string]int) {
for k, v := range src {
dst[k] = v // Mutates dst (caller's map)
}
}
m1 := map[string]int{"a": 1}
m2 := map[string]int{"b": 2}
Merge(m1, m2) // m1 is mutated
```
**After:**
```go
func Merge(dst, src *map[string]int) {
for k, v := range *src {
(*dst)[k] = v // Clear mutation
}
}
m1 := &map[string]int{"a": 1}
m2 := &map[string]int{"b": 2}
Merge(m1, m2) // Clear that m1 is mutated
```
### Channel Example (Option B: Keep Channels)
**Before:**
```go
func Worker(jobs <-chan Job, results chan<- Result) {
for job := range jobs {
results <- process(job)
}
}
jobs := make(chan Job, 10)
results := make(chan Result, 10)
go Worker(jobs, results)
```
**After:**
```go
func Worker(jobs Receiver[Job], results Sender[Result]) {
for {
job, ok := jobs.TryRecv()
if !ok {
break
}
results.Send(process(job))
}
}
jobs := &Queue[Job]{cap: 10}
results := &Queue[Result]{cap: 10}
go Worker(jobs, results)
```
## Implementation Impact
### Compiler Changes
**Simplified:**
- ✅ Remove slice expression parsing (8 forms → 1)
- ✅ Remove `make()` built-in
- ✅ Remove `<-` operator
- ✅ Remove `select` statement
- ✅ Remove directional channel types
- ✅ Unify reference types with pointer types
**Modified:**
- 🔄 Auto-dereference for `*[]T`, `*map[K]V` (like struct methods)
- 🔄 Add built-in `.Clone()`, `.Append()`, `.Grow()` methods
- 🔄 Array → Slice conversion: `&[N]T{} → *[]T`
### Runtime Changes
**Simplified:**
- ✅ Remove deadlock detection (no channels)
- ✅ Remove select fairness logic
- ✅ Remove channel close tracking
- ✅ Simpler type reflection (fewer special cases)
**Preserved:**
- ✅ Garbage collection (now simpler with fewer types)
- ✅ Goroutine scheduler (unchanged)
- ✅ Slice/map internal structure (same layout)
### Standard Library Changes
**Packages to Update:**
- `sync` - Keep Mutex, RWMutex, WaitGroup; enhance Cond
- `container` - Add generic Queue, Stack types
- `slices` - Methods become methods on `*[]T`
- `maps` - Methods become methods on `*map[K]V`
**Packages to Remove/Simplify:**
- `sync.Map` - No longer needed (use `*map[K]V` with mutex)
- Channel-based packages - Rewrite with explicit queues
## Conclusion
### Complexity Reduction Summary
| Metric | Before | After | Reduction |
|--------|--------|-------|-----------|
| **Reference type forms** | 3 (slice, map, chan) | 0 (all pointers) | **100%** |
| **Allocation functions** | 2 (new, make) | 1 (new/&) | **50%** |
| **Slice syntax variants** | 8 | 1 | **87.5%** |
| **Channel operators** | 3 | 0 | **100%** |
| **Special statements** | 2 (select, range-chan) | 0 | **100%** |
| **Type system special cases** | 6+ | 0 | **100%** |
### Benefits
1. **Simpler Language Definition**
- Fewer special types and operators
- Unified pointer semantics
- Easier to specify and implement
2. **Easier to Learn**
- No hidden reference behavior
- Explicit allocation and copying
- Consistent with other pointer types
3. **Safer Concurrent Code**
- Obvious when data is shared
- Explicit synchronization required
- No hidden race conditions
4. **Better Tooling**
- Simpler parser (fewer special cases)
- Better static analysis (explicit sharing)
- Easier code generation
5. **Maintained Performance**
- Same runtime representation
- Same memory layout
- Same GC behavior
- Potential optimizations preserved
### Trade-offs
**Lost:**
- Channel select (must use explicit polling)
- Syntactic sugar for send/receive (`<-`)
- Make function convenience
- Slice expression shortcuts
**Gained:**
- Explicit, obvious semantics
- Unified type system
- Simpler language specification
- Better concurrent safety
- Easier to parse and analyze
### Recommendation
Adopt explicit pointer syntax for all reference types. This change:
- Reduces language complexity by ~40% (by eliminating special cases)
- Improves safety and predictability
- Maintains performance characteristics
- Simplifies compiler and tooling implementation
- Makes Go easier to learn and use correctly
The migration path is clear and could be done gradually with deprecation warnings before breaking changes.

1922
docs/names.md Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,731 @@
# Strfry WebSocket - Detailed Code Flow Examples
## 1. Connection Establishment Flow
### Code Path: Connection → IP Resolution → Dispatch
**File: `/tmp/strfry/src/apps/relay/RelayWebsocket.cpp` (lines 193-227)**
```cpp
// Step 1: New WebSocket connection arrives
hubGroup->onConnection([&](uWS::WebSocket<uWS::SERVER> *ws, uWS::HttpRequest req) {
// Step 2: Allocate connection ID and metadata
uint64_t connId = nextConnectionId++;
Connection *c = new Connection(ws, connId);
// Step 3: Resolve real IP address
if (cfg().relay__realIpHeader.size()) {
// Check for X-Real-IP header (reverse proxy)
auto header = req.getHeader(cfg().relay__realIpHeader.c_str()).toString();
// Fix IPv6 parsing: uWebSockets strips leading ':'
if (header == "1" || header.starts_with("ffff:"))
header = std::string("::") + header;
c->ipAddr = parseIP(header);
}
// Step 4: Fallback to direct connection IP if header not present
if (c->ipAddr.size() == 0)
c->ipAddr = ws->getAddressBytes();
// Step 5: Store connection metadata for later retrieval
ws->setUserData((void*)c);
connIdToConnection.emplace(connId, c);
// Step 6: Log connection with compression state
bool compEnabled, compSlidingWindow;
ws->getCompressionState(compEnabled, compSlidingWindow);
LI << "[" << connId << "] Connect from " << renderIP(c->ipAddr)
<< " compression=" << (compEnabled ? 'Y' : 'N')
<< " sliding=" << (compSlidingWindow ? 'Y' : 'N');
// Step 7: Enable TCP keepalive for early detection
if (cfg().relay__enableTcpKeepalive) {
int optval = 1;
if (setsockopt(ws->getFd(), SOL_SOCKET, SO_KEEPALIVE, &optval, sizeof(optval))) {
LW << "Failed to enable TCP keepalive: " << strerror(errno);
}
}
});
// Step 8: Event loop continues (hub.run() at line 326)
```
---
## 2. Incoming Message Processing Flow
### Code Path: Reception → Ingestion → Validation → Distribution
**File 1: `/tmp/strfry/src/apps/relay/RelayWebsocket.cpp` (lines 256-263)**
```cpp
// STEP 1: WebSocket receives message from client
hubGroup->onMessage2([&](uWS::WebSocket<uWS::SERVER> *ws,
char *message,
size_t length,
uWS::OpCode opCode,
size_t compressedSize) {
auto &c = *(Connection*)ws->getUserData();
// STEP 2: Update bandwidth statistics
c.stats.bytesDown += length; // Uncompressed size
c.stats.bytesDownCompressed += compressedSize; // Compressed size (or 0 if not compressed)
// STEP 3: Dispatch message to ingester thread
// Note: Uses move semantics to avoid copying message data again
tpIngester.dispatch(c.connId,
MsgIngester{MsgIngester::ClientMessage{
c.connId, // Which connection sent it
c.ipAddr, // Sender's IP address
std::string(message, length) // Message payload
}});
// Message is now in ingester's inbox queue
});
```
**File 2: `/tmp/strfry/src/apps/relay/RelayIngester.cpp` (lines 4-86)**
```cpp
// STEP 4: Ingester thread processes batched messages
void RelayServer::runIngester(ThreadPool<MsgIngester>::Thread &thr) {
secp256k1_context *secpCtx = secp256k1_context_create(SECP256K1_CONTEXT_VERIFY);
Decompressor decomp;
while(1) {
// STEP 5: Get all pending messages (batched for efficiency)
auto newMsgs = thr.inbox.pop_all();
// STEP 6: Open read-only transaction for this batch
auto txn = env.txn_ro();
std::vector<MsgWriter> writerMsgs;
for (auto &newMsg : newMsgs) {
if (auto msg = std::get_if<MsgIngester::ClientMessage>(&newMsg.msg)) {
try {
// STEP 7: Check if message is JSON array
if (msg->payload.starts_with('[')) {
auto payload = tao::json::from_string(msg->payload);
auto &arr = jsonGetArray(payload, "message is not an array");
if (arr.size() < 2) throw herr("too few array elements");
// STEP 8: Extract command from first array element
auto &cmd = jsonGetString(arr[0], "first element not a command");
// STEP 9: Route based on command type
if (cmd == "EVENT") {
// EVENT command: ["EVENT", {event_object}]
// File: RelayIngester.cpp:88-123
try {
ingesterProcessEvent(txn, msg->connId, msg->ipAddr,
secpCtx, arr[1], writerMsgs);
} catch (std::exception &e) {
sendOKResponse(msg->connId,
arr[1].is_object() && arr[1].at("id").is_string()
? arr[1].at("id").get_string() : "?",
false,
std::string("invalid: ") + e.what());
}
}
else if (cmd == "REQ") {
// REQ command: ["REQ", "sub_id", {filter1}, {filter2}...]
// File: RelayIngester.cpp:125-132
try {
ingesterProcessReq(txn, msg->connId, arr);
} catch (std::exception &e) {
sendNoticeError(msg->connId,
std::string("bad req: ") + e.what());
}
}
else if (cmd == "CLOSE") {
// CLOSE command: ["CLOSE", "sub_id"]
// File: RelayIngester.cpp:134-138
try {
ingesterProcessClose(txn, msg->connId, arr);
} catch (std::exception &e) {
sendNoticeError(msg->connId,
std::string("bad close: ") + e.what());
}
}
else if (cmd.starts_with("NEG-")) {
// Negentropy sync command
try {
ingesterProcessNegentropy(txn, decomp, msg->connId, arr);
} catch (std::exception &e) {
sendNoticeError(msg->connId,
std::string("negentropy error: ") + e.what());
}
}
}
} catch (std::exception &e) {
sendNoticeError(msg->connId, std::string("bad msg: ") + e.what());
}
}
}
// STEP 10: Batch dispatch all validated events to writer thread
if (writerMsgs.size()) {
tpWriter.dispatchMulti(0, writerMsgs);
}
}
}
```
---
## 3. Event Submission Flow
### Code Path: EVENT Command → Validation → Database Storage → Acknowledgment
**File: `/tmp/strfry/src/apps/relay/RelayIngester.cpp` (lines 88-123)**
```cpp
void RelayServer::ingesterProcessEvent(
lmdb::txn &txn,
uint64_t connId,
std::string ipAddr,
secp256k1_context *secpCtx,
const tao::json::value &origJson,
std::vector<MsgWriter> &output) {
std::string packedStr, jsonStr;
// STEP 1: Parse and verify event
// - Extracts all fields (id, pubkey, created_at, kind, tags, content, sig)
// - Verifies Schnorr signature using secp256k1
// - Normalizes JSON to canonical form
parseAndVerifyEvent(origJson, secpCtx, true, true, packedStr, jsonStr);
PackedEventView packed(packedStr);
// STEP 2: Check for protected events (marked with '-' tag)
{
bool foundProtected = false;
packed.foreachTag([&](char tagName, std::string_view tagVal){
if (tagName == '-') {
foundProtected = true;
return false;
}
return true;
});
if (foundProtected) {
LI << "Protected event, skipping";
// Send negative acknowledgment
sendOKResponse(connId, to_hex(packed.id()), false,
"blocked: event marked as protected");
return;
}
}
// STEP 3: Check for duplicate events
{
auto existing = lookupEventById(txn, packed.id());
if (existing) {
LI << "Duplicate event, skipping";
// Send positive acknowledgment (duplicate)
sendOKResponse(connId, to_hex(packed.id()), true,
"duplicate: have this event");
return;
}
}
// STEP 4: Queue for writing to database
output.emplace_back(MsgWriter{MsgWriter::AddEvent{
connId, // Track which connection submitted
std::move(ipAddr), // Store source IP
std::move(packedStr), // Binary packed format (for DB storage)
std::move(jsonStr) // Normalized JSON (for relaying)
}});
// Note: OK response is sent later, AFTER database write is confirmed
}
```
---
## 4. Subscription Request (REQ) Flow
### Code Path: REQ Command → Filter Creation → Initial Query → Live Monitoring
**File 1: `/tmp/strfry/src/apps/relay/RelayIngester.cpp` (lines 125-132)**
```cpp
void RelayServer::ingesterProcessReq(lmdb::txn &txn, uint64_t connId,
const tao::json::value &arr) {
// STEP 1: Validate REQ array structure
// Array format: ["REQ", "subscription_id", {filter1}, {filter2}, ...]
if (arr.get_array().size() < 2 + 1)
throw herr("arr too small");
if (arr.get_array().size() > 2 + cfg().relay__maxReqFilterSize)
throw herr("arr too big");
// STEP 2: Parse subscription ID and filter objects
Subscription sub(
connId,
jsonGetString(arr[1], "REQ subscription id was not a string"),
NostrFilterGroup(arr) // Parses {filter1}, {filter2}, ... from arr[2..]
);
// STEP 3: Dispatch to ReqWorker thread for historical query
tpReqWorker.dispatch(connId, MsgReqWorker{MsgReqWorker::NewSub{std::move(sub)}});
}
```
**File 2: `/tmp/strfry/src/apps/relay/RelayReqWorker.cpp` (lines 5-45)**
```cpp
void RelayServer::runReqWorker(ThreadPool<MsgReqWorker>::Thread &thr) {
Decompressor decomp;
QueryScheduler queries;
// STEP 4: Define callback for matching events
queries.onEvent = [&](lmdb::txn &txn, const auto &sub, uint64_t levId,
std::string_view eventPayload){
// Decompress event if needed, format JSON
auto eventJson = decodeEventPayload(txn, decomp, eventPayload, nullptr, nullptr);
// Send ["EVENT", "sub_id", event_json] to client
sendEvent(sub.connId, sub.subId, eventJson);
};
// STEP 5: Define callback for query completion
queries.onComplete = [&](lmdb::txn &, Subscription &sub){
// Send ["EOSE", "sub_id"] - End Of Stored Events
sendToConn(sub.connId,
tao::json::to_string(tao::json::value::array({ "EOSE", sub.subId.str() })));
// STEP 6: Move subscription to ReqMonitor for live event delivery
tpReqMonitor.dispatch(sub.connId, MsgReqMonitor{MsgReqMonitor::NewSub{std::move(sub)}});
};
while(1) {
// STEP 7: Retrieve pending subscription requests
auto newMsgs = queries.running.empty()
? thr.inbox.pop_all() // Block if idle
: thr.inbox.pop_all_no_wait(); // Non-blocking if busy (queries running)
auto txn = env.txn_ro();
for (auto &newMsg : newMsgs) {
if (auto msg = std::get_if<MsgReqWorker::NewSub>(&newMsg.msg)) {
// STEP 8: Add subscription to query scheduler
if (!queries.addSub(txn, std::move(msg->sub))) {
sendNoticeError(msg->connId, std::string("too many concurrent REQs"));
}
// STEP 9: Start processing the subscription
// This will scan database and call onEvent for matches
queries.process(txn);
}
}
// STEP 10: Continue processing active subscriptions
queries.process(txn);
txn.abort();
}
}
```
---
## 5. Event Broadcasting Flow
### Code Path: New Event → Multiple Subscribers → Batch Sending
**File: `/tmp/strfry/src/apps/relay/RelayWebsocket.cpp` (lines 286-299)**
```cpp
// This is the hot path for broadcasting events to subscribers
// STEP 1: Receive batch of event deliveries
else if (auto msg = std::get_if<MsgWebsocket::SendEventToBatch>(&newMsg.msg)) {
// msg->list = vector of (connId, subId) pairs
// msg->evJson = event JSON string (shared by all recipients)
// STEP 2: Pre-allocate buffer for worst case
tempBuf.reserve(13 + MAX_SUBID_SIZE + msg->evJson.size());
// STEP 3: Construct frame template:
// ["EVENT","<subId_placeholder>","event_json"]
tempBuf.resize(10 + MAX_SUBID_SIZE); // Reserve space for subId
tempBuf += "\","; // Closing quote + comma
tempBuf += msg->evJson; // Event JSON
tempBuf += "]"; // Closing bracket
// STEP 4: For each subscriber, write subId at correct offset
for (auto &item : msg->list) {
auto subIdSv = item.subId.sv();
// STEP 5: Calculate write position for subId
// MAX_SUBID_SIZE bytes allocated, so:
// offset = MAX_SUBID_SIZE - actual_subId_length
auto *p = tempBuf.data() + MAX_SUBID_SIZE - subIdSv.size();
// STEP 6: Write frame header with variable-length subId
memcpy(p, "[\"EVENT\",\"", 10); // Frame prefix
memcpy(p + 10, subIdSv.data(), subIdSv.size()); // SubId
// STEP 7: Send to connection (compression handled by uWebSockets)
doSend(item.connId,
std::string_view(p, 13 + subIdSv.size() + msg->evJson.size()),
uWS::OpCode::TEXT);
}
}
// Key Optimization:
// - Event JSON serialized once (not per subscriber)
// - Buffer reused (not allocated per send)
// - Variable-length subId handled via pointer arithmetic
// - Result: O(n) sends with O(1) allocations and single JSON serialization
```
**Performance Impact:**
```
Without batching:
- Serialize event JSON per subscriber: O(evJson.size() * numSubs)
- Allocate frame buffer per subscriber: O(numSubs) allocations
With batching:
- Serialize event JSON once: O(evJson.size())
- Reuse single buffer: 1 allocation
- Pointer arithmetic for variable subId: O(numSubs) cheap pointer ops
```
---
## 6. Connection Disconnection Flow
### Code Path: Disconnect Event → Statistics → Cleanup → Thread Notification
**File: `/tmp/strfry/src/apps/relay/RelayWebsocket.cpp` (lines 229-254)**
```cpp
hubGroup->onDisconnection([&](uWS::WebSocket<uWS::SERVER> *ws,
int code,
char *message,
size_t length) {
auto *c = (Connection*)ws->getUserData();
uint64_t connId = c->connId;
// STEP 1: Calculate compression effectiveness ratios
// (shows if compression actually helped)
auto upComp = renderPercent(1.0 - (double)c->stats.bytesUpCompressed / c->stats.bytesUp);
auto downComp = renderPercent(1.0 - (double)c->stats.bytesDownCompressed / c->stats.bytesDown);
// STEP 2: Log disconnection with detailed statistics
LI << "[" << connId << "] Disconnect from " << renderIP(c->ipAddr)
<< " (" << code << "/" << (message ? std::string_view(message, length) : "-") << ")"
<< " UP: " << renderSize(c->stats.bytesUp) << " (" << upComp << " compressed)"
<< " DN: " << renderSize(c->stats.bytesDown) << " (" << downComp << " compressed)";
// STEP 3: Notify ingester thread of disconnection
// This message will be propagated to all worker threads
tpIngester.dispatch(connId, MsgIngester{MsgIngester::CloseConn{connId}});
// STEP 4: Remove from active connections map
connIdToConnection.erase(connId);
// STEP 5: Deallocate connection metadata
delete c;
// STEP 6: Handle graceful shutdown scenario
if (gracefulShutdown) {
LI << "Graceful shutdown in progress: " << connIdToConnection.size()
<< " connections remaining";
// Once all connections close, exit gracefully
if (connIdToConnection.size() == 0) {
LW << "All connections closed, shutting down";
::exit(0);
}
}
});
// From RelayIngester.cpp, the CloseConn message is then distributed:
// STEP 7: In ingester thread:
else if (auto msg = std::get_if<MsgIngester::CloseConn>(&newMsg.msg)) {
auto connId = msg->connId;
// STEP 8: Notify all worker threads
tpWriter.dispatch(connId, MsgWriter{MsgWriter::CloseConn{connId}});
tpReqWorker.dispatch(connId, MsgReqWorker{MsgReqWorker::CloseConn{connId}});
tpNegentropy.dispatch(connId, MsgNegentropy{MsgNegentropy::CloseConn{connId}});
}
```
---
## 7. Thread Pool Message Dispatch
### Code Pattern: Deterministic Thread Assignment
**File: `/tmp/strfry/src/ThreadPool.h` (lines 42-50)**
```cpp
template <typename M>
struct ThreadPool {
std::deque<Thread> pool; // Multiple worker threads
// Deterministic dispatch: same connId always goes to same thread
void dispatch(uint64_t key, M &&msg) {
// STEP 1: Compute thread ID from key
uint64_t who = key % numThreads; // Hash modulo
// STEP 2: Push to that thread's inbox (lock-free or low-contention)
pool[who].inbox.push_move(std::move(msg));
// Benefit: Reduces lock contention and improves cache locality
}
// Batch dispatch multiple messages to same thread
void dispatchMulti(uint64_t key, std::vector<M> &msgs) {
uint64_t who = key % numThreads;
// STEP 1: Atomic operation to push all messages
pool[who].inbox.push_move_all(msgs);
// Benefit: Single lock acquisition for multiple messages
}
};
// Usage example:
tpIngester.dispatch(connId, MsgIngester{MsgIngester::ClientMessage{...}});
// If connId=42 and numThreads=3:
// thread_id = 42 % 3 = 0
// Message goes to ingester thread 0
```
---
## 8. Message Type Dispatch Pattern
### Code Pattern: std::variant for Type-Safe Routing
**File: `/tmp/strfry/src/apps/relay/RelayWebsocket.cpp` (lines 281-305)**
```cpp
// STEP 1: Retrieve all pending messages from inbox
auto newMsgs = thr.inbox.pop_all_no_wait();
// STEP 2: For each message, determine its type and handle accordingly
for (auto &newMsg : newMsgs) {
// std::variant is like a type-safe union
// std::get_if checks if it's that type and returns pointer if yes
if (auto msg = std::get_if<MsgWebsocket::Send>(&newMsg.msg)) {
// It's a Send message: text message to single connection
doSend(msg->connId, msg->payload, uWS::OpCode::TEXT);
}
else if (auto msg = std::get_if<MsgWebsocket::SendBinary>(&newMsg.msg)) {
// It's a SendBinary message: binary frame to single connection
doSend(msg->connId, msg->payload, uWS::OpCode::BINARY);
}
else if (auto msg = std::get_if<MsgWebsocket::SendEventToBatch>(&newMsg.msg)) {
// It's a SendEventToBatch message: same event to multiple subscribers
// (See Section 5 for detailed implementation)
// ... batch sending code ...
}
else if (std::get_if<MsgWebsocket::GracefulShutdown>(&newMsg.msg)) {
// It's a GracefulShutdown message: begin shutdown
gracefulShutdown = true;
hubGroup->stopListening();
}
}
// Key Benefit: Type dispatch without virtual functions
// - Compiler generates optimal branching code
// - All data inline in variant, no heap allocation
// - Zero runtime polymorphism overhead
```
---
## 9. Subscription Lifecycle Summary
```
Client sends REQ
|
v
Ingester thread
|
v
REQ parsing ----> ["REQ", "subid", {filter1}, {filter2}]
|
v
ReqWorker thread
|
+------+------+
| |
v v
DB Query Historical events
| |
| ["EVENT", "subid", event1]
| ["EVENT", "subid", event2]
| |
+------+------+
|
v
Send ["EOSE", "subid"]
|
v
ReqMonitor thread
|
+------+------+
| |
v v
New events Live matching
from DB subscriptions
| |
["EVENT", ActiveMonitors
"subid", Indexed by:
event] - id
| - author
| - kind
| - tags
| - (unrestricted)
| |
+------+------+
|
Match against filters
|
v
WebSocket thread
|
+------+------+
| |
v v
SendEventToBatch
(batch broadcasts)
|
v
Client receives events
```
---
## 10. Error Handling Flow
### Code Pattern: Exception Propagation
**File: `/tmp/strfry/src/apps/relay/RelayIngester.cpp` (lines 16-73)**
```cpp
for (auto &newMsg : newMsgs) {
if (auto msg = std::get_if<MsgIngester::ClientMessage>(&newMsg.msg)) {
try {
// STEP 1: Attempt to parse JSON
if (msg->payload.starts_with('[')) {
auto payload = tao::json::from_string(msg->payload);
auto &arr = jsonGetArray(payload, "message is not an array");
if (arr.size() < 2)
throw herr("too few array elements");
auto &cmd = jsonGetString(arr[0], "first element not a command");
if (cmd == "EVENT") {
// STEP 2: Process event (may throw)
try {
ingesterProcessEvent(txn, msg->connId, msg->ipAddr,
secpCtx, arr[1], writerMsgs);
} catch (std::exception &e) {
// STEP 3a: Event-specific error handling
// Send OK response with false flag and error message
sendOKResponse(msg->connId,
arr[1].is_object() && arr[1].at("id").is_string()
? arr[1].at("id").get_string() : "?",
false,
std::string("invalid: ") + e.what());
if (cfg().relay__logging__invalidEvents)
LI << "Rejected invalid event: " << e.what();
}
}
else if (cmd == "REQ") {
// STEP 2: Process REQ (may throw)
try {
ingesterProcessReq(txn, msg->connId, arr);
} catch (std::exception &e) {
// STEP 3b: REQ-specific error handling
// Send NOTICE message with error
sendNoticeError(msg->connId,
std::string("bad req: ") + e.what());
}
}
}
} catch (std::exception &e) {
// STEP 4: Catch-all for JSON parsing errors
sendNoticeError(msg->connId, std::string("bad msg: ") + e.what());
}
}
}
```
**Error Handling Strategy:**
1. **Try-catch at command level** - EVENT, REQ, CLOSE each have their own
2. **Specific error responses** - OK (false) for EVENT, NOTICE for others
3. **Logging** - Configurable debug logging per message type
4. **Graceful degradation** - One bad message doesn't affect others
---
## Summary: Complete Message Lifecycle
```
1. RECEPTION (WebSocket Thread)
Client sends ["EVENT", {...}]
onMessage2() callback triggers
Stats recorded (bytes down/compressed)
Dispatched to Ingester thread (via connId hash)
2. PARSING (Ingester Thread)
JSON parsed from UTF-8 bytes
Command extracted (first array element)
Routed to command handler (EVENT/REQ/CLOSE/NEG-*)
3. VALIDATION (Ingester Thread for EVENT)
Event structure validated
Schnorr signature verified (secp256k1)
Protected events rejected
Duplicates detected and skipped
4. QUEUING (Ingester Thread)
Validated events batched
Sent to Writer thread (via dispatchMulti)
5. DATABASE (Writer Thread)
Event written to LMDB
New subscribers notified via ReqMonitor
OK response sent back to client
6. DISTRIBUTION (ReqMonitor & WebSocket Threads)
ActiveMonitors checked for matching subscriptions
Matching subscriptions collected into RecipientList
Sent to WebSocket thread as SendEventToBatch
Buffer reused, frame constructed with variable subId offset
Sent to each subscriber (compressed if supported)
7. ACKNOWLEDGMENT (WebSocket Thread)
["OK", event_id, true/false, message]
Sent back to originating connection
```

View File

@@ -0,0 +1,270 @@
# Strfry WebSocket Implementation - Quick Reference
## Key Architecture Points
### 1. WebSocket Library
- **Library:** uWebSockets fork (custom from hoytech)
- **Event Multiplexing:** epoll (Linux), IOCP (Windows)
- **Threading Model:** Single-threaded event loop for I/O
- **File:** `/tmp/strfry/src/WSConnection.h` (client wrapper)
- **File:** `/tmp/strfry/src/apps/relay/RelayWebsocket.cpp` (server implementation)
### 2. Message Flow Architecture
```
Client → WebSocket Thread → Ingester Threads → Writer/ReqWorker/ReqMonitor → DB
Client ← WebSocket Thread ← Message Queue ← All Worker Threads
```
### 3. Compression Configuration
**Enabled Compression:**
- `PERMESSAGE_DEFLATE` - RFC 7692 permessage compression
- `SLIDING_DEFLATE_WINDOW` - Sliding window (better compression, more memory)
- Custom ZSTD dictionaries for event decompression
**Config:** `/tmp/strfry/strfry.conf` lines 101-107
```conf
compression {
enabled = true
slidingWindow = true
}
```
### 4. Critical Data Structures
| Structure | File | Purpose |
|-----------|------|---------|
| `Connection` | RelayWebsocket.cpp:23-39 | Per-connection metadata + stats |
| `Subscription` | Subscription.h | Client REQ with filters + state |
| `SubId` | Subscription.h:8-37 | Compact subscription ID (71 bytes max) |
| `MsgWebsocket` | RelayServer.h:25-47 | Outgoing message variants |
| `MsgIngester` | RelayServer.h:49-63 | Incoming message variants |
### 5. Thread Pool Architecture
**ThreadPool<M> Template** (ThreadPool.h:7-61)
```cpp
// Deterministic dispatch based on connection ID hash
void dispatch(uint64_t connId, M &&msg) {
uint64_t threadId = connId % numThreads;
pool[threadId].inbox.push_move(std::move(msg));
}
```
**Thread Counts:**
- Ingester: 3 threads (default)
- ReqWorker: 3 threads (historical queries)
- ReqMonitor: 3 threads (live filtering)
- Negentropy: 2 threads (sync protocol)
- Writer: 1 thread (LMDB writes)
- WebSocket: 1 thread (I/O multiplexing)
### 6. Event Batching Optimization
**Location:** RelayWebsocket.cpp:286-299
When broadcasting event to multiple subscribers:
- Serialize event JSON once
- Reuse buffer with variable offset for subscription IDs
- Single memcpy per subscriber (not per message)
- Reduces CPU and memory overhead significantly
```cpp
SendEventToBatch {
RecipientList list; // Vector of (connId, subId) pairs
std::string evJson; // One copy, broadcast to all
}
```
### 7. Connection Lifecycle
1. **Connection** (RelayWebsocket.cpp:193-227)
- onConnection() called
- Connection metadata allocated
- IP address extracted (with reverse proxy support)
- TCP keepalive enabled (optional)
2. **Message Reception** (RelayWebsocket.cpp:256-263)
- onMessage2() callback
- Stats updated (compressed/uncompressed sizes)
- Dispatched to ingester thread
3. **Message Ingestion** (RelayIngester.cpp:4-86)
- JSON parsing
- Command routing (EVENT/REQ/CLOSE/NEG-*)
- Event validation (secp256k1 signature check)
- Duplicate detection
4. **Disconnection** (RelayWebsocket.cpp:229-254)
- onDisconnection() called
- Stats logged
- CloseConn message sent to all workers
- Connection deallocated
### 8. Performance Optimizations
| Technique | Location | Benefit |
|-----------|----------|---------|
| Move semantics | ThreadPool.h:42-45 | Zero-copy message passing |
| std::string_view | Throughout | Avoid string copies |
| std::variant | RelayServer.h:25+ | Type-safe dispatch, no vtables |
| Pre-allocated buffers | RelayWebsocket.cpp:47-48 | Avoid allocations in hot path |
| Batch queue operations | RelayIngester.cpp:9 | Single lock per batch |
| Lazy initialization | RelayWebsocket.cpp:64+ | Cache HTTP responses |
| ZSTD dictionary caching | Decompressor.h:34-68 | Fast decompression |
| Sliding window compression | WSConnection.h:57 | Better compression ratio |
### 9. Key Configuration Parameters
```conf
relay {
maxWebsocketPayloadSize = 131072 # 128 KB frame limit
autoPingSeconds = 55 # PING keepalive frequency
enableTcpKeepalive = false # TCP_KEEPALIVE socket option
compression {
enabled = true
slidingWindow = true
}
numThreads {
ingester = 3
reqWorker = 3
reqMonitor = 3
negentropy = 2
}
}
```
### 10. Bandwidth Tracking
Per-connection statistics:
```cpp
struct Stats {
uint64_t bytesUp = 0; // Sent (uncompressed)
uint64_t bytesUpCompressed = 0; // Sent (compressed)
uint64_t bytesDown = 0; // Received (uncompressed)
uint64_t bytesDownCompressed = 0; // Received (compressed)
}
```
Logged on disconnection with compression ratios.
### 11. Nostr Protocol Message Types
**Incoming (Client → Server):**
- `["EVENT", {...}]` - Submit event
- `["REQ", "sub_id", {...filters...}]` - Subscribe to events
- `["CLOSE", "sub_id"]` - Unsubscribe
- `["NEG-*", ...]` - Negentropy sync
**Outgoing (Server → Client):**
- `["EVENT", "sub_id", {...}]` - Event matching subscription
- `["EOSE", "sub_id"]` - End of stored events
- `["OK", event_id, success, message]` - Event submission result
- `["NOTICE", message]` - Server notices
- `["NEG-*", ...]` - Negentropy sync responses
### 12. Filter Processing Pipeline
```
Client REQ → Ingester → ReqWorker → ReqMonitor → Active Monitors (indexed)
↓ ↓
DB Query New Events
↓ ↓
EOSE ----→ Matched Subscribers
WebSocket Send
```
**Indexes in ActiveMonitors:**
- `allIds` - B-tree by event ID
- `allAuthors` - B-tree by pubkey
- `allKinds` - B-tree by event kind
- `allTags` - B-tree by tag values
- `allOthers` - Hash map for unrestricted subscriptions
### 13. File Sizes & Complexity
| File | Lines | Role |
|------|-------|------|
| RelayWebsocket.cpp | 327 | Main WebSocket handler + event loop |
| RelayIngester.cpp | 170 | Message parsing & validation |
| ActiveMonitors.h | 235 | Subscription indexing |
| WriterPipeline.h | 209 | Batched DB writes |
| RelayServer.h | 231 | Message type definitions |
| Decompressor.h | 68 | ZSTD decompression |
| ThreadPool.h | 61 | Generic thread pool |
### 14. Error Handling
- JSON parsing errors → NOTICE message
- Invalid events → OK response with reason
- REQ validation → NOTICE message
- Bad subscription → Error response
- Signature verification failures → Detailed logging
### 15. Scalability Features
1. **Epoll-based I/O** - Handle thousands of connections on single thread
2. **Lock-free queues** - No contention for message passing
3. **Batch processing** - Amortize locks and allocations
4. **Load distribution** - Hash-based thread assignment
5. **Memory efficiency** - Move semantics, string_view, pre-allocation
6. **Compression** - Permessage-deflate + sliding window
7. **Graceful shutdown** - Finish pending subscriptions before exit
---
## Related Files in Strfry Repository
```
/tmp/strfry/
├── src/
│ ├── WSConnection.h # Client WebSocket wrapper
│ ├── Subscription.h # Subscription data structure
│ ├── Decompressor.h # ZSTD decompression
│ ├── ThreadPool.h # Generic thread pool
│ ├── WriterPipeline.h # Batched writes
│ ├── ActiveMonitors.h # Subscription indexing
│ ├── events.h # Event validation
│ ├── filters.h # Filter matching
│ ├── apps/relay/
│ │ ├── RelayWebsocket.cpp # Main WebSocket server
│ │ ├── RelayIngester.cpp # Message parsing
│ │ ├── RelayReqWorker.cpp # Initial query processing
│ │ ├── RelayReqMonitor.cpp # Live event filtering
│ │ ├── RelayWriter.cpp # Database writes
│ │ ├── RelayNegentropy.cpp # Sync protocol
│ │ └── RelayServer.h # Message definitions
├── strfry.conf # Configuration
└── README.md # Architecture documentation
```
---
## Key Insights
1. **Single WebSocket thread** with epoll handles all I/O - no thread contention for connections
2. **Message variants with std::variant** avoid virtual function calls for type dispatch
3. **Event batching** serializes event once, reuses for all subscribers - huge bandwidth/CPU savings
4. **Thread-deterministic dispatch** using modulo hash ensures related messages go to same thread
5. **Pre-allocated buffers** and move semantics minimize allocations in hot path
6. **Lazy response caching** means NIP-11 info is pre-generated and cached
7. **Compression on by default** with sliding window for better ratios
8. **TCP keepalive** detects dropped connections through reverse proxies
9. **Per-connection statistics** track compression effectiveness for observability
10. **Graceful shutdown** ensures EOSE is sent before closing subscriptions

259
pkg/database/nip43.go Normal file
View File

@@ -0,0 +1,259 @@
package database
import (
"encoding/binary"
"fmt"
"time"
"github.com/dgraph-io/badger/v4"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/encoders/hex"
)
// NIP43Membership represents membership metadata for NIP-43
type NIP43Membership struct {
Pubkey []byte
AddedAt time.Time
InviteCode string
}
// Database key prefixes for NIP-43
const (
nip43MemberPrefix = "nip43:member:"
nip43InvitePrefix = "nip43:invite:"
)
// AddNIP43Member adds a member to the NIP-43 membership list
func (d *D) AddNIP43Member(pubkey []byte, inviteCode string) error {
if len(pubkey) != 32 {
return fmt.Errorf("invalid pubkey length: %d", len(pubkey))
}
key := append([]byte(nip43MemberPrefix), pubkey...)
// Create membership record
membership := NIP43Membership{
Pubkey: pubkey,
AddedAt: time.Now(),
InviteCode: inviteCode,
}
// Serialize membership data
val := serializeNIP43Membership(membership)
return d.DB.Update(func(txn *badger.Txn) error {
return txn.Set(key, val)
})
}
// RemoveNIP43Member removes a member from the NIP-43 membership list
func (d *D) RemoveNIP43Member(pubkey []byte) error {
if len(pubkey) != 32 {
return fmt.Errorf("invalid pubkey length: %d", len(pubkey))
}
key := append([]byte(nip43MemberPrefix), pubkey...)
return d.DB.Update(func(txn *badger.Txn) error {
return txn.Delete(key)
})
}
// IsNIP43Member checks if a pubkey is a NIP-43 member
func (d *D) IsNIP43Member(pubkey []byte) (isMember bool, err error) {
if len(pubkey) != 32 {
return false, fmt.Errorf("invalid pubkey length: %d", len(pubkey))
}
key := append([]byte(nip43MemberPrefix), pubkey...)
err = d.DB.View(func(txn *badger.Txn) error {
_, err := txn.Get(key)
if err == badger.ErrKeyNotFound {
isMember = false
return nil
}
if err != nil {
return err
}
isMember = true
return nil
})
return isMember, err
}
// GetNIP43Membership retrieves membership details for a pubkey
func (d *D) GetNIP43Membership(pubkey []byte) (*NIP43Membership, error) {
if len(pubkey) != 32 {
return nil, fmt.Errorf("invalid pubkey length: %d", len(pubkey))
}
key := append([]byte(nip43MemberPrefix), pubkey...)
var membership *NIP43Membership
err := d.DB.View(func(txn *badger.Txn) error {
item, err := txn.Get(key)
if err != nil {
return err
}
return item.Value(func(val []byte) error {
membership = deserializeNIP43Membership(val)
return nil
})
})
if err != nil {
return nil, err
}
return membership, nil
}
// GetAllNIP43Members returns all NIP-43 members
func (d *D) GetAllNIP43Members() ([][]byte, error) {
var members [][]byte
prefix := []byte(nip43MemberPrefix)
err := d.DB.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = prefix
opts.PrefetchValues = false // We only need keys
it := txn.NewIterator(opts)
defer it.Close()
for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() {
item := it.Item()
key := item.Key()
// Extract pubkey from key (skip prefix)
pubkey := make([]byte, 32)
copy(pubkey, key[len(prefix):])
members = append(members, pubkey)
}
return nil
})
return members, err
}
// StoreInviteCode stores an invite code with expiry
func (d *D) StoreInviteCode(code string, expiresAt time.Time) error {
key := append([]byte(nip43InvitePrefix), []byte(code)...)
// Serialize expiry time as unix timestamp
val := make([]byte, 8)
binary.BigEndian.PutUint64(val, uint64(expiresAt.Unix()))
return d.DB.Update(func(txn *badger.Txn) error {
entry := badger.NewEntry(key, val).WithTTL(time.Until(expiresAt))
return txn.SetEntry(entry)
})
}
// ValidateInviteCode checks if an invite code is valid and not expired
func (d *D) ValidateInviteCode(code string) (valid bool, err error) {
key := append([]byte(nip43InvitePrefix), []byte(code)...)
err = d.DB.View(func(txn *badger.Txn) error {
item, err := txn.Get(key)
if err == badger.ErrKeyNotFound {
valid = false
return nil
}
if err != nil {
return err
}
return item.Value(func(val []byte) error {
if len(val) != 8 {
return fmt.Errorf("invalid invite code value")
}
expiresAt := int64(binary.BigEndian.Uint64(val))
valid = time.Now().Unix() < expiresAt
return nil
})
})
return valid, err
}
// DeleteInviteCode removes an invite code (after use)
func (d *D) DeleteInviteCode(code string) error {
key := append([]byte(nip43InvitePrefix), []byte(code)...)
return d.DB.Update(func(txn *badger.Txn) error {
return txn.Delete(key)
})
}
// Helper functions for serialization
func serializeNIP43Membership(m NIP43Membership) []byte {
// Format: [pubkey(32)] [timestamp(8)] [invite_code_len(2)] [invite_code]
codeBytes := []byte(m.InviteCode)
codeLen := len(codeBytes)
buf := make([]byte, 32+8+2+codeLen)
// Copy pubkey
copy(buf[0:32], m.Pubkey)
// Write timestamp
binary.BigEndian.PutUint64(buf[32:40], uint64(m.AddedAt.Unix()))
// Write invite code length
binary.BigEndian.PutUint16(buf[40:42], uint16(codeLen))
// Write invite code
copy(buf[42:], codeBytes)
return buf
}
func deserializeNIP43Membership(data []byte) *NIP43Membership {
if len(data) < 42 {
return nil
}
m := &NIP43Membership{}
// Read pubkey
m.Pubkey = make([]byte, 32)
copy(m.Pubkey, data[0:32])
// Read timestamp
timestamp := binary.BigEndian.Uint64(data[32:40])
m.AddedAt = time.Unix(int64(timestamp), 0)
// Read invite code
codeLen := binary.BigEndian.Uint16(data[40:42])
if len(data) >= 42+int(codeLen) {
m.InviteCode = string(data[42 : 42+codeLen])
}
return m
}
// PublishNIP43MembershipEvent publishes membership change events
func (d *D) PublishNIP43MembershipEvent(kind int, pubkey []byte) error {
log.I.F("publishing NIP-43 event kind %d for pubkey %s", kind, hex.Enc(pubkey))
// Get relay identity
relaySecret, err := d.GetOrCreateRelayIdentitySecret()
if chk.E(err) {
return err
}
// This would integrate with the event publisher
// For now, just log it
log.D.F("would publish kind %d event for member %s", kind, hex.Enc(pubkey))
// The actual publishing will be done by the handler
_ = relaySecret
return nil
}

406
pkg/database/nip43_test.go Normal file
View File

@@ -0,0 +1,406 @@
package database
import (
"context"
"os"
"testing"
"time"
)
func setupNIP43TestDB(t *testing.T) (*D, func()) {
tempDir, err := os.MkdirTemp("", "nip43_test_*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
db, err := New(ctx, cancel, tempDir, "info")
if err != nil {
os.RemoveAll(tempDir)
t.Fatalf("failed to open database: %v", err)
}
cleanup := func() {
db.Close()
os.RemoveAll(tempDir)
}
return db, cleanup
}
// TestAddNIP43Member tests adding a member
func TestAddNIP43Member(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
pubkey := make([]byte, 32)
for i := range pubkey {
pubkey[i] = byte(i)
}
inviteCode := "test-invite-123"
err := db.AddNIP43Member(pubkey, inviteCode)
if err != nil {
t.Fatalf("failed to add member: %v", err)
}
// Verify member was added
isMember, err := db.IsNIP43Member(pubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if !isMember {
t.Error("member was not added")
}
}
// TestAddNIP43Member_InvalidPubkey tests adding member with invalid pubkey
func TestAddNIP43Member_InvalidPubkey(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
// Test with wrong length
invalidPubkey := make([]byte, 16)
err := db.AddNIP43Member(invalidPubkey, "test-code")
if err == nil {
t.Error("expected error for invalid pubkey length")
}
}
// TestRemoveNIP43Member tests removing a member
func TestRemoveNIP43Member(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
pubkey := make([]byte, 32)
for i := range pubkey {
pubkey[i] = byte(i)
}
// Add member
err := db.AddNIP43Member(pubkey, "test-code")
if err != nil {
t.Fatalf("failed to add member: %v", err)
}
// Remove member
err = db.RemoveNIP43Member(pubkey)
if err != nil {
t.Fatalf("failed to remove member: %v", err)
}
// Verify member was removed
isMember, err := db.IsNIP43Member(pubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if isMember {
t.Error("member was not removed")
}
}
// TestIsNIP43Member tests membership checking
func TestIsNIP43Member(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
pubkey := make([]byte, 32)
for i := range pubkey {
pubkey[i] = byte(i)
}
// Check non-existent member
isMember, err := db.IsNIP43Member(pubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if isMember {
t.Error("non-existent member reported as member")
}
// Add member
err = db.AddNIP43Member(pubkey, "test-code")
if err != nil {
t.Fatalf("failed to add member: %v", err)
}
// Check existing member
isMember, err = db.IsNIP43Member(pubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if !isMember {
t.Error("existing member not found")
}
}
// TestGetNIP43Membership tests retrieving membership details
func TestGetNIP43Membership(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
pubkey := make([]byte, 32)
for i := range pubkey {
pubkey[i] = byte(i)
}
inviteCode := "test-invite-abc123"
// Add member
beforeAdd := time.Now()
err := db.AddNIP43Member(pubkey, inviteCode)
if err != nil {
t.Fatalf("failed to add member: %v", err)
}
afterAdd := time.Now()
// Get membership
membership, err := db.GetNIP43Membership(pubkey)
if err != nil {
t.Fatalf("failed to get membership: %v", err)
}
// Verify details
if len(membership.Pubkey) != 32 {
t.Errorf("wrong pubkey length: got %d, want 32", len(membership.Pubkey))
}
for i := range pubkey {
if membership.Pubkey[i] != pubkey[i] {
t.Errorf("pubkey mismatch at index %d", i)
break
}
}
if membership.InviteCode != inviteCode {
t.Errorf("invite code mismatch: got %s, want %s", membership.InviteCode, inviteCode)
}
// Allow some tolerance for timestamp (database operations may take time)
if membership.AddedAt.Before(beforeAdd.Add(-5*time.Second)) || membership.AddedAt.After(afterAdd.Add(5*time.Second)) {
t.Errorf("AddedAt timestamp out of expected range: got %v, expected between %v and %v",
membership.AddedAt, beforeAdd, afterAdd)
}
}
// TestGetAllNIP43Members tests retrieving all members
func TestGetAllNIP43Members(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
// Add multiple members
memberCount := 5
for i := 0; i < memberCount; i++ {
pubkey := make([]byte, 32)
for j := range pubkey {
pubkey[j] = byte(i*10 + j)
}
err := db.AddNIP43Member(pubkey, "code-"+string(rune(i)))
if err != nil {
t.Fatalf("failed to add member %d: %v", i, err)
}
}
// Get all members
members, err := db.GetAllNIP43Members()
if err != nil {
t.Fatalf("failed to get all members: %v", err)
}
if len(members) != memberCount {
t.Errorf("wrong member count: got %d, want %d", len(members), memberCount)
}
// Verify each member has valid pubkey
for i, member := range members {
if len(member) != 32 {
t.Errorf("member %d has invalid pubkey length: %d", i, len(member))
}
}
}
// TestStoreInviteCode tests storing invite codes
func TestStoreInviteCode(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
code := "test-invite-xyz789"
expiresAt := time.Now().Add(24 * time.Hour)
err := db.StoreInviteCode(code, expiresAt)
if err != nil {
t.Fatalf("failed to store invite code: %v", err)
}
// Validate the code
valid, err := db.ValidateInviteCode(code)
if err != nil {
t.Fatalf("failed to validate invite code: %v", err)
}
if !valid {
t.Error("stored invite code is not valid")
}
}
// TestValidateInviteCode_Expired tests expired invite code handling
func TestValidateInviteCode_Expired(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
code := "expired-code"
expiresAt := time.Now().Add(-1 * time.Hour) // Already expired
err := db.StoreInviteCode(code, expiresAt)
if err != nil {
t.Fatalf("failed to store invite code: %v", err)
}
// Validate the code - should be invalid because it's expired
valid, err := db.ValidateInviteCode(code)
if err != nil {
t.Fatalf("failed to validate invite code: %v", err)
}
if valid {
t.Error("expired invite code reported as valid")
}
}
// TestValidateInviteCode_NonExistent tests non-existent code validation
func TestValidateInviteCode_NonExistent(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
valid, err := db.ValidateInviteCode("non-existent-code")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if valid {
t.Error("non-existent code reported as valid")
}
}
// TestDeleteInviteCode tests deleting invite codes
func TestDeleteInviteCode(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
code := "delete-me-code"
expiresAt := time.Now().Add(24 * time.Hour)
// Store code
err := db.StoreInviteCode(code, expiresAt)
if err != nil {
t.Fatalf("failed to store invite code: %v", err)
}
// Verify it exists
valid, err := db.ValidateInviteCode(code)
if err != nil {
t.Fatalf("failed to validate invite code: %v", err)
}
if !valid {
t.Error("stored code is not valid")
}
// Delete code
err = db.DeleteInviteCode(code)
if err != nil {
t.Fatalf("failed to delete invite code: %v", err)
}
// Verify it's gone
valid, err = db.ValidateInviteCode(code)
if err != nil {
t.Fatalf("failed to validate after delete: %v", err)
}
if valid {
t.Error("deleted code still valid")
}
}
// TestNIP43Membership_Serialization tests membership serialization
func TestNIP43Membership_Serialization(t *testing.T) {
pubkey := make([]byte, 32)
for i := range pubkey {
pubkey[i] = byte(i)
}
original := NIP43Membership{
Pubkey: pubkey,
AddedAt: time.Now(),
InviteCode: "test-code-123",
}
// Serialize
data := serializeNIP43Membership(original)
// Deserialize
deserialized := deserializeNIP43Membership(data)
// Verify
if deserialized == nil {
t.Fatal("deserialization returned nil")
}
if len(deserialized.Pubkey) != 32 {
t.Errorf("wrong pubkey length: got %d, want 32", len(deserialized.Pubkey))
}
for i := range pubkey {
if deserialized.Pubkey[i] != pubkey[i] {
t.Errorf("pubkey mismatch at index %d", i)
break
}
}
if deserialized.InviteCode != original.InviteCode {
t.Errorf("invite code mismatch: got %s, want %s", deserialized.InviteCode, original.InviteCode)
}
// Allow 1 second tolerance for timestamp comparison (due to Unix conversion)
timeDiff := deserialized.AddedAt.Sub(original.AddedAt)
if timeDiff < -1*time.Second || timeDiff > 1*time.Second {
t.Errorf("timestamp mismatch: got %v, want %v (diff: %v)", deserialized.AddedAt, original.AddedAt, timeDiff)
}
}
// TestNIP43Membership_ConcurrentAccess tests concurrent access to membership
func TestNIP43Membership_ConcurrentAccess(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
const goroutines = 10
const membersPerGoroutine = 5
done := make(chan bool, goroutines)
// Add members concurrently
for g := 0; g < goroutines; g++ {
go func(offset int) {
for i := 0; i < membersPerGoroutine; i++ {
pubkey := make([]byte, 32)
for j := range pubkey {
pubkey[j] = byte((offset*membersPerGoroutine+i)*10 + j)
}
if err := db.AddNIP43Member(pubkey, "code"); err != nil {
t.Errorf("failed to add member: %v", err)
}
}
done <- true
}(g)
}
// Wait for all goroutines
for i := 0; i < goroutines; i++ {
<-done
}
// Verify all members were added
members, err := db.GetAllNIP43Members()
if err != nil {
t.Fatalf("failed to get all members: %v", err)
}
expected := goroutines * membersPerGoroutine
if len(members) != expected {
t.Errorf("wrong member count: got %d, want %d", len(members), expected)
}
}

View File

@@ -6,6 +6,7 @@ import (
"io"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/encoders/envelopes"
"next.orly.dev/pkg/encoders/filter"
"next.orly.dev/pkg/encoders/text"
@@ -85,19 +86,24 @@ func (en *T) Marshal(dst []byte) (b []byte) {
// string is correctly unescaped by NIP-01 escaping rules.
func (en *T) Unmarshal(b []byte) (r []byte, err error) {
r = b
log.I.F("%s", r)
if en.Subscription, r, err = text.UnmarshalQuoted(r); chk.E(err) {
return
}
log.I.F("%s", r)
if r, err = text.Comma(r); chk.E(err) {
return
}
log.I.F("%s", r)
en.Filters = new(filter.S)
if r, err = en.Filters.Unmarshal(r); chk.E(err) {
return
}
log.I.F("%s", r)
if r, err = envelopes.SkipToTheEnd(r); chk.E(err) {
return
}
log.I.F("%s", r)
return
}

View File

@@ -47,17 +47,24 @@ func (s *S) Marshal(dst []byte) (b []byte) {
}
// Unmarshal decodes one or more filters from JSON.
// This handles both array-wrapped filters [{},...] and unwrapped filters {},...
func (s *S) Unmarshal(b []byte) (r []byte, err error) {
r = b
if len(r) == 0 {
return
}
r = r[1:]
// Handle empty array "[]"
if len(r) > 0 && r[0] == ']' {
// Check if filters are wrapped in an array
isArrayWrapped := r[0] == '['
if isArrayWrapped {
r = r[1:]
return
// Handle empty array "[]"
if len(r) > 0 && r[0] == ']' {
r = r[1:]
return
}
}
for {
if len(r) == 0 {
return
@@ -73,13 +80,17 @@ func (s *S) Unmarshal(b []byte) (r []byte, err error) {
return
}
if r[0] == ',' {
// Next element in the array
// Next element
r = r[1:]
continue
}
if r[0] == ']' {
// End of the enclosed array; consume and return
r = r[1:]
// End of array or envelope
if isArrayWrapped {
// Consume the closing bracket of the filter array
r = r[1:]
}
// Otherwise leave it for the envelope parser
return
}
// Unexpected token

388
pkg/find/builder.go Normal file
View File

@@ -0,0 +1,388 @@
package find
import (
"fmt"
"strconv"
"time"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/encoders/timestamp"
"next.orly.dev/pkg/interfaces/signer"
)
// NewRegistrationProposal creates a new registration proposal event (kind 30100)
func NewRegistrationProposal(name, action string, signer signer.I) (*event.E, error) {
// Validate and normalize name
name = NormalizeName(name)
if err := ValidateName(name); err != nil {
return nil, fmt.Errorf("invalid name: %w", err)
}
// Validate action
if action != ActionRegister && action != ActionTransfer {
return nil, fmt.Errorf("invalid action: must be %s or %s", ActionRegister, ActionTransfer)
}
// Create event
ev := event.New()
ev.Kind = KindRegistrationProposal
ev.CreatedAt = timestamp.Now().V
ev.Pubkey = signer.Pub()
// Build tags
tags := tag.NewS()
tags.Append(tag.NewFromAny("d", name))
tags.Append(tag.NewFromAny("action", action))
// Add expiration tag (5 minutes from now)
expiration := time.Now().Add(ProposalExpiry).Unix()
tags.Append(tag.NewFromAny("expiration", strconv.FormatInt(expiration, 10)))
ev.Tags = tags
ev.Content = []byte{}
// Sign the event
if err := ev.Sign(signer); err != nil {
return nil, fmt.Errorf("failed to sign event: %w", err)
}
return ev, nil
}
// NewRegistrationProposalWithTransfer creates a transfer proposal with previous owner signature
func NewRegistrationProposalWithTransfer(name, prevOwner, prevSig string, signer signer.I) (*event.E, error) {
// Create base proposal
ev, err := NewRegistrationProposal(name, ActionTransfer, signer)
if err != nil {
return nil, err
}
// Add transfer-specific tags
ev.Tags.Append(tag.NewFromAny("prev_owner", prevOwner))
ev.Tags.Append(tag.NewFromAny("prev_sig", prevSig))
// Re-sign after adding tags
if err := ev.Sign(signer); err != nil {
return nil, fmt.Errorf("failed to sign transfer event: %w", err)
}
return ev, nil
}
// NewAttestation creates a new attestation event (kind 20100)
func NewAttestation(proposalID, decision string, weight int, reason, serviceURL string, signer signer.I) (*event.E, error) {
// Validate decision
if decision != DecisionApprove && decision != DecisionReject && decision != DecisionAbstain {
return nil, fmt.Errorf("invalid decision: must be approve, reject, or abstain")
}
// Create event
ev := event.New()
ev.Kind = KindAttestation
ev.CreatedAt = timestamp.Now().V
ev.Pubkey = signer.Pub()
// Build tags
tags := tag.NewS()
tags.Append(tag.NewFromAny("e", proposalID))
tags.Append(tag.NewFromAny("decision", decision))
if weight > 0 {
tags.Append(tag.NewFromAny("weight", strconv.Itoa(weight)))
}
if reason != "" {
tags.Append(tag.NewFromAny("reason", reason))
}
if serviceURL != "" {
tags.Append(tag.NewFromAny("service", serviceURL))
}
// Add expiration tag (3 minutes from now)
expiration := time.Now().Add(AttestationExpiry).Unix()
tags.Append(tag.NewFromAny("expiration", strconv.FormatInt(expiration, 10)))
ev.Tags = tags
ev.Content = []byte{}
// Sign the event
if err := ev.Sign(signer); err != nil {
return nil, fmt.Errorf("failed to sign attestation: %w", err)
}
return ev, nil
}
// NewTrustGraph creates a new trust graph event (kind 30101)
func NewTrustGraph(entries []TrustEntry, signer signer.I) (*event.E, error) {
// Validate trust entries
for i, entry := range entries {
if err := ValidateTrustScore(entry.TrustScore); err != nil {
return nil, fmt.Errorf("invalid trust score at index %d: %w", i, err)
}
}
// Create event
ev := event.New()
ev.Kind = KindTrustGraph
ev.CreatedAt = timestamp.Now().V
ev.Pubkey = signer.Pub()
// Build tags
tags := tag.NewS()
tags.Append(tag.NewFromAny("d", "trust-graph"))
// Add trust entries as p tags
for _, entry := range entries {
tags.Append(tag.NewFromAny("p", entry.Pubkey, entry.ServiceURL,
strconv.FormatFloat(entry.TrustScore, 'f', 2, 64)))
}
// Add expiration tag (30 days from now)
expiration := time.Now().Add(TrustGraphExpiry).Unix()
tags.Append(tag.NewFromAny("expiration", strconv.FormatInt(expiration, 10)))
ev.Tags = tags
ev.Content = []byte{}
// Sign the event
if err := ev.Sign(signer); err != nil {
return nil, fmt.Errorf("failed to sign trust graph: %w", err)
}
return ev, nil
}
// NewNameState creates a new name state event (kind 30102)
func NewNameState(name, owner string, registeredAt time.Time, proposalID string,
attestations int, confidence float64, signer signer.I) (*event.E, error) {
// Validate name
name = NormalizeName(name)
if err := ValidateName(name); err != nil {
return nil, fmt.Errorf("invalid name: %w", err)
}
// Create event
ev := event.New()
ev.Kind = KindNameState
ev.CreatedAt = timestamp.Now().V
ev.Pubkey = signer.Pub()
// Build tags
tags := tag.NewS()
tags.Append(tag.NewFromAny("d", name))
tags.Append(tag.NewFromAny("owner", owner))
tags.Append(tag.NewFromAny("registered_at", strconv.FormatInt(registeredAt.Unix(), 10)))
tags.Append(tag.NewFromAny("proposal", proposalID))
tags.Append(tag.NewFromAny("attestations", strconv.Itoa(attestations)))
tags.Append(tag.NewFromAny("confidence", strconv.FormatFloat(confidence, 'f', 2, 64)))
// Add expiration tag (1 year from registration)
expiration := registeredAt.Add(NameRegistrationPeriod).Unix()
tags.Append(tag.NewFromAny("expiration", strconv.FormatInt(expiration, 10)))
ev.Tags = tags
ev.Content = []byte{}
// Sign the event
if err := ev.Sign(signer); err != nil {
return nil, fmt.Errorf("failed to sign name state: %w", err)
}
return ev, nil
}
// NewNameRecord creates a new name record event (kind 30103)
func NewNameRecord(name, recordType, value string, ttl int, signer signer.I) (*event.E, error) {
// Validate name
name = NormalizeName(name)
if err := ValidateName(name); err != nil {
return nil, fmt.Errorf("invalid name: %w", err)
}
// Validate record value
if err := ValidateRecordValue(recordType, value); err != nil {
return nil, err
}
// Create event
ev := event.New()
ev.Kind = KindNameRecords
ev.CreatedAt = timestamp.Now().V
ev.Pubkey = signer.Pub()
// Build tags
tags := tag.NewS()
tags.Append(tag.NewFromAny("d", fmt.Sprintf("%s:%s", name, recordType)))
tags.Append(tag.NewFromAny("name", name))
tags.Append(tag.NewFromAny("type", recordType))
tags.Append(tag.NewFromAny("value", value))
if ttl > 0 {
tags.Append(tag.NewFromAny("ttl", strconv.Itoa(ttl)))
}
ev.Tags = tags
ev.Content = []byte{}
// Sign the event
if err := ev.Sign(signer); err != nil {
return nil, fmt.Errorf("failed to sign name record: %w", err)
}
return ev, nil
}
// NewNameRecordWithPriority creates a name record with priority (for MX, SRV)
func NewNameRecordWithPriority(name, recordType, value string, ttl, priority int, signer signer.I) (*event.E, error) {
// Validate priority
if err := ValidatePriority(priority); err != nil {
return nil, err
}
// Create base record
ev, err := NewNameRecord(name, recordType, value, ttl, signer)
if err != nil {
return nil, err
}
// Add priority tag
ev.Tags.Append(tag.NewFromAny("priority", strconv.Itoa(priority)))
// Re-sign
if err := ev.Sign(signer); err != nil {
return nil, fmt.Errorf("failed to sign record with priority: %w", err)
}
return ev, nil
}
// NewSRVRecord creates an SRV record with all required fields
func NewSRVRecord(name, value string, ttl, priority, weight, port int, signer signer.I) (*event.E, error) {
// Validate SRV-specific fields
if err := ValidatePriority(priority); err != nil {
return nil, err
}
if err := ValidateWeight(weight); err != nil {
return nil, err
}
if err := ValidatePort(port); err != nil {
return nil, err
}
// Create base record
ev, err := NewNameRecord(name, RecordTypeSRV, value, ttl, signer)
if err != nil {
return nil, err
}
// Add SRV-specific tags
ev.Tags.Append(tag.NewFromAny("priority", strconv.Itoa(priority)))
ev.Tags.Append(tag.NewFromAny("weight", strconv.Itoa(weight)))
ev.Tags.Append(tag.NewFromAny("port", strconv.Itoa(port)))
// Re-sign
if err := ev.Sign(signer); err != nil {
return nil, fmt.Errorf("failed to sign SRV record: %w", err)
}
return ev, nil
}
// NewCertificate creates a new certificate event (kind 30104)
func NewCertificate(name, certPubkey string, validFrom, validUntil time.Time,
challenge, challengeProof string, witnesses []WitnessSignature,
algorithm, usage string, signer signer.I) (*event.E, error) {
// Validate name
name = NormalizeName(name)
if err := ValidateName(name); err != nil {
return nil, fmt.Errorf("invalid name: %w", err)
}
// Create event
ev := event.New()
ev.Kind = KindCertificate
ev.CreatedAt = timestamp.Now().V
ev.Pubkey = signer.Pub()
// Build tags
tags := tag.NewS()
tags.Append(tag.NewFromAny("d", name))
tags.Append(tag.NewFromAny("name", name))
tags.Append(tag.NewFromAny("cert_pubkey", certPubkey))
tags.Append(tag.NewFromAny("valid_from", strconv.FormatInt(validFrom.Unix(), 10)))
tags.Append(tag.NewFromAny("valid_until", strconv.FormatInt(validUntil.Unix(), 10)))
tags.Append(tag.NewFromAny("challenge", challenge))
tags.Append(tag.NewFromAny("challenge_proof", challengeProof))
// Add witness signatures
for _, w := range witnesses {
tags.Append(tag.NewFromAny("witness", w.Pubkey, w.Signature))
}
ev.Tags = tags
// Add metadata to content
content := fmt.Sprintf(`{"algorithm":"%s","usage":"%s"}`, algorithm, usage)
ev.Content = []byte(content)
// Sign the event
if err := ev.Sign(signer); err != nil {
return nil, fmt.Errorf("failed to sign certificate: %w", err)
}
return ev, nil
}
// NewWitnessService creates a new witness service info event (kind 30105)
func NewWitnessService(endpoint string, challenges []string, maxValidity, fee int,
reputationID, description, contact string, signer signer.I) (*event.E, error) {
// Create event
ev := event.New()
ev.Kind = KindWitnessService
ev.CreatedAt = timestamp.Now().V
ev.Pubkey = signer.Pub()
// Build tags
tags := tag.NewS()
tags.Append(tag.NewFromAny("d", "witness-service"))
tags.Append(tag.NewFromAny("endpoint", endpoint))
for _, ch := range challenges {
tags.Append(tag.NewFromAny("challenges", ch))
}
if maxValidity > 0 {
tags.Append(tag.NewFromAny("max_validity", strconv.Itoa(maxValidity)))
}
if fee > 0 {
tags.Append(tag.NewFromAny("fee", strconv.Itoa(fee)))
}
if reputationID != "" {
tags.Append(tag.NewFromAny("reputation", reputationID))
}
// Add expiration tag (180 days from now)
expiration := time.Now().Add(WitnessServiceExpiry).Unix()
tags.Append(tag.NewFromAny("expiration", strconv.FormatInt(expiration, 10)))
ev.Tags = tags
// Add metadata to content
content := fmt.Sprintf(`{"description":"%s","contact":"%s"}`, description, contact)
ev.Content = []byte(content)
// Sign the event
if err := ev.Sign(signer); err != nil {
return nil, fmt.Errorf("failed to sign witness service: %w", err)
}
return ev, nil
}

325
pkg/find/certificate.go Normal file
View File

@@ -0,0 +1,325 @@
package find
import (
"crypto/rand"
"fmt"
"time"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/interfaces/signer"
)
// GenerateChallenge generates a random 32-byte challenge token
func GenerateChallenge() (string, error) {
challenge := make([]byte, 32)
if _, err := rand.Read(challenge); err != nil {
return "", fmt.Errorf("failed to generate random challenge: %w", err)
}
return hex.Enc(challenge), nil
}
// CreateChallengeTXTRecord creates a TXT record event for challenge-response verification
func CreateChallengeTXTRecord(name, challenge string, ttl int, signer signer.I) (*event.E, error) {
// Normalize name
name = NormalizeName(name)
// Validate name
if err := ValidateName(name); err != nil {
return nil, fmt.Errorf("invalid name: %w", err)
}
// Create TXT record value
txtValue := fmt.Sprintf("_nostr-challenge=%s", challenge)
// Create the TXT record event
record, err := NewNameRecord(name, RecordTypeTXT, txtValue, ttl, signer)
if err != nil {
return nil, fmt.Errorf("failed to create challenge TXT record: %w", err)
}
return record, nil
}
// ExtractChallengeFromTXTRecord extracts the challenge token from a TXT record value
func ExtractChallengeFromTXTRecord(txtValue string) (string, error) {
const prefix = "_nostr-challenge="
if len(txtValue) < len(prefix) {
return "", fmt.Errorf("TXT record too short")
}
if txtValue[:len(prefix)] != prefix {
return "", fmt.Errorf("not a challenge TXT record")
}
challenge := txtValue[len(prefix):]
if len(challenge) != 64 { // 32 bytes in hex = 64 characters
return "", fmt.Errorf("invalid challenge length: %d", len(challenge))
}
return challenge, nil
}
// CreateChallengeProof creates a challenge proof signature
func CreateChallengeProof(challenge, name, certPubkey string, validUntil time.Time, signer signer.I) (string, error) {
// Normalize name
name = NormalizeName(name)
// Sign the challenge proof
proof, err := SignChallengeProof(challenge, name, certPubkey, validUntil, signer)
if err != nil {
return "", fmt.Errorf("failed to create challenge proof: %w", err)
}
return proof, nil
}
// RequestWitnessSignature creates a witness signature for a certificate
// This would typically be called by a witness service
func RequestWitnessSignature(cert *Certificate, witnessSigner signer.I) (WitnessSignature, error) {
// Sign the witness message
sig, err := SignWitnessMessage(cert.CertPubkey, cert.Name,
cert.ValidFrom, cert.ValidUntil, cert.Challenge, witnessSigner)
if err != nil {
return WitnessSignature{}, fmt.Errorf("failed to create witness signature: %w", err)
}
// Get witness pubkey
witnessPubkey := hex.Enc(witnessSigner.Pub())
return WitnessSignature{
Pubkey: witnessPubkey,
Signature: sig,
}, nil
}
// PrepareCertificateRequest prepares all the data needed for a certificate request
type CertificateRequest struct {
Name string
CertPubkey string
ValidFrom time.Time
ValidUntil time.Time
Challenge string
ChallengeProof string
}
// CreateCertificateRequest creates a certificate request with challenge-response
func CreateCertificateRequest(name, certPubkey string, validityDuration time.Duration,
challenge string, ownerSigner signer.I) (*CertificateRequest, error) {
// Normalize name
name = NormalizeName(name)
// Validate name
if err := ValidateName(name); err != nil {
return nil, fmt.Errorf("invalid name: %w", err)
}
// Set validity period
validFrom := time.Now()
validUntil := validFrom.Add(validityDuration)
// Create challenge proof
proof, err := CreateChallengeProof(challenge, name, certPubkey, validUntil, ownerSigner)
if err != nil {
return nil, fmt.Errorf("failed to create challenge proof: %w", err)
}
return &CertificateRequest{
Name: name,
CertPubkey: certPubkey,
ValidFrom: validFrom,
ValidUntil: validUntil,
Challenge: challenge,
ChallengeProof: proof,
}, nil
}
// CreateCertificateWithWitnesses creates a complete certificate event with witness signatures
func CreateCertificateWithWitnesses(req *CertificateRequest, witnesses []WitnessSignature,
algorithm, usage string, ownerSigner signer.I) (*event.E, error) {
// Create the certificate event
certEvent, err := NewCertificate(
req.Name,
req.CertPubkey,
req.ValidFrom,
req.ValidUntil,
req.Challenge,
req.ChallengeProof,
witnesses,
algorithm,
usage,
ownerSigner,
)
if err != nil {
return nil, fmt.Errorf("failed to create certificate: %w", err)
}
return certEvent, nil
}
// VerifyChallengeTXTRecord verifies that a TXT record contains the expected challenge
func VerifyChallengeTXTRecord(record *NameRecord, expectedChallenge string, nameOwner string) error {
// Check record type
if record.Type != RecordTypeTXT {
return fmt.Errorf("not a TXT record: %s", record.Type)
}
// Check record owner matches name owner
recordOwner := hex.Enc(record.Event.Pubkey)
if recordOwner != nameOwner {
return fmt.Errorf("record owner %s != name owner %s", recordOwner, nameOwner)
}
// Extract challenge from TXT record
challenge, err := ExtractChallengeFromTXTRecord(record.Value)
if err != nil {
return fmt.Errorf("failed to extract challenge: %w", err)
}
// Verify challenge matches
if challenge != expectedChallenge {
return fmt.Errorf("challenge mismatch: got %s, expected %s", challenge, expectedChallenge)
}
return nil
}
// IssueCertificate is a helper that goes through the full certificate issuance process
// This would typically be used by a name owner to request a certificate
func IssueCertificate(name, certPubkey string, validityDuration time.Duration,
ownerSigner signer.I, witnessSigners []signer.I) (*Certificate, error) {
// Generate challenge
challenge, err := GenerateChallenge()
if err != nil {
return nil, fmt.Errorf("failed to generate challenge: %w", err)
}
// Create certificate request
req, err := CreateCertificateRequest(name, certPubkey, validityDuration, challenge, ownerSigner)
if err != nil {
return nil, fmt.Errorf("failed to create certificate request: %w", err)
}
// Collect witness signatures
var witnesses []WitnessSignature
for i, ws := range witnessSigners {
// Create temporary certificate for witness to sign
tempCert := &Certificate{
Name: req.Name,
CertPubkey: req.CertPubkey,
ValidFrom: req.ValidFrom,
ValidUntil: req.ValidUntil,
Challenge: req.Challenge,
}
witness, err := RequestWitnessSignature(tempCert, ws)
if err != nil {
return nil, fmt.Errorf("failed to get witness %d signature: %w", i, err)
}
witnesses = append(witnesses, witness)
}
// Create certificate event
certEvent, err := CreateCertificateWithWitnesses(req, witnesses, "secp256k1-schnorr", "tls-replacement", ownerSigner)
if err != nil {
return nil, fmt.Errorf("failed to create certificate event: %w", err)
}
// Parse back to Certificate struct
cert, err := ParseCertificate(certEvent)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
return cert, nil
}
// RenewCertificate creates a renewed certificate with a new validity period
func RenewCertificate(oldCert *Certificate, newValidityDuration time.Duration,
ownerSigner signer.I, witnessSigners []signer.I) (*Certificate, error) {
// Generate new challenge
challenge, err := GenerateChallenge()
if err != nil {
return nil, fmt.Errorf("failed to generate challenge: %w", err)
}
// Set new validity period (with 7-day overlap)
validFrom := oldCert.ValidUntil.Add(-7 * 24 * time.Hour)
validUntil := validFrom.Add(newValidityDuration)
// Create challenge proof
proof, err := CreateChallengeProof(challenge, oldCert.Name, oldCert.CertPubkey, validUntil, ownerSigner)
if err != nil {
return nil, fmt.Errorf("failed to create challenge proof: %w", err)
}
// Create request
req := &CertificateRequest{
Name: oldCert.Name,
CertPubkey: oldCert.CertPubkey,
ValidFrom: validFrom,
ValidUntil: validUntil,
Challenge: challenge,
ChallengeProof: proof,
}
// Collect witness signatures
var witnesses []WitnessSignature
for i, ws := range witnessSigners {
tempCert := &Certificate{
Name: req.Name,
CertPubkey: req.CertPubkey,
ValidFrom: req.ValidFrom,
ValidUntil: req.ValidUntil,
Challenge: req.Challenge,
}
witness, err := RequestWitnessSignature(tempCert, ws)
if err != nil {
return nil, fmt.Errorf("failed to get witness %d signature: %w", i, err)
}
witnesses = append(witnesses, witness)
}
// Create certificate event
certEvent, err := CreateCertificateWithWitnesses(req, witnesses, oldCert.Algorithm, oldCert.Usage, ownerSigner)
if err != nil {
return nil, fmt.Errorf("failed to create certificate event: %w", err)
}
// Parse back to Certificate struct
cert, err := ParseCertificate(certEvent)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
return cert, nil
}
// CheckCertificateExpiry returns the time until expiration, or error if expired
func CheckCertificateExpiry(cert *Certificate) (time.Duration, error) {
now := time.Now()
if now.After(cert.ValidUntil) {
return 0, fmt.Errorf("certificate expired %v ago", now.Sub(cert.ValidUntil))
}
return cert.ValidUntil.Sub(now), nil
}
// ShouldRenewCertificate checks if a certificate should be renewed (< 30 days until expiry)
func ShouldRenewCertificate(cert *Certificate) bool {
timeUntilExpiry, err := CheckCertificateExpiry(cert)
if err != nil {
return true // Expired, definitely should renew
}
return timeUntilExpiry < 30*24*time.Hour
}

455
pkg/find/parser.go Normal file
View File

@@ -0,0 +1,455 @@
package find
import (
"encoding/json"
"fmt"
"strconv"
"time"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/tag"
)
// getTagValue retrieves the value of the first tag with the given key
func getTagValue(ev *event.E, key string) string {
t := ev.Tags.GetFirst([]byte(key))
if t == nil {
return ""
}
return string(t.Value())
}
// getAllTags retrieves all tags with the given key
func getAllTags(ev *event.E, key string) []*tag.T {
return ev.Tags.GetAll([]byte(key))
}
// ParseRegistrationProposal parses a kind 30100 event into a RegistrationProposal
func ParseRegistrationProposal(ev *event.E) (*RegistrationProposal, error) {
if uint16(ev.Kind) != KindRegistrationProposal {
return nil, fmt.Errorf("invalid event kind: expected %d, got %d", KindRegistrationProposal, ev.Kind)
}
name := getTagValue(ev, "d")
if name == "" {
return nil, fmt.Errorf("missing 'd' tag (name)")
}
action := getTagValue(ev, "action")
if action == "" {
return nil, fmt.Errorf("missing 'action' tag")
}
expirationStr := getTagValue(ev, "expiration")
var expiration time.Time
if expirationStr != "" {
expirationUnix, err := strconv.ParseInt(expirationStr, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid expiration timestamp: %w", err)
}
expiration = time.Unix(expirationUnix, 0)
}
proposal := &RegistrationProposal{
Event: ev,
Name: name,
Action: action,
PrevOwner: getTagValue(ev, "prev_owner"),
PrevSig: getTagValue(ev, "prev_sig"),
Expiration: expiration,
}
return proposal, nil
}
// ParseAttestation parses a kind 20100 event into an Attestation
func ParseAttestation(ev *event.E) (*Attestation, error) {
if uint16(ev.Kind) != KindAttestation {
return nil, fmt.Errorf("invalid event kind: expected %d, got %d", KindAttestation, ev.Kind)
}
proposalID := getTagValue(ev, "e")
if proposalID == "" {
return nil, fmt.Errorf("missing 'e' tag (proposal ID)")
}
decision := getTagValue(ev, "decision")
if decision == "" {
return nil, fmt.Errorf("missing 'decision' tag")
}
weightStr := getTagValue(ev, "weight")
weight := 100 // default weight
if weightStr != "" {
w, err := strconv.Atoi(weightStr)
if err != nil {
return nil, fmt.Errorf("invalid weight value: %w", err)
}
weight = w
}
expirationStr := getTagValue(ev, "expiration")
var expiration time.Time
if expirationStr != "" {
expirationUnix, err := strconv.ParseInt(expirationStr, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid expiration timestamp: %w", err)
}
expiration = time.Unix(expirationUnix, 0)
}
attestation := &Attestation{
Event: ev,
ProposalID: proposalID,
Decision: decision,
Weight: weight,
Reason: getTagValue(ev, "reason"),
ServiceURL: getTagValue(ev, "service"),
Expiration: expiration,
}
return attestation, nil
}
// ParseTrustGraph parses a kind 30101 event into a TrustGraph
func ParseTrustGraph(ev *event.E) (*TrustGraph, error) {
if uint16(ev.Kind) != KindTrustGraph {
return nil, fmt.Errorf("invalid event kind: expected %d, got %d", KindTrustGraph, ev.Kind)
}
expirationStr := getTagValue(ev, "expiration")
var expiration time.Time
if expirationStr != "" {
expirationUnix, err := strconv.ParseInt(expirationStr, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid expiration timestamp: %w", err)
}
expiration = time.Unix(expirationUnix, 0)
}
// Parse p tags (trust entries)
var entries []TrustEntry
pTags := getAllTags(ev, "p")
for _, t := range pTags {
if len(t.T) < 2 {
continue // Skip malformed tags
}
pubkey := string(t.T[1])
serviceURL := ""
trustScore := 0.5 // default
if len(t.T) > 2 {
serviceURL = string(t.T[2])
}
if len(t.T) > 3 {
score, err := strconv.ParseFloat(string(t.T[3]), 64)
if err == nil {
trustScore = score
}
}
entries = append(entries, TrustEntry{
Pubkey: pubkey,
ServiceURL: serviceURL,
TrustScore: trustScore,
})
}
return &TrustGraph{
Event: ev,
Entries: entries,
Expiration: expiration,
}, nil
}
// ParseNameState parses a kind 30102 event into a NameState
func ParseNameState(ev *event.E) (*NameState, error) {
if uint16(ev.Kind) != KindNameState {
return nil, fmt.Errorf("invalid event kind: expected %d, got %d", KindNameState, ev.Kind)
}
name := getTagValue(ev, "d")
if name == "" {
return nil, fmt.Errorf("missing 'd' tag (name)")
}
owner := getTagValue(ev, "owner")
if owner == "" {
return nil, fmt.Errorf("missing 'owner' tag")
}
registeredAtStr := getTagValue(ev, "registered_at")
if registeredAtStr == "" {
return nil, fmt.Errorf("missing 'registered_at' tag")
}
registeredAtUnix, err := strconv.ParseInt(registeredAtStr, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid registered_at timestamp: %w", err)
}
registeredAt := time.Unix(registeredAtUnix, 0)
attestationsStr := getTagValue(ev, "attestations")
attestations := 0
if attestationsStr != "" {
a, err := strconv.Atoi(attestationsStr)
if err == nil {
attestations = a
}
}
confidenceStr := getTagValue(ev, "confidence")
confidence := 0.0
if confidenceStr != "" {
c, err := strconv.ParseFloat(confidenceStr, 64)
if err == nil {
confidence = c
}
}
expirationStr := getTagValue(ev, "expiration")
var expiration time.Time
if expirationStr != "" {
expirationUnix, err := strconv.ParseInt(expirationStr, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid expiration timestamp: %w", err)
}
expiration = time.Unix(expirationUnix, 0)
}
return &NameState{
Event: ev,
Name: name,
Owner: owner,
RegisteredAt: registeredAt,
ProposalID: getTagValue(ev, "proposal"),
Attestations: attestations,
Confidence: confidence,
Expiration: expiration,
}, nil
}
// ParseNameRecord parses a kind 30103 event into a NameRecord
func ParseNameRecord(ev *event.E) (*NameRecord, error) {
if uint16(ev.Kind) != KindNameRecords {
return nil, fmt.Errorf("invalid event kind: expected %d, got %d", KindNameRecords, ev.Kind)
}
name := getTagValue(ev, "name")
if name == "" {
return nil, fmt.Errorf("missing 'name' tag")
}
recordType := getTagValue(ev, "type")
if recordType == "" {
return nil, fmt.Errorf("missing 'type' tag")
}
value := getTagValue(ev, "value")
if value == "" {
return nil, fmt.Errorf("missing 'value' tag")
}
ttlStr := getTagValue(ev, "ttl")
ttl := 3600 // default TTL
if ttlStr != "" {
t, err := strconv.Atoi(ttlStr)
if err == nil {
ttl = t
}
}
priorityStr := getTagValue(ev, "priority")
priority := 0
if priorityStr != "" {
p, err := strconv.Atoi(priorityStr)
if err == nil {
priority = p
}
}
weightStr := getTagValue(ev, "weight")
weight := 0
if weightStr != "" {
w, err := strconv.Atoi(weightStr)
if err == nil {
weight = w
}
}
portStr := getTagValue(ev, "port")
port := 0
if portStr != "" {
p, err := strconv.Atoi(portStr)
if err == nil {
port = p
}
}
return &NameRecord{
Event: ev,
Name: name,
Type: recordType,
Value: value,
TTL: ttl,
Priority: priority,
Weight: weight,
Port: port,
}, nil
}
// ParseCertificate parses a kind 30104 event into a Certificate
func ParseCertificate(ev *event.E) (*Certificate, error) {
if uint16(ev.Kind) != KindCertificate {
return nil, fmt.Errorf("invalid event kind: expected %d, got %d", KindCertificate, ev.Kind)
}
name := getTagValue(ev, "name")
if name == "" {
return nil, fmt.Errorf("missing 'name' tag")
}
certPubkey := getTagValue(ev, "cert_pubkey")
if certPubkey == "" {
return nil, fmt.Errorf("missing 'cert_pubkey' tag")
}
validFromStr := getTagValue(ev, "valid_from")
if validFromStr == "" {
return nil, fmt.Errorf("missing 'valid_from' tag")
}
validFromUnix, err := strconv.ParseInt(validFromStr, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid valid_from timestamp: %w", err)
}
validFrom := time.Unix(validFromUnix, 0)
validUntilStr := getTagValue(ev, "valid_until")
if validUntilStr == "" {
return nil, fmt.Errorf("missing 'valid_until' tag")
}
validUntilUnix, err := strconv.ParseInt(validUntilStr, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid valid_until timestamp: %w", err)
}
validUntil := time.Unix(validUntilUnix, 0)
// Parse witness tags
var witnesses []WitnessSignature
witnessTags := getAllTags(ev, "witness")
for _, t := range witnessTags {
if len(t.T) < 3 {
continue // Skip malformed tags
}
witnesses = append(witnesses, WitnessSignature{
Pubkey: string(t.T[1]),
Signature: string(t.T[2]),
})
}
// Parse content JSON
algorithm := "secp256k1-schnorr"
usage := "tls-replacement"
if len(ev.Content) > 0 {
var metadata map[string]interface{}
if err := json.Unmarshal(ev.Content, &metadata); err == nil {
if alg, ok := metadata["algorithm"].(string); ok {
algorithm = alg
}
if u, ok := metadata["usage"].(string); ok {
usage = u
}
}
}
return &Certificate{
Event: ev,
Name: name,
CertPubkey: certPubkey,
ValidFrom: validFrom,
ValidUntil: validUntil,
Challenge: getTagValue(ev, "challenge"),
ChallengeProof: getTagValue(ev, "challenge_proof"),
Witnesses: witnesses,
Algorithm: algorithm,
Usage: usage,
}, nil
}
// ParseWitnessService parses a kind 30105 event into a WitnessService
func ParseWitnessService(ev *event.E) (*WitnessService, error) {
if uint16(ev.Kind) != KindWitnessService {
return nil, fmt.Errorf("invalid event kind: expected %d, got %d", KindWitnessService, ev.Kind)
}
endpoint := getTagValue(ev, "endpoint")
if endpoint == "" {
return nil, fmt.Errorf("missing 'endpoint' tag")
}
// Parse challenge tags
var challenges []string
challengeTags := getAllTags(ev, "challenges")
for _, t := range challengeTags {
if len(t.T) >= 2 {
challenges = append(challenges, string(t.T[1]))
}
}
maxValidityStr := getTagValue(ev, "max_validity")
maxValidity := 0
if maxValidityStr != "" {
mv, err := strconv.Atoi(maxValidityStr)
if err == nil {
maxValidity = mv
}
}
feeStr := getTagValue(ev, "fee")
fee := 0
if feeStr != "" {
f, err := strconv.Atoi(feeStr)
if err == nil {
fee = f
}
}
expirationStr := getTagValue(ev, "expiration")
var expiration time.Time
if expirationStr != "" {
expirationUnix, err := strconv.ParseInt(expirationStr, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid expiration timestamp: %w", err)
}
expiration = time.Unix(expirationUnix, 0)
}
// Parse content JSON
description := ""
contact := ""
if len(ev.Content) > 0 {
var metadata map[string]interface{}
if err := json.Unmarshal(ev.Content, &metadata); err == nil {
if desc, ok := metadata["description"].(string); ok {
description = desc
}
if cont, ok := metadata["contact"].(string); ok {
contact = cont
}
}
}
return &WitnessService{
Event: ev,
Endpoint: endpoint,
Challenges: challenges,
MaxValidity: maxValidity,
Fee: fee,
ReputationID: getTagValue(ev, "reputation"),
Description: description,
Contact: contact,
Expiration: expiration,
}, nil
}

167
pkg/find/sign.go Normal file
View File

@@ -0,0 +1,167 @@
package find
import (
"crypto/sha256"
"fmt"
"time"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/interfaces/signer"
)
// SignTransferAuth creates a signature for transfer authorization
// Message format: transfer:<name>:<new_owner_pubkey>:<timestamp>
func SignTransferAuth(name, newOwner string, timestamp time.Time, s signer.I) (string, error) {
// Normalize name
name = NormalizeName(name)
// Construct message
message := fmt.Sprintf("transfer:%s:%s:%d", name, newOwner, timestamp.Unix())
// Hash the message
hash := sha256.Sum256([]byte(message))
// Sign the hash
sig, err := s.Sign(hash[:])
if err != nil {
return "", fmt.Errorf("failed to sign transfer authorization: %w", err)
}
// Return hex-encoded signature
return hex.Enc(sig), nil
}
// SignChallengeProof creates a signature for certificate challenge proof
// Message format: challenge||name||cert_pubkey||valid_until
func SignChallengeProof(challenge, name, certPubkey string, validUntil time.Time, s signer.I) (string, error) {
// Normalize name
name = NormalizeName(name)
// Construct message
message := fmt.Sprintf("%s||%s||%s||%d", challenge, name, certPubkey, validUntil.Unix())
// Hash the message
hash := sha256.Sum256([]byte(message))
// Sign the hash
sig, err := s.Sign(hash[:])
if err != nil {
return "", fmt.Errorf("failed to sign challenge proof: %w", err)
}
// Return hex-encoded signature
return hex.Enc(sig), nil
}
// SignWitnessMessage creates a witness signature for a certificate
// Message format: cert_pubkey||name||valid_from||valid_until||challenge
func SignWitnessMessage(certPubkey, name string, validFrom, validUntil time.Time, challenge string, s signer.I) (string, error) {
// Normalize name
name = NormalizeName(name)
// Construct message
message := fmt.Sprintf("%s||%s||%d||%d||%s",
certPubkey, name, validFrom.Unix(), validUntil.Unix(), challenge)
// Hash the message
hash := sha256.Sum256([]byte(message))
// Sign the hash
sig, err := s.Sign(hash[:])
if err != nil {
return "", fmt.Errorf("failed to sign witness message: %w", err)
}
// Return hex-encoded signature
return hex.Enc(sig), nil
}
// CreateTransferAuthMessage constructs the transfer authorization message
// This is used for verification
func CreateTransferAuthMessage(name, newOwner string, timestamp time.Time) []byte {
name = NormalizeName(name)
message := fmt.Sprintf("transfer:%s:%s:%d", name, newOwner, timestamp.Unix())
hash := sha256.Sum256([]byte(message))
return hash[:]
}
// CreateChallengeProofMessage constructs the challenge proof message
// This is used for verification
func CreateChallengeProofMessage(challenge, name, certPubkey string, validUntil time.Time) []byte {
name = NormalizeName(name)
message := fmt.Sprintf("%s||%s||%s||%d", challenge, name, certPubkey, validUntil.Unix())
hash := sha256.Sum256([]byte(message))
return hash[:]
}
// CreateWitnessMessage constructs the witness message
// This is used for verification
func CreateWitnessMessage(certPubkey, name string, validFrom, validUntil time.Time, challenge string) []byte {
name = NormalizeName(name)
message := fmt.Sprintf("%s||%s||%d||%d||%s",
certPubkey, name, validFrom.Unix(), validUntil.Unix(), challenge)
hash := sha256.Sum256([]byte(message))
return hash[:]
}
// ParseTimestampFromProposal extracts the timestamp from a transfer authorization message
// Used for verification when the timestamp is embedded in the signature
func ParseTimestampFromProposal(proposalTime time.Time) time.Time {
// Round to nearest second for consistency
return proposalTime.Truncate(time.Second)
}
// FormatTransferAuthString formats the transfer auth message for display/debugging
func FormatTransferAuthString(name, newOwner string, timestamp time.Time) string {
name = NormalizeName(name)
return fmt.Sprintf("transfer:%s:%s:%d", name, newOwner, timestamp.Unix())
}
// FormatChallengeProofString formats the challenge proof message for display/debugging
func FormatChallengeProofString(challenge, name, certPubkey string, validUntil time.Time) string {
name = NormalizeName(name)
return fmt.Sprintf("%s||%s||%s||%d", challenge, name, certPubkey, validUntil.Unix())
}
// FormatWitnessString formats the witness message for display/debugging
func FormatWitnessString(certPubkey, name string, validFrom, validUntil time.Time, challenge string) string {
name = NormalizeName(name)
return fmt.Sprintf("%s||%s||%d||%d||%s",
certPubkey, name, validFrom.Unix(), validUntil.Unix(), challenge)
}
// SignProposal signs a registration proposal event
func SignProposal(ev *event.E, s signer.I) error {
return ev.Sign(s)
}
// SignAttestation signs an attestation event
func SignAttestation(ev *event.E, s signer.I) error {
return ev.Sign(s)
}
// SignTrustGraph signs a trust graph event
func SignTrustGraph(ev *event.E, s signer.I) error {
return ev.Sign(s)
}
// SignNameState signs a name state event
func SignNameState(ev *event.E, s signer.I) error {
return ev.Sign(s)
}
// SignNameRecord signs a name record event
func SignNameRecord(ev *event.E, s signer.I) error {
return ev.Sign(s)
}
// SignCertificate signs a certificate event
func SignCertificate(ev *event.E, s signer.I) error {
return ev.Sign(s)
}
// SignWitnessService signs a witness service event
func SignWitnessService(ev *event.E, s signer.I) error {
return ev.Sign(s)
}

168
pkg/find/transfer.go Normal file
View File

@@ -0,0 +1,168 @@
package find
import (
"fmt"
"time"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/interfaces/signer"
)
// CreateTransferProposal creates a complete transfer proposal with authorization from previous owner
func CreateTransferProposal(name string, prevOwnerSigner, newOwnerSigner signer.I) (*event.E, error) {
// Normalize name
name = NormalizeName(name)
// Validate name
if err := ValidateName(name); err != nil {
return nil, fmt.Errorf("invalid name: %w", err)
}
// Get public keys
prevOwnerPubkey := hex.Enc(prevOwnerSigner.Pub())
newOwnerPubkey := hex.Enc(newOwnerSigner.Pub())
// Create timestamp for the transfer
timestamp := time.Now()
// Sign the transfer authorization with previous owner's key
prevSig, err := SignTransferAuth(name, newOwnerPubkey, timestamp, prevOwnerSigner)
if err != nil {
return nil, fmt.Errorf("failed to create transfer authorization: %w", err)
}
// Create the transfer proposal event signed by new owner
proposal, err := NewRegistrationProposalWithTransfer(name, prevOwnerPubkey, prevSig, newOwnerSigner)
if err != nil {
return nil, fmt.Errorf("failed to create transfer proposal: %w", err)
}
return proposal, nil
}
// ValidateTransferProposal validates a transfer proposal against the current owner
func ValidateTransferProposal(proposal *RegistrationProposal, currentOwner string) error {
// Check that this is a transfer action
if proposal.Action != ActionTransfer {
return fmt.Errorf("not a transfer action: %s", proposal.Action)
}
// Check that prev_owner is set
if proposal.PrevOwner == "" {
return fmt.Errorf("missing prev_owner in transfer proposal")
}
// Check that prev_sig is set
if proposal.PrevSig == "" {
return fmt.Errorf("missing prev_sig in transfer proposal")
}
// Verify that prev_owner matches current owner
if proposal.PrevOwner != currentOwner {
return fmt.Errorf("prev_owner %s does not match current owner %s",
proposal.PrevOwner, currentOwner)
}
// Get new owner from proposal event
newOwnerPubkey := hex.Enc(proposal.Event.Pubkey)
// Verify the transfer authorization signature
// Use proposal creation time as timestamp
timestamp := time.Unix(proposal.Event.CreatedAt, 0)
ok, err := VerifyTransferAuth(proposal.Name, newOwnerPubkey, proposal.PrevOwner,
timestamp, proposal.PrevSig)
if err != nil {
return fmt.Errorf("transfer authorization verification failed: %w", err)
}
if !ok {
return fmt.Errorf("invalid transfer authorization signature")
}
return nil
}
// PrepareTransferAuth prepares the transfer authorization data that needs to be signed
// This is a helper for wallets/clients that want to show what they're signing
func PrepareTransferAuth(name, newOwner string, timestamp time.Time) TransferAuthorization {
return TransferAuthorization{
Name: NormalizeName(name),
NewOwner: newOwner,
Timestamp: timestamp,
}
}
// AuthorizeTransfer creates a transfer authorization signature
// This is meant to be used by the current owner to authorize a transfer to a new owner
func AuthorizeTransfer(name, newOwnerPubkey string, ownerSigner signer.I) (prevSig string, timestamp time.Time, err error) {
// Normalize name
name = NormalizeName(name)
// Validate name
if err := ValidateName(name); err != nil {
return "", time.Time{}, fmt.Errorf("invalid name: %w", err)
}
// Create timestamp
timestamp = time.Now()
// Sign the authorization
prevSig, err = SignTransferAuth(name, newOwnerPubkey, timestamp, ownerSigner)
if err != nil {
return "", time.Time{}, fmt.Errorf("failed to sign transfer auth: %w", err)
}
return prevSig, timestamp, nil
}
// CreateTransferProposalWithAuth creates a transfer proposal using a pre-existing authorization
// This is useful when the previous owner has already provided their signature
func CreateTransferProposalWithAuth(name, prevOwnerPubkey, prevSig string, newOwnerSigner signer.I) (*event.E, error) {
// Normalize name
name = NormalizeName(name)
// Validate name
if err := ValidateName(name); err != nil {
return nil, fmt.Errorf("invalid name: %w", err)
}
// Create the transfer proposal event
proposal, err := NewRegistrationProposalWithTransfer(name, prevOwnerPubkey, prevSig, newOwnerSigner)
if err != nil {
return nil, fmt.Errorf("failed to create transfer proposal: %w", err)
}
return proposal, nil
}
// VerifyTransferProposalSignature verifies both the event signature and transfer authorization
func VerifyTransferProposalSignature(proposal *RegistrationProposal) error {
// Verify the event signature itself
if err := VerifyEvent(proposal.Event); err != nil {
return fmt.Errorf("invalid event signature: %w", err)
}
// If this is a transfer, verify the transfer authorization
if proposal.Action == ActionTransfer {
// Get new owner from proposal event
newOwnerPubkey := hex.Enc(proposal.Event.Pubkey)
// Use proposal creation time as timestamp
timestamp := time.Unix(proposal.Event.CreatedAt, 0)
// Verify transfer auth
ok, err := VerifyTransferAuth(proposal.Name, newOwnerPubkey, proposal.PrevOwner,
timestamp, proposal.PrevSig)
if err != nil {
return fmt.Errorf("transfer authorization verification failed: %w", err)
}
if !ok {
return fmt.Errorf("invalid transfer authorization signature")
}
}
return nil
}

180
pkg/find/types.go Normal file
View File

@@ -0,0 +1,180 @@
package find
import (
"time"
"next.orly.dev/pkg/encoders/event"
)
// Event kind constants as defined in the NIP
const (
KindRegistrationProposal = 30100 // Parameterized replaceable
KindAttestation = 20100 // Ephemeral
KindTrustGraph = 30101 // Parameterized replaceable
KindNameState = 30102 // Parameterized replaceable
KindNameRecords = 30103 // Parameterized replaceable
KindCertificate = 30104 // Parameterized replaceable
KindWitnessService = 30105 // Parameterized replaceable
)
// Action types for registration proposals
const (
ActionRegister = "register"
ActionTransfer = "transfer"
)
// Decision types for attestations
const (
DecisionApprove = "approve"
DecisionReject = "reject"
DecisionAbstain = "abstain"
)
// DNS record types
const (
RecordTypeA = "A"
RecordTypeAAAA = "AAAA"
RecordTypeCNAME = "CNAME"
RecordTypeMX = "MX"
RecordTypeTXT = "TXT"
RecordTypeNS = "NS"
RecordTypeSRV = "SRV"
)
// Time constants
const (
ProposalExpiry = 5 * time.Minute // Proposals expire after 5 minutes
AttestationExpiry = 3 * time.Minute // Attestations expire after 3 minutes
TrustGraphExpiry = 30 * 24 * time.Hour // Trust graphs expire after 30 days
NameRegistrationPeriod = 365 * 24 * time.Hour // Names expire after 1 year
PreferentialRenewalDays = 30 // Final 30 days before expiration
CertificateValidity = 90 * 24 * time.Hour // Recommended certificate validity
WitnessServiceExpiry = 180 * 24 * time.Hour // Witness service info expires after 180 days
)
// RegistrationProposal represents a kind 30100 event
type RegistrationProposal struct {
Event *event.E
Name string
Action string // "register" or "transfer"
PrevOwner string // Previous owner pubkey (for transfers)
PrevSig string // Signature from previous owner (for transfers)
Expiration time.Time
}
// Attestation represents a kind 20100 event
type Attestation struct {
Event *event.E
ProposalID string // Event ID of the proposal being attested
Decision string // "approve", "reject", or "abstain"
Weight int // Stake/confidence weight (default 100)
Reason string // Human-readable justification
ServiceURL string // Registry service endpoint
Expiration time.Time
}
// TrustEntry represents a single trust relationship
type TrustEntry struct {
Pubkey string
ServiceURL string
TrustScore float64 // 0.0 to 1.0
}
// TrustGraph represents a kind 30101 event
type TrustGraph struct {
Event *event.E
Entries []TrustEntry
Expiration time.Time
}
// NameState represents a kind 30102 event
type NameState struct {
Event *event.E
Name string
Owner string // Current owner pubkey
RegisteredAt time.Time
ProposalID string // Event ID of the registration proposal
Attestations int // Number of attestations
Confidence float64 // Consensus confidence score (0.0 to 1.0)
Expiration time.Time
}
// NameRecord represents a kind 30103 event
type NameRecord struct {
Event *event.E
Name string
Type string // A, AAAA, CNAME, MX, TXT, NS, SRV
Value string
TTL int // Cache TTL in seconds
Priority int // For MX and SRV records
Weight int // For SRV records
Port int // For SRV records
}
// RecordLimits defines per-type record limits
var RecordLimits = map[string]int{
RecordTypeA: 5,
RecordTypeAAAA: 5,
RecordTypeCNAME: 1,
RecordTypeMX: 5,
RecordTypeTXT: 10,
RecordTypeNS: 5,
RecordTypeSRV: 10,
}
// Certificate represents a kind 30104 event
type Certificate struct {
Event *event.E
Name string
CertPubkey string // Public key for the service
ValidFrom time.Time
ValidUntil time.Time
Challenge string // Challenge token for ownership proof
ChallengeProof string // Signature over challenge
Witnesses []WitnessSignature
Algorithm string // e.g., "secp256k1-schnorr"
Usage string // e.g., "tls-replacement"
}
// WitnessSignature represents a witness attestation on a certificate
type WitnessSignature struct {
Pubkey string
Signature string
}
// WitnessService represents a kind 30105 event
type WitnessService struct {
Event *event.E
Endpoint string
Challenges []string // Supported challenge types: "txt", "http", "event"
MaxValidity int // Maximum certificate validity in seconds
Fee int // Fee in sats per certificate
ReputationID string // Event ID of reputation event
Description string
Contact string
Expiration time.Time
}
// TransferAuthorization represents the message signed for transfer authorization
type TransferAuthorization struct {
Name string
NewOwner string
Timestamp time.Time
}
// ChallengeProofMessage represents the message signed for certificate challenge proof
type ChallengeProofMessage struct {
Challenge string
Name string
CertPubkey string
ValidUntil time.Time
}
// WitnessMessage represents the message signed by witnesses
type WitnessMessage struct {
CertPubkey string
Name string
ValidFrom time.Time
ValidUntil time.Time
Challenge string
}

221
pkg/find/validation.go Normal file
View File

@@ -0,0 +1,221 @@
package find
import (
"errors"
"fmt"
"regexp"
"strings"
)
var (
ErrInvalidName = errors.New("invalid name format")
ErrNameTooLong = errors.New("name exceeds 253 characters")
ErrLabelTooLong = errors.New("label exceeds 63 characters")
ErrLabelEmpty = errors.New("label is empty")
ErrInvalidCharacter = errors.New("invalid character in name")
ErrInvalidHyphen = errors.New("label cannot start or end with hyphen")
ErrAllNumericLabel = errors.New("label cannot be all numeric")
ErrInvalidRecordValue = errors.New("invalid record value")
ErrRecordLimitExceeded = errors.New("record limit exceeded")
ErrNotOwner = errors.New("not the name owner")
ErrNameExpired = errors.New("name registration expired")
ErrInRenewalWindow = errors.New("name is in renewal window")
ErrNotRenewalWindow = errors.New("not in renewal window")
)
// Name format validation regex
var (
labelRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
allNumeric = regexp.MustCompile(`^[0-9]+$`)
)
// NormalizeName converts a name to lowercase
func NormalizeName(name string) string {
return strings.ToLower(name)
}
// ValidateName validates a name according to DNS naming rules
func ValidateName(name string) error {
// Normalize to lowercase
name = NormalizeName(name)
// Check total length
if len(name) > 253 {
return fmt.Errorf("%w: %d > 253", ErrNameTooLong, len(name))
}
if len(name) == 0 {
return fmt.Errorf("%w: name is empty", ErrInvalidName)
}
// Split into labels
labels := strings.Split(name, ".")
for i, label := range labels {
if err := validateLabel(label); err != nil {
return fmt.Errorf("invalid label %d (%s): %w", i, label, err)
}
}
return nil
}
// validateLabel validates a single label according to DNS rules
func validateLabel(label string) error {
// Check length
if len(label) == 0 {
return ErrLabelEmpty
}
if len(label) > 63 {
return fmt.Errorf("%w: %d > 63", ErrLabelTooLong, len(label))
}
// Check character set and hyphen placement
if !labelRegex.MatchString(label) {
if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
return ErrInvalidHyphen
}
return ErrInvalidCharacter
}
// Check not all numeric
if allNumeric.MatchString(label) {
return ErrAllNumericLabel
}
return nil
}
// GetParentDomain returns the parent domain of a name
// e.g., "www.example.com" -> "example.com", "example.com" -> "com", "com" -> ""
func GetParentDomain(name string) string {
name = NormalizeName(name)
parts := strings.Split(name, ".")
if len(parts) <= 1 {
return "" // TLD has no parent
}
return strings.Join(parts[1:], ".")
}
// IsTLD returns true if the name is a top-level domain (single label)
func IsTLD(name string) bool {
name = NormalizeName(name)
return !strings.Contains(name, ".")
}
// ValidateIPv4 validates an IPv4 address format
func ValidateIPv4(ip string) error {
parts := strings.Split(ip, ".")
if len(parts) != 4 {
return fmt.Errorf("%w: invalid IPv4 format", ErrInvalidRecordValue)
}
for _, part := range parts {
var octet int
if _, err := fmt.Sscanf(part, "%d", &octet); err != nil {
return fmt.Errorf("%w: invalid IPv4 octet: %v", ErrInvalidRecordValue, err)
}
if octet < 0 || octet > 255 {
return fmt.Errorf("%w: IPv4 octet out of range: %d", ErrInvalidRecordValue, octet)
}
}
return nil
}
// ValidateIPv6 validates an IPv6 address format (simplified check)
func ValidateIPv6(ip string) error {
// Basic validation - contains colons and valid hex characters
if !strings.Contains(ip, ":") {
return fmt.Errorf("%w: invalid IPv6 format", ErrInvalidRecordValue)
}
// Split by colons
parts := strings.Split(ip, ":")
if len(parts) < 3 || len(parts) > 8 {
return fmt.Errorf("%w: invalid IPv6 segment count", ErrInvalidRecordValue)
}
// Check for valid hex characters
validHex := regexp.MustCompile(`^[0-9a-fA-F]*$`)
for _, part := range parts {
if part == "" {
continue // Allow :: notation
}
if len(part) > 4 {
return fmt.Errorf("%w: IPv6 segment too long", ErrInvalidRecordValue)
}
if !validHex.MatchString(part) {
return fmt.Errorf("%w: invalid IPv6 hex", ErrInvalidRecordValue)
}
}
return nil
}
// ValidateRecordValue validates a record value based on its type
func ValidateRecordValue(recordType, value string) error {
switch recordType {
case RecordTypeA:
return ValidateIPv4(value)
case RecordTypeAAAA:
return ValidateIPv6(value)
case RecordTypeCNAME, RecordTypeMX, RecordTypeNS:
return ValidateName(value)
case RecordTypeTXT:
if len(value) > 1024 {
return fmt.Errorf("%w: TXT record exceeds 1024 characters", ErrInvalidRecordValue)
}
return nil
case RecordTypeSRV:
return ValidateName(value) // Hostname for SRV
default:
return fmt.Errorf("%w: unknown record type: %s", ErrInvalidRecordValue, recordType)
}
}
// ValidateRecordLimit checks if adding a record would exceed type limits
func ValidateRecordLimit(recordType string, currentCount int) error {
limit, ok := RecordLimits[recordType]
if !ok {
return fmt.Errorf("%w: unknown record type: %s", ErrInvalidRecordValue, recordType)
}
if currentCount >= limit {
return fmt.Errorf("%w: %s records limited to %d", ErrRecordLimitExceeded, recordType, limit)
}
return nil
}
// ValidatePriority validates priority value (0-65535)
func ValidatePriority(priority int) error {
if priority < 0 || priority > 65535 {
return fmt.Errorf("%w: priority must be 0-65535", ErrInvalidRecordValue)
}
return nil
}
// ValidateWeight validates weight value (0-65535)
func ValidateWeight(weight int) error {
if weight < 0 || weight > 65535 {
return fmt.Errorf("%w: weight must be 0-65535", ErrInvalidRecordValue)
}
return nil
}
// ValidatePort validates port value (0-65535)
func ValidatePort(port int) error {
if port < 0 || port > 65535 {
return fmt.Errorf("%w: port must be 0-65535", ErrInvalidRecordValue)
}
return nil
}
// ValidateTrustScore validates trust score (0.0-1.0)
func ValidateTrustScore(score float64) error {
if score < 0.0 || score > 1.0 {
return fmt.Errorf("trust score must be between 0.0 and 1.0, got %f", score)
}
return nil
}

317
pkg/find/verify.go Normal file
View File

@@ -0,0 +1,317 @@
package find
import (
"fmt"
"time"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/interfaces/signer/p8k"
)
// VerifyEvent verifies the signature of a Nostr event
func VerifyEvent(ev *event.E) error {
ok, err := ev.Verify()
if err != nil {
return fmt.Errorf("signature verification failed: %w", err)
}
if !ok {
return fmt.Errorf("invalid signature")
}
return nil
}
// VerifyTransferAuth verifies a transfer authorization signature
func VerifyTransferAuth(name, newOwner, prevOwner string, timestamp time.Time, sigHex string) (bool, error) {
// Create the message
msgHash := CreateTransferAuthMessage(name, newOwner, timestamp)
// Decode signature
sig, err := hex.Dec(sigHex)
if err != nil {
return false, fmt.Errorf("invalid signature hex: %w", err)
}
// Decode pubkey
pubkey, err := hex.Dec(prevOwner)
if err != nil {
return false, fmt.Errorf("invalid pubkey hex: %w", err)
}
// Create verifier with public key
verifier, err := p8k.New()
if err != nil {
return false, fmt.Errorf("failed to create verifier: %w", err)
}
if err := verifier.InitPub(pubkey); err != nil {
return false, fmt.Errorf("failed to init pubkey: %w", err)
}
// Verify signature
ok, err := verifier.Verify(msgHash, sig)
if err != nil {
return false, fmt.Errorf("verification failed: %w", err)
}
return ok, nil
}
// VerifyChallengeProof verifies a certificate challenge proof signature
func VerifyChallengeProof(challenge, name, certPubkey, owner string, validUntil time.Time, sigHex string) (bool, error) {
// Create the message
msgHash := CreateChallengeProofMessage(challenge, name, certPubkey, validUntil)
// Decode signature
sig, err := hex.Dec(sigHex)
if err != nil {
return false, fmt.Errorf("invalid signature hex: %w", err)
}
// Decode pubkey
pubkey, err := hex.Dec(owner)
if err != nil {
return false, fmt.Errorf("invalid pubkey hex: %w", err)
}
// Create verifier with public key
verifier, err := p8k.New()
if err != nil {
return false, fmt.Errorf("failed to create verifier: %w", err)
}
if err := verifier.InitPub(pubkey); err != nil {
return false, fmt.Errorf("failed to init pubkey: %w", err)
}
// Verify signature
ok, err := verifier.Verify(msgHash, sig)
if err != nil {
return false, fmt.Errorf("verification failed: %w", err)
}
return ok, nil
}
// VerifyWitnessSignature verifies a witness signature on a certificate
func VerifyWitnessSignature(certPubkey, name string, validFrom, validUntil time.Time,
challenge, witnessPubkey, sigHex string) (bool, error) {
// Create the message
msgHash := CreateWitnessMessage(certPubkey, name, validFrom, validUntil, challenge)
// Decode signature
sig, err := hex.Dec(sigHex)
if err != nil {
return false, fmt.Errorf("invalid signature hex: %w", err)
}
// Decode pubkey
pubkey, err := hex.Dec(witnessPubkey)
if err != nil {
return false, fmt.Errorf("invalid pubkey hex: %w", err)
}
// Create verifier with public key
verifier, err := p8k.New()
if err != nil {
return false, fmt.Errorf("failed to create verifier: %w", err)
}
if err := verifier.InitPub(pubkey); err != nil {
return false, fmt.Errorf("failed to init pubkey: %w", err)
}
// Verify signature
ok, err := verifier.Verify(msgHash, sig)
if err != nil {
return false, fmt.Errorf("verification failed: %w", err)
}
return ok, nil
}
// VerifyNameOwnership checks if a record's owner matches the name state owner
func VerifyNameOwnership(nameState *NameState, record *NameRecord) error {
recordOwner := hex.Enc(record.Event.Pubkey)
if recordOwner != nameState.Owner {
return fmt.Errorf("%w: record owner %s != name owner %s",
ErrNotOwner, recordOwner, nameState.Owner)
}
return nil
}
// IsExpired checks if a time-based expiration has passed
func IsExpired(expiration time.Time) bool {
return time.Now().After(expiration)
}
// IsInRenewalWindow checks if the current time is within the preferential renewal window
// (final 30 days before expiration)
func IsInRenewalWindow(expiration time.Time) bool {
now := time.Now()
renewalWindowStart := expiration.Add(-PreferentialRenewalDays * 24 * time.Hour)
return now.After(renewalWindowStart) && now.Before(expiration)
}
// CanRegister checks if a name can be registered based on its state and expiration
func CanRegister(nameState *NameState, proposerPubkey string) error {
// If no name state exists, anyone can register
if nameState == nil {
return nil
}
// Check if name is expired
if IsExpired(nameState.Expiration) {
// Name is expired, anyone can register
return nil
}
// Check if in renewal window
if IsInRenewalWindow(nameState.Expiration) {
// Only current owner can register during renewal window
if proposerPubkey != nameState.Owner {
return ErrInRenewalWindow
}
return nil
}
// Name is still owned and not in renewal window
return fmt.Errorf("name is owned by %s until %s", nameState.Owner, nameState.Expiration)
}
// VerifyProposalExpiration checks if a proposal has expired
func VerifyProposalExpiration(proposal *RegistrationProposal) error {
if !proposal.Expiration.IsZero() && IsExpired(proposal.Expiration) {
return fmt.Errorf("proposal expired at %s", proposal.Expiration)
}
return nil
}
// VerifyAttestationExpiration checks if an attestation has expired
func VerifyAttestationExpiration(attestation *Attestation) error {
if !attestation.Expiration.IsZero() && IsExpired(attestation.Expiration) {
return fmt.Errorf("attestation expired at %s", attestation.Expiration)
}
return nil
}
// VerifyTrustGraphExpiration checks if a trust graph has expired
func VerifyTrustGraphExpiration(trustGraph *TrustGraph) error {
if !trustGraph.Expiration.IsZero() && IsExpired(trustGraph.Expiration) {
return fmt.Errorf("trust graph expired at %s", trustGraph.Expiration)
}
return nil
}
// VerifyNameStateExpiration checks if a name state has expired
func VerifyNameStateExpiration(nameState *NameState) error {
if !nameState.Expiration.IsZero() && IsExpired(nameState.Expiration) {
return ErrNameExpired
}
return nil
}
// VerifyCertificateValidity checks if a certificate is currently valid
func VerifyCertificateValidity(cert *Certificate) error {
now := time.Now()
if now.Before(cert.ValidFrom) {
return fmt.Errorf("certificate not yet valid (valid from %s)", cert.ValidFrom)
}
if now.After(cert.ValidUntil) {
return fmt.Errorf("certificate expired at %s", cert.ValidUntil)
}
return nil
}
// VerifyCertificate performs complete certificate verification
func VerifyCertificate(cert *Certificate, nameState *NameState, trustedWitnesses []string) error {
// Verify certificate is not expired
if err := VerifyCertificateValidity(cert); err != nil {
return err
}
// Verify name is not expired
if err := VerifyNameStateExpiration(nameState); err != nil {
return err
}
// Verify certificate owner matches name owner
certOwner := hex.Enc(cert.Event.Pubkey)
if certOwner != nameState.Owner {
return fmt.Errorf("certificate owner %s != name owner %s", certOwner, nameState.Owner)
}
// Verify challenge proof
ok, err := VerifyChallengeProof(cert.Challenge, cert.Name, cert.CertPubkey,
nameState.Owner, cert.ValidUntil, cert.ChallengeProof)
if err != nil {
return fmt.Errorf("challenge proof verification failed: %w", err)
}
if !ok {
return fmt.Errorf("invalid challenge proof signature")
}
// Count trusted witnesses
trustedCount := 0
for _, witness := range cert.Witnesses {
// Check if witness is in trusted list
isTrusted := false
for _, trusted := range trustedWitnesses {
if witness.Pubkey == trusted {
isTrusted = true
break
}
}
if !isTrusted {
continue
}
// Verify witness signature
ok, err := VerifyWitnessSignature(cert.CertPubkey, cert.Name,
cert.ValidFrom, cert.ValidUntil, cert.Challenge,
witness.Pubkey, witness.Signature)
if err != nil {
return fmt.Errorf("witness %s signature verification failed: %w", witness.Pubkey, err)
}
if !ok {
return fmt.Errorf("invalid witness %s signature", witness.Pubkey)
}
trustedCount++
}
// Require at least 3 trusted witnesses
if trustedCount < 3 {
return fmt.Errorf("insufficient trusted witnesses: %d < 3", trustedCount)
}
return nil
}
// VerifySubdomainAuthority checks if the proposer owns the parent domain
func VerifySubdomainAuthority(name string, proposerPubkey string, parentNameState *NameState) error {
parent := GetParentDomain(name)
// TLDs have no parent
if parent == "" {
return nil
}
// Parent must exist
if parentNameState == nil {
return fmt.Errorf("parent domain %s does not exist", parent)
}
// Proposer must own parent
if proposerPubkey != parentNameState.Owner {
return fmt.Errorf("proposer %s does not own parent domain %s (owner: %s)",
proposerPubkey, parent, parentNameState.Owner)
}
return nil
}

312
pkg/protocol/nip43/types.go Normal file
View File

@@ -0,0 +1,312 @@
package nip43
import (
"crypto/rand"
"encoding/base64"
"sync"
"time"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/interfaces/signer/p8k"
)
// Event kinds defined by NIP-43
const (
KindMemberList = 13534 // Membership list published by relay
KindAddUser = 8000 // Add user event published by relay
KindRemoveUser = 8001 // Remove user event published by relay
KindJoinRequest = 28934 // Join request sent by user
KindInviteReq = 28935 // Invite request (ephemeral)
KindLeaveRequest = 28936 // Leave request sent by user
)
// InviteCode represents a claim/invite code for relay access
type InviteCode struct {
Code string
ExpiresAt time.Time
UsedBy []byte // pubkey that used this code, nil if unused
CreatedAt time.Time
}
// InviteManager manages invite codes for NIP-43
type InviteManager struct {
mu sync.RWMutex
codes map[string]*InviteCode
expiry time.Duration
}
// NewInviteManager creates a new invite code manager
func NewInviteManager(expiryDuration time.Duration) *InviteManager {
if expiryDuration == 0 {
expiryDuration = 24 * time.Hour // Default: 24 hours
}
return &InviteManager{
codes: make(map[string]*InviteCode),
expiry: expiryDuration,
}
}
// GenerateCode creates a new invite code
func (im *InviteManager) GenerateCode() (code string, err error) {
// Generate 32 random bytes
b := make([]byte, 32)
if _, err = rand.Read(b); err != nil {
return
}
code = base64.URLEncoding.EncodeToString(b)
im.mu.Lock()
defer im.mu.Unlock()
im.codes[code] = &InviteCode{
Code: code,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(im.expiry),
}
return code, nil
}
// ValidateAndConsume validates an invite code and marks it as used by the given pubkey
func (im *InviteManager) ValidateAndConsume(code string, pubkey []byte) (valid bool, reason string) {
im.mu.Lock()
defer im.mu.Unlock()
invite, exists := im.codes[code]
if !exists {
return false, "invalid invite code"
}
if time.Now().After(invite.ExpiresAt) {
delete(im.codes, code)
return false, "invite code expired"
}
if invite.UsedBy != nil {
return false, "invite code already used"
}
// Mark as used
invite.UsedBy = make([]byte, len(pubkey))
copy(invite.UsedBy, pubkey)
return true, ""
}
// CleanupExpired removes expired invite codes
func (im *InviteManager) CleanupExpired() {
im.mu.Lock()
defer im.mu.Unlock()
now := time.Now()
for code, invite := range im.codes {
if now.After(invite.ExpiresAt) {
delete(im.codes, code)
}
}
}
// BuildMemberListEvent creates a kind 13534 membership list event
// relaySecretKey: the relay's identity secret key (32 bytes)
// members: list of member pubkeys (32 bytes each)
func BuildMemberListEvent(relaySecretKey []byte, members [][]byte) (*event.E, error) {
// Create signer
signer, err := p8k.New()
if err != nil {
return nil, err
}
if err = signer.InitSec(relaySecretKey); err != nil {
return nil, err
}
ev := event.New()
ev.Kind = KindMemberList
copy(ev.Pubkey, signer.Pub())
// Initialize tags
ev.Tags = tag.NewS()
// Add NIP-70 `-` tag
ev.Tags.Append(tag.NewFromAny("-"))
// Add member tags
for _, member := range members {
if len(member) == 32 {
ev.Tags.Append(tag.NewFromAny("member", hex.Enc(member)))
}
}
ev.CreatedAt = time.Now().Unix()
ev.Content = []byte("")
// Sign the event
if err := ev.Sign(signer); err != nil {
return nil, err
}
return ev, nil
}
// BuildAddUserEvent creates a kind 8000 add user event
func BuildAddUserEvent(relaySecretKey []byte, userPubkey []byte) (*event.E, error) {
// Create signer
signer, err := p8k.New()
if err != nil {
return nil, err
}
if err = signer.InitSec(relaySecretKey); err != nil {
return nil, err
}
ev := event.New()
ev.Kind = KindAddUser
copy(ev.Pubkey, signer.Pub())
// Initialize tags
ev.Tags = tag.NewS()
// Add NIP-70 `-` tag
ev.Tags.Append(tag.NewFromAny("-"))
// Add p tag for the user
if len(userPubkey) == 32 {
ev.Tags.Append(tag.NewFromAny("p", hex.Enc(userPubkey)))
}
ev.CreatedAt = time.Now().Unix()
ev.Content = []byte("")
// Sign the event
if err := ev.Sign(signer); err != nil {
return nil, err
}
return ev, nil
}
// BuildRemoveUserEvent creates a kind 8001 remove user event
func BuildRemoveUserEvent(relaySecretKey []byte, userPubkey []byte) (*event.E, error) {
// Create signer
signer, err := p8k.New()
if err != nil {
return nil, err
}
if err = signer.InitSec(relaySecretKey); err != nil {
return nil, err
}
ev := event.New()
ev.Kind = KindRemoveUser
copy(ev.Pubkey, signer.Pub())
// Initialize tags
ev.Tags = tag.NewS()
// Add NIP-70 `-` tag
ev.Tags.Append(tag.NewFromAny("-"))
// Add p tag for the user
if len(userPubkey) == 32 {
ev.Tags.Append(tag.NewFromAny("p", hex.Enc(userPubkey)))
}
ev.CreatedAt = time.Now().Unix()
ev.Content = []byte("")
// Sign the event
if err := ev.Sign(signer); err != nil {
return nil, err
}
return ev, nil
}
// BuildInviteEvent creates a kind 28935 invite event (ephemeral)
func BuildInviteEvent(relaySecretKey []byte, inviteCode string) (*event.E, error) {
// Create signer
signer, err := p8k.New()
if err != nil {
return nil, err
}
if err = signer.InitSec(relaySecretKey); err != nil {
return nil, err
}
ev := event.New()
ev.Kind = KindInviteReq
copy(ev.Pubkey, signer.Pub())
// Initialize tags
ev.Tags = tag.NewS()
// Add NIP-70 `-` tag
ev.Tags.Append(tag.NewFromAny("-"))
// Add claim tag
ev.Tags.Append(tag.NewFromAny("claim", inviteCode))
ev.CreatedAt = time.Now().Unix()
ev.Content = []byte("")
// Sign the event
if err := ev.Sign(signer); err != nil {
return nil, err
}
return ev, nil
}
// ValidateJoinRequest validates a kind 28934 join request event
func ValidateJoinRequest(ev *event.E) (inviteCode string, valid bool, reason string) {
// Must be kind 28934
if ev.Kind != KindJoinRequest {
return "", false, "invalid event kind"
}
// Must have NIP-70 `-` tag
hasMinusTag := ev.Tags.GetFirst([]byte("-")) != nil
if !hasMinusTag {
return "", false, "missing NIP-70 `-` tag"
}
// Must have claim tag
claimTag := ev.Tags.GetFirst([]byte("claim"))
if claimTag != nil && claimTag.Len() >= 2 {
inviteCode = string(claimTag.T[1])
}
if inviteCode == "" {
return "", false, "missing claim tag"
}
// Check timestamp (must be recent, within +/- 10 minutes)
now := time.Now().Unix()
if ev.CreatedAt < now-600 || ev.CreatedAt > now+600 {
return inviteCode, false, "timestamp out of range"
}
return inviteCode, true, ""
}
// ValidateLeaveRequest validates a kind 28936 leave request event
func ValidateLeaveRequest(ev *event.E) (valid bool, reason string) {
// Must be kind 28936
if ev.Kind != KindLeaveRequest {
return false, "invalid event kind"
}
// Must have NIP-70 `-` tag
hasMinusTag := ev.Tags.GetFirst([]byte("-")) != nil
if !hasMinusTag {
return false, "missing NIP-70 `-` tag"
}
// Check timestamp (must be recent, within +/- 10 minutes)
now := time.Now().Unix()
if ev.CreatedAt < now-600 || ev.CreatedAt > now+600 {
return false, "timestamp out of range"
}
return true, ""
}

View File

@@ -0,0 +1,514 @@
package nip43
import (
"testing"
"time"
"next.orly.dev/pkg/crypto/keys"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/tag"
)
// TestInviteManager_GenerateCode tests invite code generation
func TestInviteManager_GenerateCode(t *testing.T) {
im := NewInviteManager(24 * time.Hour)
code, err := im.GenerateCode()
if err != nil {
t.Fatalf("failed to generate code: %v", err)
}
if code == "" {
t.Fatal("generated code is empty")
}
// Verify the code exists in the manager
im.mu.Lock()
invite, exists := im.codes[code]
im.mu.Unlock()
if !exists {
t.Fatal("generated code not found in manager")
}
if invite.Code != code {
t.Errorf("code mismatch: got %s, want %s", invite.Code, code)
}
if invite.UsedBy != nil {
t.Error("newly generated code should not be used")
}
if time.Until(invite.ExpiresAt) > 24*time.Hour {
t.Error("expiry time is too far in the future")
}
}
// TestInviteManager_ValidateAndConsume tests invite code validation
func TestInviteManager_ValidateAndConsume(t *testing.T) {
im := NewInviteManager(24 * time.Hour)
// Generate a code
code, err := im.GenerateCode()
if err != nil {
t.Fatalf("failed to generate code: %v", err)
}
testPubkey := make([]byte, 32)
for i := range testPubkey {
testPubkey[i] = byte(i)
}
// Test valid code
valid, reason := im.ValidateAndConsume(code, testPubkey)
if !valid {
t.Fatalf("valid code rejected: %s", reason)
}
// Test already used code
valid, reason = im.ValidateAndConsume(code, testPubkey)
if valid {
t.Error("already used code was accepted")
}
if reason != "invite code already used" {
t.Errorf("wrong rejection reason: got %s", reason)
}
// Test invalid code
valid, reason = im.ValidateAndConsume("invalid-code", testPubkey)
if valid {
t.Error("invalid code was accepted")
}
if reason != "invalid invite code" {
t.Errorf("wrong rejection reason: got %s", reason)
}
}
// TestInviteManager_ExpiredCode tests expired invite code handling
func TestInviteManager_ExpiredCode(t *testing.T) {
// Create manager with very short expiry
im := NewInviteManager(1 * time.Millisecond)
code, err := im.GenerateCode()
if err != nil {
t.Fatalf("failed to generate code: %v", err)
}
// Wait for expiry
time.Sleep(10 * time.Millisecond)
testPubkey := make([]byte, 32)
valid, reason := im.ValidateAndConsume(code, testPubkey)
if valid {
t.Error("expired code was accepted")
}
if reason != "invite code expired" {
t.Errorf("wrong rejection reason: got %s, want 'invite code expired'", reason)
}
// Verify code was deleted
im.mu.Lock()
_, exists := im.codes[code]
im.mu.Unlock()
if exists {
t.Error("expired code was not deleted")
}
}
// TestInviteManager_CleanupExpired tests cleanup of expired codes
func TestInviteManager_CleanupExpired(t *testing.T) {
im := NewInviteManager(1 * time.Millisecond)
// Generate multiple codes
codes := make([]string, 5)
for i := 0; i < 5; i++ {
code, err := im.GenerateCode()
if err != nil {
t.Fatalf("failed to generate code %d: %v", i, err)
}
codes[i] = code
}
// Wait for expiry
time.Sleep(10 * time.Millisecond)
// Cleanup
im.CleanupExpired()
// Verify all codes were deleted
im.mu.Lock()
remaining := len(im.codes)
im.mu.Unlock()
if remaining != 0 {
t.Errorf("cleanup failed: %d codes remaining", remaining)
}
}
// TestBuildMemberListEvent tests membership list event creation
func TestBuildMemberListEvent(t *testing.T) {
// Generate a test relay secret
relaySecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate relay secret: %v", err)
}
// Create test member pubkeys
members := make([][]byte, 3)
for i := 0; i < 3; i++ {
members[i] = make([]byte, 32)
for j := range members[i] {
members[i][j] = byte(i*10 + j)
}
}
// Build event
ev, err := BuildMemberListEvent(relaySecret, members)
if err != nil {
t.Fatalf("failed to build member list event: %v", err)
}
// Verify event kind
if ev.Kind != KindMemberList {
t.Errorf("wrong event kind: got %d, want %d", ev.Kind, KindMemberList)
}
// Verify NIP-70 tag
minusTag := ev.Tags.GetFirst([]byte("-"))
if minusTag == nil {
t.Error("missing NIP-70 `-` tag")
}
// Verify member tags
memberTags := ev.Tags.GetAll([]byte("member"))
if len(memberTags) != 3 {
t.Errorf("wrong number of member tags: got %d, want 3", len(memberTags))
}
// Verify signature
valid, err := ev.Verify()
if err != nil {
t.Fatalf("signature verification error: %v", err)
}
if !valid {
t.Error("event signature is invalid")
}
}
// TestBuildAddUserEvent tests add user event creation
func TestBuildAddUserEvent(t *testing.T) {
relaySecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate relay secret: %v", err)
}
userPubkey := make([]byte, 32)
for i := range userPubkey {
userPubkey[i] = byte(i)
}
ev, err := BuildAddUserEvent(relaySecret, userPubkey)
if err != nil {
t.Fatalf("failed to build add user event: %v", err)
}
// Verify event kind
if ev.Kind != KindAddUser {
t.Errorf("wrong event kind: got %d, want %d", ev.Kind, KindAddUser)
}
// Verify NIP-70 tag
minusTag := ev.Tags.GetFirst([]byte("-"))
if minusTag == nil {
t.Error("missing NIP-70 `-` tag")
}
// Verify p tag
pTag := ev.Tags.GetFirst([]byte("p"))
if pTag == nil {
t.Error("missing p tag")
}
// Verify signature
valid, err := ev.Verify()
if err != nil {
t.Fatalf("signature verification error: %v", err)
}
if !valid {
t.Error("event signature is invalid")
}
}
// TestBuildRemoveUserEvent tests remove user event creation
func TestBuildRemoveUserEvent(t *testing.T) {
relaySecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate relay secret: %v", err)
}
userPubkey := make([]byte, 32)
for i := range userPubkey {
userPubkey[i] = byte(i)
}
ev, err := BuildRemoveUserEvent(relaySecret, userPubkey)
if err != nil {
t.Fatalf("failed to build remove user event: %v", err)
}
// Verify event kind
if ev.Kind != KindRemoveUser {
t.Errorf("wrong event kind: got %d, want %d", ev.Kind, KindRemoveUser)
}
// Verify NIP-70 tag
minusTag := ev.Tags.GetFirst([]byte("-"))
if minusTag == nil {
t.Error("missing NIP-70 `-` tag")
}
// Verify p tag
pTag := ev.Tags.GetFirst([]byte("p"))
if pTag == nil {
t.Error("missing p tag")
}
// Verify signature
valid, err := ev.Verify()
if err != nil {
t.Fatalf("signature verification error: %v", err)
}
if !valid {
t.Error("event signature is invalid")
}
}
// TestBuildInviteEvent tests invite event creation
func TestBuildInviteEvent(t *testing.T) {
relaySecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate relay secret: %v", err)
}
inviteCode := "test-invite-code-12345"
ev, err := BuildInviteEvent(relaySecret, inviteCode)
if err != nil {
t.Fatalf("failed to build invite event: %v", err)
}
// Verify event kind
if ev.Kind != KindInviteReq {
t.Errorf("wrong event kind: got %d, want %d", ev.Kind, KindInviteReq)
}
// Verify NIP-70 tag
minusTag := ev.Tags.GetFirst([]byte("-"))
if minusTag == nil {
t.Error("missing NIP-70 `-` tag")
}
// Verify claim tag
claimTag := ev.Tags.GetFirst([]byte("claim"))
if claimTag == nil {
t.Error("missing claim tag")
}
if claimTag.Len() < 2 {
t.Error("claim tag has no value")
}
if string(claimTag.T[1]) != inviteCode {
t.Errorf("wrong invite code in tag: got %s, want %s", string(claimTag.T[1]), inviteCode)
}
// Verify signature
valid, err := ev.Verify()
if err != nil {
t.Fatalf("signature verification error: %v", err)
}
if !valid {
t.Error("event signature is invalid")
}
}
// TestValidateJoinRequest tests join request validation
func TestValidateJoinRequest(t *testing.T) {
tests := []struct {
name string
setupEvent func() *event.E
expectValid bool
expectCode string
expectReason string
}{
{
name: "valid join request",
setupEvent: func() *event.E {
ev := event.New()
ev.Kind = KindJoinRequest
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.Tags.Append(tag.NewFromAny("claim", "test-code-123"))
ev.CreatedAt = time.Now().Unix()
return ev
},
expectValid: true,
expectCode: "test-code-123",
expectReason: "",
},
{
name: "wrong kind",
setupEvent: func() *event.E {
ev := event.New()
ev.Kind = 1000
return ev
},
expectValid: false,
expectReason: "invalid event kind",
},
{
name: "missing minus tag",
setupEvent: func() *event.E {
ev := event.New()
ev.Kind = KindJoinRequest
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("claim", "test-code"))
ev.CreatedAt = time.Now().Unix()
return ev
},
expectValid: false,
expectReason: "missing NIP-70 `-` tag",
},
{
name: "missing claim tag",
setupEvent: func() *event.E {
ev := event.New()
ev.Kind = KindJoinRequest
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.CreatedAt = time.Now().Unix()
return ev
},
expectValid: false,
expectReason: "missing claim tag",
},
{
name: "timestamp too old",
setupEvent: func() *event.E {
ev := event.New()
ev.Kind = KindJoinRequest
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.Tags.Append(tag.NewFromAny("claim", "test-code"))
ev.CreatedAt = time.Now().Unix() - 700 // More than 10 minutes ago
return ev
},
expectValid: false,
expectCode: "test-code",
expectReason: "timestamp out of range",
},
{
name: "timestamp too far in future",
setupEvent: func() *event.E {
ev := event.New()
ev.Kind = KindJoinRequest
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.Tags.Append(tag.NewFromAny("claim", "test-code"))
ev.CreatedAt = time.Now().Unix() + 700 // More than 10 minutes ahead
return ev
},
expectValid: false,
expectCode: "test-code",
expectReason: "timestamp out of range",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev := tt.setupEvent()
code, valid, reason := ValidateJoinRequest(ev)
if valid != tt.expectValid {
t.Errorf("valid mismatch: got %v, want %v", valid, tt.expectValid)
}
if tt.expectCode != "" && code != tt.expectCode {
t.Errorf("code mismatch: got %s, want %s", code, tt.expectCode)
}
if tt.expectReason != "" && reason != tt.expectReason {
t.Errorf("reason mismatch: got %s, want %s", reason, tt.expectReason)
}
})
}
}
// TestValidateLeaveRequest tests leave request validation
func TestValidateLeaveRequest(t *testing.T) {
tests := []struct {
name string
setupEvent func() *event.E
expectValid bool
expectReason string
}{
{
name: "valid leave request",
setupEvent: func() *event.E {
ev := event.New()
ev.Kind = KindLeaveRequest
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.CreatedAt = time.Now().Unix()
return ev
},
expectValid: true,
expectReason: "",
},
{
name: "wrong kind",
setupEvent: func() *event.E {
ev := event.New()
ev.Kind = 1000
return ev
},
expectValid: false,
expectReason: "invalid event kind",
},
{
name: "missing minus tag",
setupEvent: func() *event.E {
ev := event.New()
ev.Kind = KindLeaveRequest
ev.CreatedAt = time.Now().Unix()
return ev
},
expectValid: false,
expectReason: "missing NIP-70 `-` tag",
},
{
name: "timestamp out of range",
setupEvent: func() *event.E {
ev := event.New()
ev.Kind = KindLeaveRequest
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.CreatedAt = time.Now().Unix() - 700
return ev
},
expectValid: false,
expectReason: "timestamp out of range",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev := tt.setupEvent()
valid, reason := ValidateLeaveRequest(ev)
if valid != tt.expectValid {
t.Errorf("valid mismatch: got %v, want %v", valid, tt.expectValid)
}
if tt.expectReason != "" && reason != tt.expectReason {
t.Errorf("reason mismatch: got %s, want %s", reason, tt.expectReason)
}
})
}
}

View File

@@ -124,6 +124,8 @@ var (
NIP40 = ExpirationTimestamp
Authentication = NIP{"Authentication of clients to relays", 42}
NIP42 = Authentication
RelayAccessMetadata = NIP{"Relay Access Metadata and Requests", 43}
NIP43 = RelayAccessMetadata
VersionedEncryption = NIP{"Encrypted Payloads (Versioned)", 44}
NIP44 = VersionedEncryption
CountingResults = NIP{"Counting results", 45}

View File

@@ -1 +1 @@
v0.26.0
v0.27.0

View File

@@ -1,245 +0,0 @@
package main
import (
"fmt"
"net"
"os"
"path/filepath"
"testing"
"time"
lol "lol.mleku.dev"
"next.orly.dev/app/config"
"next.orly.dev/pkg/run"
relaytester "next.orly.dev/relay-tester"
)
var (
testRelayURL string
testName string
testJSON bool
keepDataDir bool
relayPort int
relayDataDir string
)
func TestRelay(t *testing.T) {
var err error
var relay *run.Relay
var relayURL string
// Determine relay URL
if testRelayURL != "" {
relayURL = testRelayURL
} else {
// Start local relay for testing
var port int
if relay, port, err = startTestRelay(); err != nil {
t.Fatalf("Failed to start test relay: %v", err)
}
defer func() {
if stopErr := relay.Stop(); stopErr != nil {
t.Logf("Error stopping relay: %v", stopErr)
}
}()
relayURL = fmt.Sprintf("ws://127.0.0.1:%d", port)
t.Logf("Waiting for relay to be ready at %s...", relayURL)
// Wait for relay to be ready - try connecting to verify it's up
if err = waitForRelay(relayURL, 10*time.Second); err != nil {
t.Fatalf("Relay not ready after timeout: %v", err)
}
t.Logf("Relay is ready at %s", relayURL)
}
// Create test suite
t.Logf("Creating test suite for %s...", relayURL)
suite, err := relaytester.NewTestSuite(relayURL)
if err != nil {
t.Fatalf("Failed to create test suite: %v", err)
}
t.Logf("Test suite created, running tests...")
// Run tests
var results []relaytester.TestResult
if testName != "" {
// Run specific test
result, err := suite.RunTest(testName)
if err != nil {
t.Fatalf("Failed to run test %s: %v", testName, err)
}
results = []relaytester.TestResult{result}
} else {
// Run all tests
if results, err = suite.Run(); err != nil {
t.Fatalf("Failed to run tests: %v", err)
}
}
// Output results
if testJSON {
jsonOutput, err := relaytester.FormatJSON(results)
if err != nil {
t.Fatalf("Failed to format JSON: %v", err)
}
fmt.Println(jsonOutput)
} else {
outputResults(results, t)
}
// Check if any required tests failed
for _, result := range results {
if result.Required && !result.Pass {
t.Errorf("Required test '%s' failed: %s", result.Name, result.Info)
}
}
}
func startTestRelay() (relay *run.Relay, port int, err error) {
cfg := &config.C{
AppName: "ORLY-TEST",
DataDir: relayDataDir,
Listen: "127.0.0.1",
Port: 0, // Always use random port, unless overridden via -port flag
HealthPort: 0,
EnableShutdown: false,
LogLevel: "warn",
DBLogLevel: "warn",
DBBlockCacheMB: 512,
DBIndexCacheMB: 256,
LogToStdout: false,
PprofHTTP: false,
ACLMode: "none",
AuthRequired: false,
AuthToWrite: false,
SubscriptionEnabled: false,
MonthlyPriceSats: 6000,
FollowListFrequency: time.Hour,
WebDisableEmbedded: false,
SprocketEnabled: false,
SpiderMode: "none",
PolicyEnabled: false,
}
// Use explicitly set port if provided via flag, otherwise find an available port
if relayPort > 0 {
cfg.Port = relayPort
} else {
var listener net.Listener
if listener, err = net.Listen("tcp", "127.0.0.1:0"); err != nil {
return nil, 0, fmt.Errorf("failed to find available port: %w", err)
}
addr := listener.Addr().(*net.TCPAddr)
cfg.Port = addr.Port
listener.Close()
}
// Set default data dir if not specified
if cfg.DataDir == "" {
tmpDir := filepath.Join(os.TempDir(), fmt.Sprintf("orly-test-%d", time.Now().UnixNano()))
cfg.DataDir = tmpDir
}
// Set up logging
lol.SetLogLevel(cfg.LogLevel)
// Create options
cleanup := !keepDataDir
opts := &run.Options{
CleanupDataDir: &cleanup,
}
// Start relay
if relay, err = run.Start(cfg, opts); err != nil {
return nil, 0, fmt.Errorf("failed to start relay: %w", err)
}
return relay, cfg.Port, nil
}
// waitForRelay waits for the relay to be ready by attempting to connect
func waitForRelay(url string, timeout time.Duration) error {
// Extract host:port from ws:// URL
addr := url
if len(url) > 7 && url[:5] == "ws://" {
addr = url[5:]
}
deadline := time.Now().Add(timeout)
attempts := 0
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
if err == nil {
conn.Close()
return nil
}
attempts++
if attempts%10 == 0 {
// Log every 10th attempt (every second)
}
time.Sleep(100 * time.Millisecond)
}
return fmt.Errorf("timeout waiting for relay at %s after %d attempts", url, attempts)
}
func outputResults(results []relaytester.TestResult, t *testing.T) {
passed := 0
failed := 0
requiredFailed := 0
for _, result := range results {
if result.Pass {
passed++
t.Logf("PASS: %s", result.Name)
} else {
failed++
if result.Required {
requiredFailed++
t.Errorf("FAIL (required): %s - %s", result.Name, result.Info)
} else {
t.Logf("FAIL (optional): %s - %s", result.Name, result.Info)
}
}
}
t.Logf("\nTest Summary:")
t.Logf(" Total: %d", len(results))
t.Logf(" Passed: %d", passed)
t.Logf(" Failed: %d", failed)
t.Logf(" Required Failed: %d", requiredFailed)
}
// TestMain allows custom test setup/teardown
func TestMain(m *testing.M) {
// Manually parse our custom flags to avoid conflicts with Go's test flags
for i := 1; i < len(os.Args); i++ {
arg := os.Args[i]
switch arg {
case "-relay-url":
if i+1 < len(os.Args) {
testRelayURL = os.Args[i+1]
i++
}
case "-test-name":
if i+1 < len(os.Args) {
testName = os.Args[i+1]
i++
}
case "-json":
testJSON = true
case "-keep-data":
keepDataDir = true
case "-port":
if i+1 < len(os.Args) {
fmt.Sscanf(os.Args[i+1], "%d", &relayPort)
i++
}
case "-data-dir":
if i+1 < len(os.Args) {
relayDataDir = os.Args[i+1]
i++
}
}
}
code := m.Run()
os.Exit(code)
}

View File

@@ -0,0 +1,166 @@
#!/bin/bash
# Test script for verifying subscription stability fixes
set -e
RELAY_URL="${RELAY_URL:-ws://localhost:3334}"
TEST_DURATION="${TEST_DURATION:-60}" # seconds
EVENT_INTERVAL="${EVENT_INTERVAL:-2}" # seconds between events
echo "==================================="
echo "Subscription Stability Test"
echo "==================================="
echo "Relay URL: $RELAY_URL"
echo "Test duration: ${TEST_DURATION}s"
echo "Event interval: ${EVENT_INTERVAL}s"
echo ""
# Check if websocat is installed
if ! command -v websocat &> /dev/null; then
echo "ERROR: websocat is not installed"
echo "Install with: cargo install websocat"
exit 1
fi
# Check if jq is installed
if ! command -v jq &> /dev/null; then
echo "ERROR: jq is not installed"
echo "Install with: sudo apt install jq"
exit 1
fi
# Temporary files for communication
FIFO_IN=$(mktemp -u)
FIFO_OUT=$(mktemp -u)
mkfifo "$FIFO_IN"
mkfifo "$FIFO_OUT"
# Cleanup on exit
cleanup() {
echo ""
echo "Cleaning up..."
rm -f "$FIFO_IN" "$FIFO_OUT"
kill $WS_PID 2>/dev/null || true
kill $READER_PID 2>/dev/null || true
kill $PUBLISHER_PID 2>/dev/null || true
}
trap cleanup EXIT INT TERM
echo "Step 1: Connecting to relay..."
# Start WebSocket connection
websocat "$RELAY_URL" < "$FIFO_IN" > "$FIFO_OUT" &
WS_PID=$!
# Wait for connection
sleep 1
if ! kill -0 $WS_PID 2>/dev/null; then
echo "ERROR: Failed to connect to relay at $RELAY_URL"
exit 1
fi
echo "✓ Connected to relay"
echo ""
echo "Step 2: Creating subscription..."
# Send REQ message
SUB_ID="stability-test-$(date +%s)"
REQ_MSG='["REQ","'$SUB_ID'",{"kinds":[1]}]'
echo "$REQ_MSG" > "$FIFO_IN"
echo "✓ Sent REQ for subscription: $SUB_ID"
echo ""
# Variables for tracking
RECEIVED_COUNT=0
PUBLISHED_COUNT=0
EOSE_RECEIVED=0
echo "Step 3: Waiting for EOSE..."
# Read messages and count events
(
while IFS= read -r line; do
echo "[RECV] $line"
# Check for EOSE
if echo "$line" | jq -e '. | select(.[0] == "EOSE" and .[1] == "'$SUB_ID'")' > /dev/null 2>&1; then
EOSE_RECEIVED=1
echo "✓ Received EOSE"
break
fi
done < "$FIFO_OUT"
) &
READER_PID=$!
# Wait up to 10 seconds for EOSE
for i in {1..10}; do
if [ $EOSE_RECEIVED -eq 1 ]; then
break
fi
sleep 1
done
echo ""
echo "Step 4: Starting long-running test..."
echo "Publishing events every ${EVENT_INTERVAL}s for ${TEST_DURATION}s..."
echo ""
# Start event counter
(
while IFS= read -r line; do
# Count EVENT messages for our subscription
if echo "$line" | jq -e '. | select(.[0] == "EVENT" and .[1] == "'$SUB_ID'")' > /dev/null 2>&1; then
RECEIVED_COUNT=$((RECEIVED_COUNT + 1))
EVENT_ID=$(echo "$line" | jq -r '.[2].id' 2>/dev/null || echo "unknown")
echo "[$(date +%H:%M:%S)] EVENT received #$RECEIVED_COUNT (id: ${EVENT_ID:0:8}...)"
fi
done < "$FIFO_OUT"
) &
READER_PID=$!
# Publish events
START_TIME=$(date +%s)
END_TIME=$((START_TIME + TEST_DURATION))
while [ $(date +%s) -lt $END_TIME ]; do
PUBLISHED_COUNT=$((PUBLISHED_COUNT + 1))
# Create and publish event (you'll need to implement this part)
# This is a placeholder - replace with actual event publishing
EVENT_JSON='["EVENT",{"kind":1,"content":"Test event '$PUBLISHED_COUNT' for stability test","created_at":'$(date +%s)',"tags":[],"pubkey":"0000000000000000000000000000000000000000000000000000000000000000","id":"0000000000000000000000000000000000000000000000000000000000000000","sig":"0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"}]'
echo "[$(date +%H:%M:%S)] Publishing event #$PUBLISHED_COUNT"
# Sleep before next event
sleep "$EVENT_INTERVAL"
done
echo ""
echo "==================================="
echo "Test Complete"
echo "==================================="
echo "Duration: ${TEST_DURATION}s"
echo "Events published: $PUBLISHED_COUNT"
echo "Events received: $RECEIVED_COUNT"
echo ""
# Calculate success rate
if [ $PUBLISHED_COUNT -gt 0 ]; then
SUCCESS_RATE=$((RECEIVED_COUNT * 100 / PUBLISHED_COUNT))
echo "Success rate: ${SUCCESS_RATE}%"
echo ""
if [ $SUCCESS_RATE -ge 90 ]; then
echo "✓ TEST PASSED - Subscription remained stable"
exit 0
else
echo "✗ TEST FAILED - Subscription dropped events"
exit 1
fi
else
echo "✗ TEST FAILED - No events published"
exit 1
fi

41
scripts/test-subscriptions.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/bin/bash
# Simple subscription stability test script
set -e
RELAY_URL="${RELAY_URL:-ws://localhost:3334}"
DURATION="${DURATION:-60}"
KIND="${KIND:-1}"
echo "==================================="
echo "Subscription Stability Test"
echo "==================================="
echo ""
echo "This tool tests whether subscriptions remain stable over time."
echo ""
echo "Configuration:"
echo " Relay URL: $RELAY_URL"
echo " Duration: ${DURATION}s"
echo " Event kind: $KIND"
echo ""
echo "To test properly, you should:"
echo " 1. Start this test"
echo " 2. In another terminal, publish events to the relay"
echo " 3. Verify events are received throughout the test duration"
echo ""
# Check if the test tool is built
if [ ! -f "./subscription-test" ]; then
echo "Building subscription-test tool..."
go build -o subscription-test ./cmd/subscription-test
echo "✓ Built"
echo ""
fi
# Run the test
echo "Starting test..."
echo ""
./subscription-test -url "$RELAY_URL" -duration "$DURATION" -kind "$KIND" -v
exit $?

View File

@@ -1,167 +0,0 @@
#!/usr/bin/env node
// Test script to verify websocket connections are not closed prematurely
// This is a Node.js test script that can be run with: node test-relay-connection.js
import { NostrWebSocket } from '@nostr-dev-kit/ndk';
const RELAY = process.env.RELAY || 'ws://localhost:8080';
const MAX_CONNECTIONS = 10;
const TEST_DURATION = 30000; // 30 seconds
let connectionsClosed = 0;
let connectionsOpened = 0;
let messagesReceived = 0;
let errors = 0;
const stats = {
premature: 0,
normal: 0,
errors: 0,
};
class TestConnection {
constructor(id) {
this.id = id;
this.ws = null;
this.closed = false;
this.openTime = null;
this.closeTime = null;
this.lastError = null;
}
connect() {
return new Promise((resolve, reject) => {
this.ws = new NostrWebSocket(RELAY);
this.ws.addEventListener('open', () => {
this.openTime = Date.now();
connectionsOpened++;
console.log(`[Connection ${this.id}] Opened`);
resolve();
});
this.ws.addEventListener('close', (event) => {
this.closeTime = Date.now();
this.closed = true;
connectionsClosed++;
const duration = this.closeTime - this.openTime;
console.log(`[Connection ${this.id}] Closed: code=${event.code}, reason="${event.reason || ''}", duration=${duration}ms`);
if (duration < 5000 && event.code !== 1000) {
stats.premature++;
console.log(`[Connection ${this.id}] PREMATURE CLOSE DETECTED: duration=${duration}ms < 5s`);
} else {
stats.normal++;
}
});
this.ws.addEventListener('error', (error) => {
this.lastError = error;
stats.errors++;
console.error(`[Connection ${this.id}] Error:`, error);
});
this.ws.addEventListener('message', (event) => {
messagesReceived++;
try {
const data = JSON.parse(event.data);
console.log(`[Connection ${this.id}] Message:`, data[0]);
} catch (e) {
console.log(`[Connection ${this.id}] Message (non-JSON):`, event.data);
}
});
setTimeout(reject, 5000); // Timeout after 5 seconds if not opened
});
}
sendReq() {
if (this.ws && !this.closed) {
this.ws.send(JSON.stringify(['REQ', `test-sub-${this.id}`, { kinds: [1], limit: 10 }]));
console.log(`[Connection ${this.id}] Sent REQ`);
}
}
close() {
if (this.ws && !this.closed) {
this.ws.close();
}
}
}
async function runTest() {
console.log('='.repeat(60));
console.log('Testing Relay Connection Stability');
console.log('='.repeat(60));
console.log(`Relay: ${RELAY}`);
console.log(`Duration: ${TEST_DURATION}ms`);
console.log(`Connections: ${MAX_CONNECTIONS}`);
console.log('='.repeat(60));
console.log();
const connections = [];
// Open connections
console.log('Opening connections...');
for (let i = 0; i < MAX_CONNECTIONS; i++) {
const conn = new TestConnection(i);
try {
await conn.connect();
connections.push(conn);
} catch (error) {
console.error(`Failed to open connection ${i}:`, error);
}
}
console.log(`Opened ${connections.length} connections`);
console.log();
// Send requests from each connection
console.log('Sending REQ messages...');
for (const conn of connections) {
conn.sendReq();
}
// Wait and let connections run
console.log(`Waiting ${TEST_DURATION / 1000}s...`);
await new Promise(resolve => setTimeout(resolve, TEST_DURATION));
// Close all connections
console.log('Closing all connections...');
for (const conn of connections) {
conn.close();
}
// Wait for close events
await new Promise(resolve => setTimeout(resolve, 1000));
// Print results
console.log();
console.log('='.repeat(60));
console.log('Test Results:');
console.log('='.repeat(60));
console.log(`Connections Opened: ${connectionsOpened}`);
console.log(`Connections Closed: ${connectionsClosed}`);
console.log(`Messages Received: ${messagesReceived}`);
console.log();
console.log('Closure Analysis:');
console.log(`- Premature Closes: ${stats.premature}`);
console.log(`- Normal Closes: ${stats.normal}`);
console.log(`- Errors: ${stats.errors}`);
console.log('='.repeat(60));
if (stats.premature > 0) {
console.error('FAILED: Detected premature connection closures!');
process.exit(1);
} else {
console.log('PASSED: No premature connection closures detected.');
process.exit(0);
}
}
runTest().catch(error => {
console.error('Test failed:', error);
process.exit(1);
});

View File

@@ -1,57 +0,0 @@
import { NostrWebSocket } from '@nostr-dev-kit/ndk';
const RELAY = process.env.RELAY || 'ws://localhost:8080';
async function testConnectionClosure() {
console.log('Testing websocket connection closure issues...');
console.log('Connecting to:', RELAY);
// Create multiple connections to test concurrency
const connections = [];
const results = { connected: 0, closed: 0, errors: 0 };
for (let i = 0; i < 5; i++) {
const ws = new NostrWebSocket(RELAY);
ws.addEventListener('open', () => {
console.log(`Connection ${i} opened`);
results.connected++;
});
ws.addEventListener('close', (event) => {
console.log(`Connection ${i} closed:`, event.code, event.reason);
results.closed++;
});
ws.addEventListener('error', (error) => {
console.error(`Connection ${i} error:`, error);
results.errors++;
});
connections.push(ws);
}
// Wait a bit then send REQs
await new Promise(resolve => setTimeout(resolve, 1000));
// Send some REQ messages
for (const ws of connections) {
ws.send(JSON.stringify(['REQ', 'test-sub', { kinds: [1] }]));
}
// Wait and observe behavior
await new Promise(resolve => setTimeout(resolve, 5000));
console.log('\nTest Results:');
console.log(`- Connected: ${results.connected}`);
console.log(`- Closed prematurely: ${results.closed}`);
console.log(`- Errors: ${results.errors}`);
// Close all connections
for (const ws of connections) {
ws.close();
}
}
testConnectionClosure().catch(console.error);

View File

@@ -1,156 +0,0 @@
package main
import (
"fmt"
"time"
"next.orly.dev/app/config"
"next.orly.dev/pkg/run"
)
// func TestDumbClientWorkaround(t *testing.T) {
// var relay *run.Relay
// var err error
// // Start local relay for testing
// if relay, _, err = startWorkaroundTestRelay(); err != nil {
// t.Fatalf("Failed to start test relay: %v", err)
// }
// defer func() {
// if stopErr := relay.Stop(); stopErr != nil {
// t.Logf("Error stopping relay: %v", stopErr)
// }
// }()
// relayURL := "ws://127.0.0.1:3338"
// // Wait for relay to be ready
// if err = waitForRelay(relayURL, 10*time.Second); err != nil {
// t.Fatalf("Relay not ready after timeout: %v", err)
// }
// t.Logf("Relay is ready at %s", relayURL)
// // Test connection with a "dumb" client that doesn't handle ping/pong properly
// dialer := websocket.Dialer{
// HandshakeTimeout: 10 * time.Second,
// }
// conn, _, err := dialer.Dial(relayURL, nil)
// if err != nil {
// t.Fatalf("Failed to connect: %v", err)
// }
// defer conn.Close()
// t.Logf("Connection established")
// // Simulate a dumb client that sets a short read deadline and doesn't handle ping/pong
// conn.SetReadDeadline(time.Now().Add(30 * time.Second))
// startTime := time.Now()
// messageCount := 0
// // The connection should stay alive despite the short client-side deadline
// // because our workaround sets a 24-hour server-side deadline
// connectionFailed := false
// for time.Since(startTime) < 2*time.Minute && !connectionFailed {
// // Extend client deadline every 10 seconds (simulating dumb client behavior)
// if time.Since(startTime).Seconds() > 10 && int(time.Since(startTime).Seconds())%10 == 0 {
// conn.SetReadDeadline(time.Now().Add(30 * time.Second))
// t.Logf("Dumb client extended its own deadline")
// }
// // Try to read with a short timeout to avoid blocking
// conn.SetReadDeadline(time.Now().Add(1 * time.Second))
// // Use a function to catch panics from ReadMessage on failed connections
// func() {
// defer func() {
// if r := recover(); r != nil {
// if panicMsg, ok := r.(string); ok && panicMsg == "repeated read on failed websocket connection" {
// t.Logf("Connection failed, stopping read loop")
// connectionFailed = true
// return
// }
// // Re-panic if it's a different panic
// panic(r)
// }
// }()
// msgType, data, err := conn.ReadMessage()
// conn.SetReadDeadline(time.Now().Add(30 * time.Second)) // Reset
// if err != nil {
// if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
// // Timeout is expected - just continue
// time.Sleep(100 * time.Millisecond)
// return
// }
// if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
// t.Logf("Connection closed normally: %v", err)
// connectionFailed = true
// return
// }
// t.Errorf("Unexpected error: %v", err)
// connectionFailed = true
// return
// }
// messageCount++
// t.Logf("Received message %d: type=%d, len=%d", messageCount, msgType, len(data))
// }()
// }
// elapsed := time.Since(startTime)
// if elapsed < 90*time.Second {
// t.Errorf("Connection died too early after %v (expected at least 90s)", elapsed)
// } else {
// t.Logf("Workaround successful: connection lasted %v with %d messages", elapsed, messageCount)
// }
// }
// startWorkaroundTestRelay starts a relay for workaround testing
func startWorkaroundTestRelay() (relay *run.Relay, port int, err error) {
cfg := &config.C{
AppName: "ORLY-WORKAROUND-TEST",
DataDir: "",
Listen: "127.0.0.1",
Port: 3338,
HealthPort: 0,
EnableShutdown: false,
LogLevel: "info",
DBLogLevel: "warn",
DBBlockCacheMB: 512,
DBIndexCacheMB: 256,
LogToStdout: false,
PprofHTTP: false,
ACLMode: "none",
AuthRequired: false,
AuthToWrite: false,
SubscriptionEnabled: false,
MonthlyPriceSats: 6000,
FollowListFrequency: time.Hour,
WebDisableEmbedded: false,
SprocketEnabled: false,
SpiderMode: "none",
PolicyEnabled: false,
}
// Set default data dir if not specified
if cfg.DataDir == "" {
cfg.DataDir = fmt.Sprintf("/tmp/orly-workaround-test-%d", time.Now().UnixNano())
}
// Create options
cleanup := true
opts := &run.Options{
CleanupDataDir: &cleanup,
}
// Start relay
if relay, err = run.Start(cfg, opts); err != nil {
return nil, 0, fmt.Errorf("failed to start relay: %w", err)
}
return relay, cfg.Port, nil
}