web-app/NOSTR_ARCHITECTURE.md
padreug a4584ed9bd feat: Add NOSTR architecture documentation
- Introduce a comprehensive documentation file detailing the singleton-based architecture of the Ario web app.
- Outline the core singletons for authentication, relay management, chat, market, and Nostr store, along with their integration into components.
- Provide insights into the benefits of the architecture, including resource management, event handling, and performance optimizations.
- Include guidelines for configuration, error handling, and future extensibility to support ongoing development.
2025-08-13 15:18:54 +02:00

22 KiB

Ario Web App Architecture

Overview

The Ario web app uses a singleton-based architecture to manage shared state and resources across components. This document explains how the core singletons work and how different components (chat, market, feed) plug into the system.

Core Singleton Architecture

1. Authentication Singleton (useAuth)

Location: src/composables/useAuth.ts

Purpose: Manages user authentication state using LNBits integration.

Singleton Pattern:

export function useAuth() {
  // ... implementation
}

// Export singleton instance for global state
export const auth = useAuth()

Usage: Imported throughout the app for authentication state:

import { auth } from '@/composables/useAuth'

// All components see the same authentication state
console.log(auth.isAuthenticated.value)

2. Relay Hub Singleton (relayHub)

Location: src/lib/nostr/relayHub.ts

Purpose: Centralized management of Nostr relay connections, providing a single WebSocket connection pool for the entire application.

Singleton Pattern:

export class RelayHub extends EventEmitter {
  // ... implementation
}

// Export singleton instance
export const relayHub = new RelayHub()

Key Features:

  • Single WebSocket connection pool
  • Automatic reconnection and health checks
  • Event broadcasting for connection state changes
  • Mobile visibility handling for WebSocket management

3. Relay Hub Composable Singleton (useRelayHub)

Location: src/composables/useRelayHub.ts

Purpose: Vue-reactive wrapper around the core relayHub instance, providing reactive state and Vue-specific functionality.

Singleton Pattern:

export function useRelayHub() {
  // Uses the same relayHub instance
  const initialize = async () => {
    await relayHub.initialize(relayUrls)
  }
  
  return { initialize, connect, disconnect, ... }
}

// Export singleton instance
export const relayHubComposable = useRelayHub()

Relationship: This composable wraps the core relayHub instance, making it Vue-reactive while maintaining the singleton connection pool.

4. Chat Singleton (useNostrChat)

Location: src/composables/useNostrChat.ts

Purpose: Manages Nostr chat functionality including message encryption/decryption, peer management, and real-time messaging.

Singleton Pattern:

export function useNostrChat() {
  // ... implementation
}

// Export singleton instance for global state
export const nostrChat = useNostrChat()

Relay Hub Integration: Uses useRelayHub() to connect to relays and manage subscriptions.

5. Market Singleton (useMarket)

Location: src/composables/useMarket.ts

Purpose: Manages market functionality including stalls, products, and market data.

Singleton Pattern:

export function useMarket() {
  // ... implementation
}

// Export singleton instance
export const market = useMarket()

Relay Hub Integration: Uses useRelayHub() to fetch market data from Nostr relays.

6. Nostr Store Singleton (useNostrStore)

Location: src/stores/nostr.ts

Purpose: Pinia store for Nostr-related state including push notifications and relay connection status.

Singleton Pattern:

export const useNostrStore = defineStore('nostr', () => {
  // ... implementation
})

Relay Hub Integration: Imports the relayHub singleton directly for connection management.

Component Integration Architecture

How Components Plug Into the System

