- 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.
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:
- Efficient Resource Management: Single connection pools and shared state
- Consistent User Experience: All components see the same data
- Simplified Development: Clear patterns for state management
- Better Performance: No duplicate connections or state
- 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 finalizationnip04- Encrypted message handlingSimplePool- Relay connection managementEventTemplate- 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:
- New Event Types: Add new event creation utilities following the existing pattern
- Additional Relays: Configure new relays in the centralized relay hub
- New Components: Import the existing singletons and use the established patterns
- Enhanced Features: Extend the relay hub with new capabilities (e.g., event caching, filtering)
- Advanced Filtering: Implement more sophisticated event filtering and processing
- Caching Layer: Add event caching for improved performance
- Rate Limiting: Implement rate limiting for relay operations
- 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.