A powerful local-first nostr client framework written in Dart with built-in WebSocket pool management and background isolate support.
Reference implementation of models.
- 🔄 Local-First Architecture - SQLite-backed storage with background isolate processing
- 🌐 Smart WebSocket Pool - Automatic reconnection with exponential backoff
- 📡 Ping-Based Health Checks - Detects zombie connections via
limit:0requests - 🎯 Event Deduplication - Intelligent handling of events from multiple relays
- 📊 Unified State - Single source of truth for subscriptions and relay state
- ⚙️ Configurable Batching - Low-latency streaming with tunable flush windows
import 'package:purplebase/purplebase.dart';
import 'package:riverpod/riverpod.dart';
// Configure
final config = StorageConfiguration(
databasePath: 'myapp.db',
defaultRelays: {
'default': {'wss://relay.damus.io', 'wss://relay.nostr.band'},
},
defaultQuerySource: const LocalAndRemoteSource(
relays: 'default',
stream: false,
),
);
// Initialize
final container = ProviderContainer();
await container.read(initializationProvider(config).future);
// Query
final notes = await container.read(storageNotifierProvider.notifier).query<Note>(
RequestFilter(kinds: {1}, limit: 20).toRequest(),
source: RemoteSource(relays: 'default'),
);Call connect() when your app resumes and disconnect() when paused:
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final storage = ref.read(storageNotifierProvider.notifier);
if (state == AppLifecycleState.resumed) {
storage.connect();
} else if (state == AppLifecycleState.paused) {
storage.disconnect();
}
}connect()resets backoff timers and triggers immediate reconnection for all non-connected relays (including failed ones)disconnect()cleanly closes all connections and stops retry timers
StorageConfiguration(
databasePath: 'myapp.db', // null for in-memory
defaultRelays: {'default': {'wss://relay.com'}},
defaultQuerySource: LocalAndRemoteSource(relays: 'default', stream: false),
responseTimeout: Duration(seconds: 15), // EOSE and publish timeout
streamingBufferDuration: Duration(milliseconds: 100), // Event batching window
)The pool uses these timing constants (not configurable):
| Constant | Value | Description |
|---|---|---|
relayTimeout |
5s | Connection and ping timeout |
pingIdleThreshold |
55s | Ping if idle this long |
healthCheckInterval |
60s | Heartbeat frequency |
maxRetries |
31 | Total attempts before marking relay as failed |
Reconnection uses a tiered backoff: delay = 2^n seconds, repeated 2^n times:
| Level | Delay | Attempts | Cumulative |
|---|---|---|---|
| n=0 | 1s | 1 | 1 |
| n=1 | 2s | 2 | 3 |
| n=2 | 4s | 4 | 7 |
| n=3 | 8s | 8 | 15 |
| n=4 | 16s | 16 | 31 |
After 31 attempts, the relay is marked as failed. Call connect() to restart failed relays.
| Mode | stream |
Returns | Behavior |
|---|---|---|---|
| Blocking | false |
Future<List<Event>> |
Waits for all relays, then returns |
| Streaming | true |
[] immediately |
Events arrive via callbacks |
// Blocking: waits for all relays to respond
final events = await storage.query<Note>(
req,
source: RemoteSource(relays: 'default', stream: false),
);
// Streaming: returns immediately, events come via notification
await storage.query<Note>(
req,
source: RemoteSource(relays: 'default', stream: true),
);When relays don't respond within responseTimeout:
- Blocking queries: Return partial results and auto-cleanup
- Streaming queries: Continue with responding relays
- Background Isolate - SQLite + WebSocket pool run off the UI thread
- Single Source of Truth -
PoolStatecontains all subscription and relay state - Per-Subscription Relay Tracking - Each subscription tracks its relays independently
- Auto-Reconnect - Tiered backoff (1s, 2s×2, 4s×4, 8s×8, 16s×16) with 31 total attempts
- connect() / disconnect() - App lifecycle management for reconnection and clean shutdown
- Ping Health Checks -
limit:0requests detect zombie connections (55s idle threshold) - Event Batching - Cross-relay deduplication with configurable flush window
- No Resource Leaks - Timeouts always clean up blocking subscriptions
dart testMIT