┌─────────────────────────────────────────────────────────────┐
│                    Component Layer                          │
├─────────────────────────────────────────────────────────────┤
│  ChatComponent  │  NostrFeed  │  Market.vue  │  Navbar.vue │
│       ↓         │      ↓      │      ↓       │      ↓      │
├─────────────────────────────────────────────────────────────┤
│                 Composable Layer                            │
├─────────────────────────────────────────────────────────────┤
│  useNostrChat   │  useRelayHub │  useMarket  │  useAuth    │
│       ↓         │      ↓       │      ↓      │      ↓      │
├─────────────────────────────────────────────────────────────┤
│                 Singleton Instance Layer                     │
├─────────────────────────────────────────────────────────────┤
│  nostrChat      │  relayHub    │  market      │  auth       │
│       ↓         │      ↓       │      ↓      │      ↓      │
├─────────────────────────────────────────────────────────────┤
│                 Core Implementation Layer                    │
├─────────────────────────────────────────────────────────────┤
│  RelayHub       │  LNBits API  │  Market API │  Chat API   │
└─────────────────────────────────────────────────────────────┘

1. Chat Component Integration

Component: src/components/nostr/ChatComponent.vue

Integration Path:

// 1. Component imports the chat singleton
import { nostrChat } from '@/composables/useNostrChat'

// 2. Chat singleton uses relay hub composable
import { useRelayHub } from './useRelayHub'

// 3. Relay hub composable uses core relay hub singleton
import { relayHub } from '../lib/nostr/relayHub'

Data Flow:

ChatComponent → nostrChat → useRelayHub → relayHub → Nostr Relays
     ↓              ↓           ↓           ↓
  UI Updates   Chat State   Vue State   WebSocket

Key Benefits:

  • Single chat state across all components
  • Shared relay connections
  • Centralized message encryption/decryption
  • Unified peer management

2. Feed Component Integration

Component: src/components/nostr/NostrFeed.vue

Integration Path:

// 1. Component imports relay hub composable
import { useRelayHub } from '@/composables/useRelayHub'

// 2. Relay hub composable uses core relay hub singleton
import { relayHub } from '../lib/nostr/relayHub'

Data Flow:

NostrFeed → useRelayHub → relayHub → Nostr Relays
     ↓           ↓           ↓
  Feed UI   Vue State   WebSocket

Key Benefits:

  • Real-time feed updates
  • Shared relay connections
  • Centralized admin filtering
  • Efficient event subscription management

3. Market Component Integration

Component: src/pages/Market.vue

Integration Path:

// 1. Component uses market composable
import { useMarket } from '@/composables/useMarket'

// 2. Market composable uses relay hub composable
import { useRelayHub } from './useRelayHub'

// 3. Relay hub composable uses core relay hub singleton
import { relayHub } from '../lib/nostr/relayHub'

Data Flow:

Market.vue → useMarket → useRelayHub → relayHub → Nostr Relays
     ↓          ↓           ↓           ↓
  Market UI  Market    Vue State   WebSocket
            State

Key Benefits:

  • Centralized market data
  • Shared relay connections
  • Efficient product/stall fetching
  • Real-time market updates

Singleton Benefits in Practice

1. Shared Connection State

// Component A
const { isConnected } = useRelayHub()
console.log(isConnected.value) // true

// Component B (different part of app)
const { isConnected } = useRelayHub()
console.log(isConnected.value) // true (same state!)

2. Centralized Resource Management

// All components share the same WebSocket connections
relayHub.connectedRelayCount // Same value everywhere
relayHub.totalSubscriptionCount // Same value everywhere

3. Event Broadcasting

// Component A listens to connection events
relayHub.on('connected', () => console.log('Connected!'))

// Component B triggers connection
await relayHub.connect() // Component A gets notified!

4. Efficient Resource Usage

  • Before: Each component creates its own relay connections
  • After: Single connection pool shared across all components
  • Result: Reduced memory usage, better performance, consistent state

Configuration and Environment Variables

Required Environment Variables

# Nostr relay configuration
VITE_NOSTR_RELAYS=["wss://relay1.example.com", "wss://relay2.example.com"]

# Admin pubkeys for feed filtering
VITE_ADMIN_PUBKEYS=["npub1admin1...", "npub1admin2..."]

# LNBits API configuration
VITE_LNBITS_BASE_URL=https://your-lnbits-instance.com
VITE_API_KEY=your-api-key

Configuration Structure

