feed2.html was missing walletGetBalance and walletGetMints in both the init-ndk.mjs import and the initPostCards call. Without these, the zap rail selector and capability badges couldn't function — handleZapClick couldn't resolve zap capabilities or show the rail toggle.
client
A nostr web application framework built on NDK (Nostr Development Kit). Uses a SharedWorker architecture where a single NDK instance manages all relay connections, caching, and subscriptions across multiple browser tabs.
Architecture Overview
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ index.html │ │ post.html │ │ cashu.html │ ... (any page)
│ init-ndk.mjs│ │ init-ndk.mjs│ │ init-ndk.mjs│
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ postMessage │ │
└─────────────┬────┘─────────────────┘
▼
┌─────────────────┐
│ ndk-worker.js │ (SharedWorker — single instance)
│ ┌─────────────┐ │
│ │ NDK │ │
│ │ + Dexie │ │ ← IndexedDB cache (ndk-shared)
│ │ + Signer │ │
│ └──────┬──────┘ │
└─────────┼────────┘
▼
┌────────────────────────┐
│ Nostr Relay Pool │
│ wss://relay1 relay2 │
└────────────────────────┘
Event Management Strategy
This section defines when and how nostr events are fetched, cached, and subscribed to. It serves as the single source of truth for event lifecycle management.
Cache Behavior
All events flowing through NDK are automatically cached in IndexedDB via the Dexie adapter (database: ndk-shared). This means:
- Any
ndk.fetchEvents()call caches results automatically - Any subscription event received is cached via
cacheAdapter.setEvent() - Pages can query the cache directly via
queryCache()without hitting relays - NDK's
CACHE_FIRSTmode returns cached data immediately, then updates from relays
Startup Events (fetched by worker on init)
These events are fetched once during handleInit() when the first tab connects. They populate the Dexie cache and provide essential app-wide state.
| Priority | Kind | NIP | Name | Filter | Subscription | Purpose |
|---|---|---|---|---|---|---|
| 1 | 0 |
NIP-01 | User Metadata | authors:[pubkey] |
No — one-shot | Profile display (name, picture, about) |
| 2 | 10002 |
NIP-65 | Relay List | authors:[pubkey], limit:1 |
Yes — persistent closeOnEose:false |
Configure relay pool; live updates |
| 3 | 30078 |
NIP-78 | App Settings | authors:[pubkey], #d:[user-settings] |
No — one-shot | Encrypted user preferences (NIP-44) |
| 4 | 17375 |
NIP-60 | Cashu Wallet | authors:[pubkey] |
Yes — startup fetch + persistent | Wallet metadata, mint list, and live wallet changes |
| 5 | 7375 |
NIP-60 | Cashu Tokens | authors:[pubkey] |
Yes — startup fetch + persistent | Unspent proofs and live token changes |
| 6 | 5 |
NIP-09 | Event Deletion | authors:[pubkey], #k:[7375] |
Yes — startup fetch + persistent | Track spent/deleted tokens |
Startup sequence (current implementation):
handleInit(pubkey)
├── initNDK() // Create NDK, connect relays
├── hydrateUserSettingsForPubkey() // kind 30078
├── fetchUserProfile() // kind 0
├── fetchUserRelays() // kind 10002 (one-shot)
├── subscribe kind 10002 // persistent relay list updates
├── fetchStartupEventDownloads() // one-shot preload: kinds 3, 17375, 7375, 5(#k=7375)
└── ensureStartupWalletSubscriptions() // persistent wallet subscriptions: 17375, 7375, 5(#k=7375)
This means wallet information is not only prefetched at startup; it is now also continuously updated by long-lived worker subscriptions.
On-Demand Events (fetched by individual pages)
These events are fetched when a specific page loads. They benefit from the Dexie cache — if the same events were previously fetched, they're served from cache first.
| Kind | NIP | Name | Pages | Pattern |
|---|---|---|---|---|
0 |
NIP-01 | User Metadata | profile, msg, post, npub | fetchEventsFromAllRelays or subscribe |
1 |
NIP-01 | Short Text Notes | post, index, links, strudel, template | subscribe with CACHE_FIRST |
3 |
NIP-02 | Contact List | post, profile, cashu | subscribe + queryCache + fetchEventsFromAllRelays |
4 |
NIP-04 | Encrypted DMs | msg | subscribe with CACHE_FIRST |
1059 |
NIP-44 | Gift Wrap (DMs) | msg | subscribe with CACHE_FIRST |
5 |
NIP-09 | Event Deletion | (via startup sub) | Persistent subscription |
7 |
NIP-25 | Reactions | post (via interactions) | fetchEventsFromAllRelays |
6 |
NIP-18 | Reposts | post (via interactions) | fetchEventsFromAllRelays |
9735 |
NIP-57 | Zap Receipts | post (via interactions) | fetchEventsFromAllRelays |
10019 |
NIP-60 | Mint List | cashu (mint discovery) | fetchEventsFromAllRelays |
10063 |
NIP-96 | Blossom Server List | blobs | subscribe with CACHE_FIRST |
17375 |
NIP-60 | Cashu Wallet | cashu (full wallet ops) | Via NDKCashuWallet |
7375 |
NIP-60 | Cashu Tokens | cashu (full wallet ops) | Via NDKCashuWallet (cache-first) |
7376 |
NIP-60 | Spending History | cashu (transaction list) | Via NDKCashuWallet |
30023 |
NIP-23 | Long-form Content | note | subscribe with CACHE_FIRST |
30024 |
NIP-23 | Draft Long-form | note | subscribe with CACHE_FIRST |
30078 |
NIP-78 | App-specific Data | todo, cal, links, post (viewed) | subscribe or fetchEvents |
38000 |
NIP-89 | Recommendations | cashu (mint discovery) | fetchEventsFromAllRelays |
38421 |
— | AI Service Announcements | ai | subscribe with CACHE_FIRST |
Subscription Patterns
The app uses three distinct patterns for fetching events:
1. One-shot Fetch
// Fetch once, cache result, done
const events = await ndk.fetchEvents(filter);
Used for: startup profile, relay list, wallet definition, user settings
2. Subscribe with CACHE_FIRST
// Returns cached events immediately, then live events from relays
subscribe(filter, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
Used for: most page-level subscriptions (posts, contacts, notes, DMs)
3. Persistent Worker Subscription
// Long-lived subscription in the worker, survives page navigation
ndk.subscribe(filter, { closeOnEose: false });
Used for: relay list updates (kind 10002), wallet metadata (kind 17375), token events (kind 7375), and token deletions (kind 5 with #k=7375)
Cache-First Advantage
Because startup events are fetched early and cached in Dexie, subsequent page loads benefit:
- post.html subscribes to kind 3 (contacts) → if profile.html already fetched it, cache hit
- cashu.html starts NDKCashuWallet → kind 7375 tokens already in cache from startup → instant optimistic balance
- Any page fetching kind 0 (profile) → already cached from startup
Modifying Event Timing
To change when an event kind is fetched:
-
Move to startup: Add the fetch to
fetchStartupWalletState()or create a new startup function inhandleInit(). This pre-populates the cache for all pages. -
Move to on-demand: Remove from startup, let individual pages fetch as needed. First load will be slower but startup will be faster.
-
Add a subscription: For events that change frequently, add a persistent subscription in the worker. This keeps the cache fresh without page-level polling.
Trade-offs:
- More startup events = slower initial load, faster page navigation
- Fewer startup events = faster initial load, slower first page that needs the data
- Persistent subscriptions = always fresh data, more relay bandwidth
Project Structure
www/
├── ndk-worker.js # SharedWorker — NDK instance, cache, subscriptions
├── js/
│ ├── init-ndk.mjs # Client-side NDK API (shared by all pages)
│ ├── relay-ui.mjs # Footer relay status + balance display
│ ├── cashu-wallet.mjs # Cashu wallet controller (cashu.html only)
│ ├── post-interactions.mjs # Social interactions (reactions, reposts, zaps)
│ └── utilities.mjs # Shared utilities
├── css/client.css # Shared styles
├── *.html # Individual pages
└── ndk-core.bundle.js # Bundled NDK library
build/
├── ndk-entry.js # Bundle entry point
└── ndk-core.bundle.js # Built bundle
ndk/ # NDK source (submodule/clone)
├── core/ # Core NDK library
├── wallet/ # Cashu wallet implementation
├── cache-browser/ # Browser cache adapter
├── cache-dexie/ # Dexie (IndexedDB) cache
└── ...
plans/ # Architecture and implementation plans
Building
# Build the NDK bundle
node build-ndk-bundle.js
# Serve locally
python serve_local.py