Files
client/plans/keep-alive-page.md
2026-04-17 16:52:51 -04:00

9.9 KiB
Raw Permalink Blame History

Keep-Alive Page Architecture

Overview

The keep-alive page is a utility that ensures a user's Nostr events persist across the ecosystem by discovering free relays and republishing events to them. It operates as a manual, step-by-step process with buttons for each phase, allowing the user to observe and control each stage.

Core Concepts

Event Priority System

Not all events are equally important. The page prioritizes event kinds for spreading:

Priority Kinds Rationale
Critical 7375, 7376, 17375, 37375 (Cashu wallet/token events), 10002 (relay list), 0 (profile metadata) Loss = lost ecash or identity
High 3 (contacts/follows), 10000 (mute list), 30078 (app-specific data), 30023 (long-form content) Important user data
Medium 30311 (live events), 30024 (drafts), 1063 (file metadata), 9735 (zap receipts) Valuable but recoverable
Low 1 (text notes), 6 (reposts), 7 (reactions), 1111 (comments) High volume, low individual value

Relay Registry - kind 30078 with t-tag grouping

Discovered and tested relays are stored as addressable events using a t tag for grouping and sequential d tags for pagination. This keeps all data in Nostr events (portable across devices and apps) without needing a manifest.

Event structure (each chunk):

{
  "kind": 30078,
  "tags": [
    ["d", "relay-list-001"],
    ["t", "relay-list"]
  ],
  "content": "[{\"url\":\"wss://relay1.com\",\"available\":true,...}, ...]"
}

Each relay entry in the JSON content array:

{
  "url": "wss://relay.example.com",
  "tested": 1711440000,
  "available": true,
  "nip11": { "name": "...", "description": "...", "supported_nips": [], "limitation": {} },
  "geo": null
}

Fetching: A single filter retrieves all chunks:

{ kinds: [30078], authors: [pubkey], "#t": ["relay-list"] }

Pagination: Each chunk holds ~100 relays (~50KB). Sequential d tags (relay-list-001, 002, etc.) make each chunk independently replaceable.

Republishing: When re-testing, rebuild chunks and publish. If fewer chunks are needed than before, publish kind 5 deletion events for the extras.

Size math: ~500 bytes per relay with NIP-11 data × 100 relays = ~50KB per chunk. 300 relays = 3 events. 500 relays = 5 events. Well within relay limits.

Cross-app usage: Any app can discover the relay registry by querying for kind 30078 with #t: relay-list for a given pubkey.

Architecture

Data Flow

flowchart TD
    A[Step 1: Find My Events] --> B[Step 2: Find Relays]
    B --> C[Step 3: Test Relays]
    C --> D[Step 4: Store Relay Registry]
    D --> E[Step 5: Spread Events]
    
    A -->|Fetch all user events via NDK| A1[Events grouped by kind + priority]
    B -->|Fetch kind 3 follows list| B1[Get follows pubkeys]
    B1 -->|Fetch kind 10002 for each follow| B2[Extract unique relay URLs]
    C -->|Generate session dummy nsec| C1[Post kind 1 test event to each relay]
    C1 -->|Track success/failure| C2[Filtered list of available relays]
    D -->|Publish kind 30078| D1[Relay registry persisted]
    E -->|For each priority tier| E1[Publish events to available relays]
    E1 -->|Rate limited - one relay at a time| E2[Progress log with success/fail per event]

Page Layout

flowchart TD
    subgraph Header
        H[KEEP ALIVE title]
    end
    
    subgraph Body
        BTN[Step Buttons Row]
        STATS[Stats Summary Panel]
        LOG[Live Activity Log - scrollable]
    end
    
    subgraph Footer
        F[Relay status + balance]
    end

Step Buttons

Each button represents a phase. They are enabled sequentially - Step 2 only enables after Step 1 completes, etc. Each button shows a status indicator:

  • ○ Not started
  • ◐ In progress
  • ● Complete
  • ✕ Error

Buttons:

  1. Find My Events - Fetches all user events, displays count by kind
  2. Find Relays - Discovers relays from follows' kind 10002
  3. Test Relays - Tests each discovered relay with dummy event
  4. Save Registry - Stores tested relay data as kind 30078
  5. Spread: Critical - Spreads critical priority events
  6. Spread: High - Spreads high priority events
  7. Spread: Medium - Spreads medium priority events
  8. Spread: Low - Spreads low priority events

Stats Summary Panel

Shows at-a-glance numbers:

  • Total events found: X
  • Events by priority: Critical: X | High: X | Medium: X | Low: X
  • Relays discovered: X
  • Relays available: X / X tested
  • Events spread: X / X total

Live Activity Log

A scrollable log area showing real-time progress messages:

[12:34:01] Fetching user events...
[12:34:03] Found 142 events across 8 kinds
[12:34:03]   kind 1: 89 events (low priority)
[12:34:03]   kind 0: 1 event (critical priority)
[12:34:03]   kind 7375: 3 events (critical priority)
...
[12:35:10] Testing wss://relay.damus.io ... ✓ available
[12:35:12] Testing wss://relay.nostr.band ... ✓ available
[12:35:15] Testing wss://broken.relay.xyz ... ✕ timeout