// src/lib/config/index.ts
export const config: AppConfig = {
  nostr: {
    relays: parseJsonEnv(import.meta.env.VITE_NOSTR_RELAYS, []),
    adminPubkeys: parseJsonEnv(import.meta.env.VITE_ADMIN_PUBKEYS, [])
  },
  api: {
    baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || '',
    key: import.meta.env.VITE_API_KEY || ''
  }
}

Testing and Development

Mocking Singletons for Testing

// In test files, you can mock the singleton instances
import { auth } from '@/composables/useAuth'
import { nostrChat } from '@/composables/useNostrChat'

// Mock the singletons
vi.mock('@/composables/useAuth', () => ({
  auth: {
    isAuthenticated: ref(false),
    currentUser: ref(null)
  }
}))

Development Benefits

  • Hot Reload: Singleton state persists across component reloads
  • Debugging: Single point of inspection for shared state
  • Performance: No unnecessary re-initialization of expensive resources

Best Practices

1. Always Use the Singleton Instance

// ✅ Good: Use the exported singleton
import { auth } from '@/composables/useAuth'
import { nostrChat } from '@/composables/useNostrChat'

// ❌ Bad: Don't create new instances
const { currentUser } = useAuth() // This creates a new instance!

2. Access State Through Composables

// ✅ Good: Use the composable wrapper for reactive state
const { isConnected, connectionStatus } = useRelayHub()

// ❌ Bad: Don't access relayHub directly in components
import { relayHub } from '@/lib/nostr/relayHub'
console.log(relayHub.isConnected) // Not reactive!

3. Listen to Events for State Changes

// ✅ Good: Listen to relay hub events
relayHub.on('connected', () => {
  console.log('Relay connected!')
})

// ❌ Bad: Don't poll for state changes
setInterval(() => {
  console.log(relayHub.isConnected) // Inefficient!
}, 1000)

Conclusion

The singleton architecture in Ario provides:

  1. Efficient Resource Management: Single connection pools and shared state
  2. Consistent User Experience: All components see the same data
  3. Simplified Development: Clear patterns for state management
  4. Better Performance: No duplicate connections or state
  5. Easier Testing: Centralized mocking of shared resources

This architecture makes the app more maintainable, performant, and user-friendly while providing a solid foundation for future features.

Shared Nostr Function Execution

Event Building and Publishing Pattern

All components that need to create and publish Nostr events follow a consistent pattern through the relay hub singleton:

1. Event Creation Utilities

Direct Event Template Creation (used in useNostrChat):

import { nip04, finalizeEvent, type EventTemplate } from 'nostr-tools'

// Create event template
const eventTemplate: EventTemplate = {
  kind: 4, // Encrypted DM
  created_at: Math.floor(Date.now() / 1000),
  tags: [['p', peerPubkey]]
  content: encryptedContent
}

// Finalize and sign the event
const event = finalizeEvent(eventTemplate, hexToBytes(privateKey))

Market Event Creation (used in useMarket):

// Market events use specific kinds and tags
const MARKET_EVENT_KINDS = {
  STALL: 30017,
  PRODUCT: 30018,
  MARKET: 30019
}

// Extract data from event tags
const stallId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]

2. Centralized Publishing Through Relay Hub

All components publish through the same singleton:

// In useNostrChat.ts
await relayHub.publishEvent(event)

// In useMarket.ts (if publishing)
await relayHub.publishEvent(event)

// In any other component
await relayHub.publishEvent(event)

Relay Hub Publishing Logic:

async publishEvent(event: Event): Promise<{ success: number; total: number }> {
  if (!this._isConnected) {
    throw new Error('Not connected to any relays')
  }

  const relayUrls = Array.from(this.connectedRelays.keys())
  const results = await Promise.allSettled(
    relayUrls.map(relay => this.pool.publish([relay], event))
  )

  const successful = results.filter(result => result.status === 'fulfilled').length
  const total = results.length

  this.emit('eventPublished', { eventId: event.id, success: successful, total })
  return { success: successful, total }
}

3. Event Querying and Subscription

Consistent Query Interface:

// One-time event queries
const events = await relayHub.queryEvents(filters)

