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 callshandleRelayReconnect(relayUrl)which callsreconnectRelay(relayUrl)→ sendsreconnectRelaymessage 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:
- Reconnect (disconnect + immediate reconnect) — relay comes right back up
- 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 10002relay 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:
- Pool's
addRelay()(line 21879) — refuses to add relays that fail the filter (including temporary outbox relays) - Outbox tracker relay list resolution (line 54404) — filters disabled relays out of
readRelaysandwriteRelayssets - 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
- Add the relay URL (normalized) to
disabledRelays - Update
ndk.relayConnectionFilter(or it readsdisabledRelayslive) - Get the relay from the pool, disconnect it
- Suppress auto-reconnect: the NDK connectivity layer's
handleReconnection()is called on disconnect. We need to intercept this.
enableRelay — re-enable a relay
- Remove the relay URL from
disabledRelays - Get the relay from the pool, call
relay.connect() - If the relay isn't in the pool (e.g. write-only), add it on demand (same as
handleReconnectRelaydoes)
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)— sendsdisableRelaymessage to workerenableRelay(relayUrl)— sendsenableRelaymessage to workergetDisabledRelays()— sendsgetDisabledRelaysmessage, 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)
- If enabled (checked) → disable the relay (call
- The "Connected" column keeps its existing behavior (click to reconnect a disconnected relay)
- The
disabledfield fromgetRelayDatadrives 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)
- Add
disabledRelaysSet — near the top of the worker logic, alongside other relay state - Set
ndk.relayConnectionFilter— after NDK init, set it to checkdisabledRelays(covers outbox model for disabled relays) - Add
handleDisableRelay(relayUrl, port)— add to set, disconnect relay, suppress reconnect - Add
handleEnableRelay(relayUrl, port)— remove from set, reconnect relay - Add
handleGetDisabledRelays(requestId, port)— return the set - Modify auto-reconnect suppression — in the disconnect event handler or
attachRelayEventListeners, checkdisabledRelaysbefore allowing reconnection - Modify
handleGetRelayData— adddisabled: booleanfield to each relay entry - Add
handleSetOutboxModel(enabled, port)— setndk.autoConnectUserRelays, disconnect temporary relays if disabling - Add message handler cases —
disableRelay,enableRelay,getDisabledRelays,setOutboxModelin the switch statement
Init module (www/js/init-ndk.mjs)
- Add
disableRelay(relayUrl)export — sendsdisableRelaymessage - Add
enableRelay(relayUrl)export — sendsenableRelaymessage - Add
getDisabledRelays()export — sendsgetDisabledRelays, returns Promise - Add
setOutboxModel(enabled)export — sendssetOutboxModelmessage
Relays page (www/relays.html)
- Import new functions — add
disableRelay,enableRelay,setOutboxModelto the import from init-ndk.mjs - Add "Enabled" column to table header — new
<th>between "Relay" and "Connected" - Add "Enabled" checkbox to each relay row —
SVG_CHECKED/SVG_UNCHECKEDbased onrelay.disabledfield, withdata-enable-relay-urlattribute for click handler - Add "Enabled" placeholder to add-relay row —
-(new relays are enabled by default) - Add click handler for Enabled checkbox — calls
disableRelay()orenableRelay()based on current state, then refreshes relay data - Keep existing "Connected" click handler — still calls
handleRelayReconnect()for reconnecting disconnected (but enabled) relays - Add outbox model checkbox to sidenav — alongside "Show connection history", with localStorage persistence and
setOutboxModel()call - Sync outbox model state on init — read from localStorage, send to worker on page load
Key Considerations
- No
kind 10002changes — 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.htmlaffects 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
handleReconnectRelaydoes) - Outbox model coverage —
ndk.relayConnectionFilterautomatically 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 relayConnectionFilteris already proven — thendk-storemodule already uses this exact pattern forblockedRelaysat line 83105