Implementation Details

Phase 1: Page Skeleton

  • Modify www/keep-alive.html from template
  • Title: "KEEP ALIVE"
  • CSS for button row, stats panel, log area
  • Import required NDK functions: initNDKPage, getPubkey, subscribe, publishEvent, ndkFetchEvents, fetchEvents, getVersion, etc.
  • All page logic inline in the HTML script module, following existing page patterns

Phase 2: Find My Events

// Fetch all events authored by the user
const filters = { authors: [currentPubkey] };
const events = await ndkFetchEvents(filters);

// Group by kind and assign priority
const eventsByKind = new Map();
for (const event of events) {
    if (!eventsByKind.has(event.kind)) eventsByKind.set(event.kind, []);
    eventsByKind.get(event.kind).push(event);
}

Priority assignment uses the table above. Events are stored in memory for the spreading phase.

Phase 3: Find Relays

  1. Fetch user's kind 3 event to get follows list
  2. Extract pubkeys from p tags
  3. Fetch kind 10002 events for all follows: { kinds: [10002], authors: [followPubkeys...] }
  4. Parse r tags from each 10002 event to extract relay URLs
  5. Deduplicate and normalize URLs
  6. Also check if we already have a stored relay registry (kind 30078 with #t: relay-list) and merge

Phase 4: Test Relays

Uses the user's own kind 0 (profile metadata) event as the test payload. This is ideal because:

  • It's already signed by the user — no dummy keypair needed
  • Every relay accepts kind 0 events
  • It serves double duty: testing the relay AND spreading the user's profile

For each discovered relay:

  1. Use the user's kind 0 event fetched in Phase 2 (already signed)
  2. Open a direct WebSocket connection to the relay (bypassing NDK pool since these are not user relays)
  3. Send the kind 0 event as the test
  4. Wait for OK response or timeout (5 seconds)
  5. Also fetch NIP-11 info document via HTTP: fetch(relayUrl.replace('wss://', 'https://'), { headers: { Accept: 'application/nostr+json' } })
  6. Record result: available/unavailable, NIP-11 data, response time

Important: This uses raw WebSocket connections, not the NDK worker, because we don't want to pollute the user's relay pool with hundreds of test relays.

Phase 5: Store Relay Registry

  1. Build relay metadata array from test results
  2. Chunk into events of ~100 relays each with d: "relay-list-001", d: "relay-list-002", etc.
  3. Each event tagged with ["t", "relay-list"]
  4. Publish via publishEvent() (uses user's connected relays)
  5. If fewer chunks needed than previously stored, publish kind 5 deletion events for extras

Phase 6: Spread Events

For each priority tier, when the user clicks the corresponding spread button:

  1. Iterate through available relays
  2. For each relay, open a WebSocket connection
  3. Send events one at a time with a delay between each (rate limiting)
  4. Use publishRawEvent-style approach: events are already signed by the user
  5. Track success/failure per event per relay
  6. Log progress in real-time
  7. Rate limiting: ~500ms between events to the same relay, process one relay at a time

Relay kindness rules:

  • Respect NIP-11 limitation.max_event_tags, limitation.max_content_length
  • Don't send events that exceed relay's stated limits
  • If relay returns blocked: or rate-limited: in OK response, back off and skip
  • Close WebSocket cleanly after batch is done

Worker Considerations

The keep-alive page will need to do direct WebSocket operations for:

  • Testing relays with dummy events (Phase 4)
  • Spreading events to non-pool relays (Phase 6)

This will be done client-side in the page script, not through the NDK worker, because:

  • We don't want to add hundreds of relays to the NDK pool
  • We need fine-grained control over individual relay connections
  • The worker's publish function only publishes to pool relays

The NDK worker is still used for:

  • Authentication and user pubkey
  • Fetching user events (Phase 2)
  • Fetching follows and kind 10002 events (Phase 3)
  • Publishing the relay registry kind 30078 (Phase 5)

File Structure

All implementation is in a single file following existing patterns:

  • www/keep-alive.html - Complete page with inline styles and script module

No new JS modules needed for Phase 1. If the WebSocket relay testing/spreading logic grows complex, it could be extracted to www/js/keep-alive.mjs later.

Future Enhancements

  • Geo-location: Resolve relay hostnames to IP addresses, use a geo-IP service, store lat/lng in relay registry metadata. Display on a world map showing where user data exists.
  • Broader discovery: Fetch kind 10002 from popular relays for random pubkeys to discover more relays beyond the user's social graph.
  • Automation: Add a "Run All" button that executes all steps sequentially.
  • Scheduling: Option to run keep-alive periodically (e.g., weekly) using a service worker or reminder.
  • Diff mode: Show which events are already on which relays before spreading, to avoid redundant publishes.