// Real-time subscriptions
const unsubscribe = relayHub.subscribe({
  id: 'subscription-id',
  filters: [{ kinds: [1], limit: 50 }],
  onEvent: (event) => {
    // Handle incoming event
  }
})

Shared Dependencies

nostr-tools Package:

  • finalizeEvent - Event signing and finalization
  • nip04 - Encrypted message handling
  • SimplePool - Relay connection management
  • EventTemplate - Event structure typing

Common Event Patterns:

  • Text Notes (kind 1): Community posts and announcements
  • Encrypted DMs (kind 4): Private chat messages
  • Market Events (kinds 30017-30019): Stall, product, and market data
  • Reactions (kind 7): Emoji reactions to posts

Configuration Management

Environment Variables and Configuration

Centralized Config (src/lib/config/index.ts):

interface NostrConfig {
  relays: string[]
  adminPubkeys: string[]
}

interface MarketConfig {
  defaultNaddr: string
  supportedRelays: string[]
  lightningEnabled: boolean
  defaultCurrency: string
}

// Environment variables
VITE_NOSTR_RELAYS: JSON array of relay URLs
VITE_ADMIN_PUBKEYS: JSON array of admin public keys
VITE_MARKET_RELAYS: JSON array of market-specific relays
VITE_LIGHTNING_ENABLED: Boolean for Lightning integration

Default Relay Configuration:

// Fallback relay configuration
supportedRelays: [
  'ws://127.0.0.1:7777',
  'wss://relay.damus.io',
  'wss://relay.snort.social',
  'wss://nostr-pub.wellorder.net',
  'wss://nostr.zebedee.cloud',
  'wss://nostr.walletofsatoshi.com'
]

Configuration Utilities

Admin Pubkey Validation:

export const configUtils = {
  isAdminPubkey(pubkey: string): boolean {
    return config.nostr.adminPubkeys.includes(pubkey)
  }
}

Error Handling and Resilience

Connection Management

Automatic Reconnection:

// Relay hub automatically handles reconnection
private scheduleReconnect(): void {
  if (this.reconnectInterval) {
    clearTimeout(this.reconnectInterval)
  }

  this.reconnectInterval = setTimeout(async () => {
    await this.connect()
  }, this.reconnectDelay)
}

Health Monitoring:

// Continuous health checks
private startHealthCheck(): void {
  this.healthCheckInterval = setInterval(() => {
    this.performHealthCheck()
  }, this.healthCheckIntervalMs)
}

Error Handling Patterns

Graceful Degradation:

// Components handle connection failures gracefully
async function loadNotes() {
  try {
    if (!relayHub.isConnected.value) {
      await relayHub.connect()
    }
    // ... load notes
  } catch (err) {
    error.value = err instanceof Error ? err : new Error('Failed to load notes')
    console.error('Failed to load notes:', err)
  }
}

Retry Logic:

// Market app retry pattern
async def wait_for_nostr_events(nostr_client: NostrClient):
    while True:
        try:
            await subscribe_to_all_merchants()
            # ... process events
        except Exception as e:
            logger.warning(f"Subscription failed. Will retry in one minute: {e}")
            await asyncio.sleep(10)

Event Processing and Filtering

Filter Management

Dynamic Filter Creation:

// Feed component creates filters based on type
const filters: any[] = [{
  kinds: [1], // TEXT_NOTE
  limit: 50
}]

// Filter by authors for announcements
if (props.feedType === 'announcements' && hasAdminPubkeys.value) {
  filters[0].authors = adminPubkeys
}

Market Event Filtering:

// Market-specific filters
const _filters_for_stall_events = (public_keys: List[str], since: int) -> List:
    stall_filter = {"kinds": [30017], "authors": public_keys}
    if since and since != 0:
        stall_filter["since"] = since
    return [stall_filter]

Event Processing Pipeline

Event Processing Flow:

