Files
client/plans/relay-disable-enable.md
T

12 KiB

Relay Disable/Enable Feature Plan

Goal

Allow users to temporarily disable and enable individual relays in the app without modifying the contents of kind 10002 (the user's persisted relay list). This is an app-wide, session-level state that affects all pages via the shared NDK worker.

Current State

What exists today

  • relays.html — clicking the "connected" status icon calls handleRelayReconnect(relayUrl) which calls reconnectRelay(relayUrl) → sends reconnectRelay message to worker
  • Worker handleReconnectRelay() — disconnects the relay, waits 500ms, then reconnects it (a toggle/restart behavior)
  • Worker handleDisconnect() — disconnects ALL relays (used for logout only)
  • NDK auto-reconnect — when a relay disconnects, NDK's connectivity layer automatically attempts reconnection with exponential backoff (see handleReconnection() at line 19884 and flapping detection at line 19844)

What's missing

There is no concept of "temporarily disabled" — the only options are:

  1. Reconnect (disconnect + immediate reconnect) — relay comes right back up
  2. Full disconnect (logout) — disconnects everything

If you just call relay.disconnect(), NDK's auto-reconnect logic will bring it back online within seconds.

The outbox model

The worker's NDK instance has a full outbox model (ndk.outboxTracker, ndk.outboxPool) that:

  • Resolves followed authors' kind 10002 relay lists
  • Adds temporary relays to the pool via pool.useTemporaryRelay() to fetch events from those authors
  • These temporary relays auto-remove after inactivity (temporaryRelayTimers)

NDK has a built-in relayConnectionFilter callback that is checked in three key places:

  1. Pool's addRelay() (line 21879) — refuses to add relays that fail the filter (including temporary outbox relays)
  2. Outbox tracker relay list resolution (line 54404) — filters disabled relays out of readRelays and writeRelays sets
  3. All three NDK pool implementations check the filter (line 47128, line 73531, line 78145)

The relayConnectionFilter is already used elsewhere in the codebase (the ndk-store module at line 83105 uses it for blockedRelays), but the worker's main NDK instance (created at line 90998) does not currently set it.

This is the ideal mechanism for the disable feature — setting ndk.relayConnectionFilter on the worker's main NDK instance will automatically prevent disabled relays from being used by the outbox model, without any additional outbox-specific code.

Design

Worker-side: disabledRelays Set

Add a Set<string> in the worker that tracks temporarily disabled relay URLs (normalized, trailing-slash form).

ndk.relayConnectionFilter — the key mechanism

Set ndk.relayConnectionFilter on the worker's main NDK instance to check disabledRelays:

ndk.relayConnectionFilter = (relayUrl) => {
    return !disabledRelays.has(normalizeRelayUrl(relayUrl));
};

This automatically covers the outbox model:

  • Prevents disabled relays from being added as temporary outbox relays
  • Filters disabled relays out of outbox tracker's read/write relay sets
  • Prevents NDK from connecting to disabled relays via any path

New worker message handlers

disableRelay — disable a relay

  1. Add the relay URL (normalized) to disabledRelays
  2. Update ndk.relayConnectionFilter (or it reads disabledRelays live)
  3. Get the relay from the pool, disconnect it
  4. Suppress auto-reconnect: the NDK connectivity layer's handleReconnection() is called on disconnect. We need to intercept this.

enableRelay — re-enable a relay

  1. Remove the relay URL from disabledRelays
  2. Get the relay from the pool, call relay.connect()
  3. If the relay isn't in the pool (e.g. write-only), add it on demand (same as handleReconnectRelay does)

getDisabledRelays — query disabled state

Returns the current disabledRelays set to the page, so the UI can show disabled state.

Suppressing auto-reconnect

The NDK pool's disconnectHandler (see line 21908) fires on relay disconnect and triggers reconnection. We need to prevent this for disabled relays.

Approach: In the worker's existing relay disconnect event handling, check if the relay URL is in disabledRelays before allowing reconnection. The disconnect handler in the pool calls relay.connect() on disconnect — we intercept by checking disabledRelays before calling connect.

The most surgical approach: in attachRelayEventListeners (where disconnect handlers are registered), add a check: if the relay URL is in disabledRelays, don't trigger reconnection. Since the worker wraps NDK, we can intercept at the worker level.

handleGetRelayData enhancement

Add a disabled boolean field to each relay entry in the response, so pages can show disabled state without a separate query.

init-ndk.mjs API

Add three new exported functions:

  • disableRelay(relayUrl) — sends disableRelay message to worker
  • enableRelay(relayUrl) — sends enableRelay message to worker
  • getDisabledRelays() — sends getDisabledRelays message, returns Promise

relays.html UI changes

  • Add a new "Enabled" column between "Relay" and "Connected" in the relay table
  • Uses the same checkbox style as the Read/Write/DM Inbox columns (SVG_CHECKED/SVG_UNCHECKED)
  • Clicking the Enabled checkbox:
    • If enabled (checked) → disable the relay (call disableRelay(), unchecks box, disconnects relay)
    • If disabled (unchecked) → enable the relay (call enableRelay(), checks box, reconnects relay)
  • The "Connected" column keeps its existing behavior (click to reconnect a disconnected relay)
  • The disabled field from getRelayData drives the checkbox state
  • The add-relay row gets a - placeholder in the Enabled column (new relays are enabled by default)

Current table columns:

| (remove) | Relay | Connected | Read | Write | DM Inbox | Reads | Writes | Connection Time |

New table columns:

| (remove) | Relay | Enabled | Connected | Read | Write | DM Inbox | Reads | Writes | Connection Time |

Persistence

No persistence — disabled relays are session-only. On page reload / worker restart, all relays reconnect normally from kind 10002. This matches the "temporarily" requirement.

Architecture Diagram

flowchart TD
  A[User clicks relay status icon] --> B{Relay state?}
  B -- connected --> C[disableRelay - disconnect and suppress reconnect]
  B -- disabled --> D[enableRelay - reconnect]
  B -- disconnected, not disabled --> E[reconnectRelay - existing toggle]

  C --> F[Worker: add to disabledRelays Set]
  F --> G[Worker: relay.disconnect]
  G --> H[NDK disconnect handler fires]
  H --> I{Is relay in disabledRelays?}
  I -- yes --> J[Skip auto-reconnect]
  I -- no --> K[Auto-reconnect with backoff]

  D --> L[Worker: remove from disabledRelays Set]
  L --> M[Worker: relay.connect]
  M --> N[Relay reconnects]

  E --> O[Worker: disconnect + 500ms + reconnect]

Outbox Model Toggle

Add a checkbox in the sidenav (alongside "Show connection history") to enable/disable the outbox model. Default: enabled.

How it works

NDK has autoConnectUserRelays (default true) which controls whether NDK auto-connects to relays discovered via the outbox tracker. Toggling this at runtime effectively enables/disables the outbox model:

  • Disable: set ndk.autoConnectUserRelays = false, disconnect all temporary/discovered relays
  • Enable: set ndk.autoConnectUserRelays = true, let NDK re-discover and connect as needed

The outboxTracker still resolves relay lists when disabled, but NDK won't connect to those relays — so no outbox fetches happen.

Persistence

localStorage key outboxModel ('true'/'false'), default 'true'. Read on page load, sent to worker on init.

UI

Same pattern as "Show connection history" checkbox — a clickable row in the sidenav with SVG_CHECKED/SVG_UNCHECKED icon.

Implementation Steps

Worker (www/ndk-worker.js)

  1. Add disabledRelays Set — near the top of the worker logic, alongside other relay state
  2. Set ndk.relayConnectionFilter — after NDK init, set it to check disabledRelays (covers outbox model for disabled relays)
  3. Add handleDisableRelay(relayUrl, port) — add to set, disconnect relay, suppress reconnect
  4. Add handleEnableRelay(relayUrl, port) — remove from set, reconnect relay
  5. Add handleGetDisabledRelays(requestId, port) — return the set
  6. Modify auto-reconnect suppression — in the disconnect event handler or attachRelayEventListeners, check disabledRelays before allowing reconnection
  7. Modify handleGetRelayData — add disabled: boolean field to each relay entry
  8. Add handleSetOutboxModel(enabled, port) — set ndk.autoConnectUserRelays, disconnect temporary relays if disabling
  9. Add message handler casesdisableRelay, enableRelay, getDisabledRelays, setOutboxModel in the switch statement

Init module (www/js/init-ndk.mjs)

  1. Add disableRelay(relayUrl) export — sends disableRelay message
  2. Add enableRelay(relayUrl) export — sends enableRelay message
  3. Add getDisabledRelays() export — sends getDisabledRelays, returns Promise
  4. Add setOutboxModel(enabled) export — sends setOutboxModel message

Relays page (www/relays.html)

  1. Import new functions — add disableRelay, enableRelay, setOutboxModel to the import from init-ndk.mjs
  2. Add "Enabled" column to table header — new <th> between "Relay" and "Connected"
  3. Add "Enabled" checkbox to each relay rowSVG_CHECKED/SVG_UNCHECKED based on relay.disabled field, with data-enable-relay-url attribute for click handler
  4. Add "Enabled" placeholder to add-relay row- (new relays are enabled by default)
  5. Add click handler for Enabled checkbox — calls disableRelay() or enableRelay() based on current state, then refreshes relay data
  6. Keep existing "Connected" click handler — still calls handleRelayReconnect() for reconnecting disconnected (but enabled) relays
  7. Add outbox model checkbox to sidenav — alongside "Show connection history", with localStorage persistence and setOutboxModel() call
  8. Sync outbox model state on init — read from localStorage, send to worker on page load

Key Considerations

  • No kind 10002 changes — disabled relays stay in the user's relay list, they're just temporarily disconnected
  • Session-only — disabled state is lost on worker restart / page reload
  • App-wide — the worker is a SharedWorker, so disabling a relay on relays.html affects all open pages
  • Auto-reconnect suppression — the critical piece; NDK will try to reconnect unless we explicitly prevent it
  • Write-only relays — need to handle the case where a disabled relay isn't in the main pool (same as handleReconnectRelay does)
  • Outbox model coveragendk.relayConnectionFilter automatically prevents disabled relays from being used by the outbox tracker, so disabled relays won't be added as temporary relays for fetching events from followed authors
  • relayConnectionFilter is already proven — the ndk-store module already uses this exact pattern for blockedRelays at line 83105