// 1. Receive event from relay
onEvent: (event) => {
  // 2. Process and transform
  const newNote = {
    id: event.id,
    pubkey: event.pubkey,
    content: event.content,
    created_at: event.created_at,
    tags: event.tags || [],
    mentions: event.tags?.filter(tag => tag[0] === 'p').map(tag => tag[1]) || [],
    isReply: event.tags?.some(tag => tag[0] === 'e' && tag[3] === 'reply'),
    replyTo: event.tags?.find(tag => tag[0] === 'e' && tag[3] === 'reply')?.[1]
  }

  // 3. Apply business logic filters
  let shouldInclude = true
  if (props.feedType === 'announcements' && !isAdminPost(event.pubkey)) {
    shouldInclude = false
  }

  // 4. Add to state if approved
  if (shouldInclude) {
    notes.value.unshift(newNote)
    // 5. Limit array size for memory management
    if (notes.value.length > 100) {
      notes.value = notes.value.slice(0, 100)
    }
  }
}

Performance Optimizations

Memory Management

Array Size Limits:

// Prevent memory issues with large feeds
if (notes.value.length > 100) {
  notes.value = notes.value.slice(0, 100)
}

Subscription Cleanup:

// Proper cleanup prevents memory leaks
onUnmounted(() => {
  if (unsubscribe) {
    unsubscribe()
    unsubscribe = null
  }
})

Connection Pooling

Shared WebSocket Connections:

// Single pool shared across all components
private pool: SimplePool

// Components don't create individual connections
// They all use the centralized relay hub

Efficient Relay Usage:

// Query multiple relays simultaneously
const events = await this.pool.querySync(availableRelays, filter)

// Publish to all relays in parallel
const results = await Promise.allSettled(
  relayUrls.map(relay => this.pool.publish([relay], event))
)

Security Considerations

Encryption and Privacy

End-to-End Encryption:

// All chat messages are encrypted using nip04
const encryptedContent = await nip04.encrypt(
  privateKey,
  publicKey,
  content
)

Private Key Management:

// Keys never leave the browser
// Validation of key format and length
if (privateKey.length !== 64) {
  throw new Error(`Invalid private key length: ${privateKey.length} (expected 64)`)
}

// Hex format validation
const hexRegex = /^[0-9a-fA-F]+$/
if (!hexRegex.test(privateKey)) {
  throw new Error(`Invalid private key format: contains non-hex characters`)
}

Access Control

Admin Pubkey Validation:

// Only admin pubkeys can post announcements
function isAdminPost(pubkey: string): boolean {
  return configUtils.isAdminPubkey(pubkey)
}

// Filter content based on user permissions
if (props.feedType === 'announcements' && !isAdminPost(event.pubkey)) {
  shouldInclude = false
}

Benefits of This Architecture

1. Consistent Event Handling

  • All components use the same event creation and publishing patterns
  • Centralized relay management ensures consistent behavior
  • Shared error handling and retry logic

2. Efficient Resource Usage

  • Single WebSocket connection pool shared across all components
  • No duplicate relay connections
  • Centralized connection health monitoring

3. Simplified Component Logic

  • Components focus on business logic, not relay management
  • Consistent API for all Nostr operations
  • Easy to add new event types and functionality

4. Maintainable Codebase

  • Single source of truth for relay connections
  • Centralized event publishing logic
  • Easy to debug and monitor Nostr operations

5. Robust Error Handling

  • Automatic reconnection and health monitoring
  • Graceful degradation when relays are unavailable
  • Comprehensive error logging and user feedback

6. Performance Optimized

  • Memory management with array size limits
  • Efficient connection pooling
  • Parallel relay operations

Future Extensibility

This architecture makes it easy to add new Nostr functionality:

  1. New Event Types: Add new event creation utilities following the existing pattern
  2. Additional Relays: Configure new relays in the centralized relay hub
  3. New Components: Import the existing singletons and use the established patterns
  4. Enhanced Features: Extend the relay hub with new capabilities (e.g., event caching, filtering)
  5. Advanced Filtering: Implement more sophisticated event filtering and processing
  6. Caching Layer: Add event caching for improved performance
  7. Rate Limiting: Implement rate limiting for relay operations
  8. Metrics and Monitoring: Add comprehensive metrics for relay performance

This architecture makes the app more maintainable, performant, and user-friendly while providing a solid foundation for future features.