nostr-client-patterns

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Nostr Client Patterns

Nostr客户端模式

Overview

概述

Implement robust Nostr client architecture. This skill covers the patterns agents miss: relay pool connection management, subscription state machines that correctly handle EOSE/CLOSED transitions, event deduplication across relays, optimistic UI with OK message error recovery, and reconnection with gap-free event delivery.
实现健壮的Nostr客户端架构。本技能覆盖了Agent通常会遗漏的模式:中继池连接管理、可正确处理EOSE/CLOSED状态流转的订阅状态机、跨中继事件去重、支持OK消息错误恢复的乐观UI,以及无事件丢失的重连机制。

When to Use

适用场景

  • Building a Nostr client that connects to multiple relays
  • Implementing relay pool management (connection lifecycle, backoff)
  • Managing subscription state (loading vs live, EOSE transitions)
  • Deduplicating events received from multiple relays
  • Implementing optimistic UI for event publishing
  • Handling OK/EOSE/CLOSED/NOTICE relay messages correctly
  • Building reconnection logic that doesn't lose events
  • Caching events locally for offline or fast-load scenarios
Do NOT use when:
  • Constructing event JSON structures (use nostr-event-builder)
  • Building relay server software (this is client-side patterns)
  • Working with NIP-19 encoding/decoding (bech32 concerns)
  • Designing subscription filters (use nostr-filter-designer)
  • 构建需要连接多个中继的Nostr客户端
  • 实现中继池管理(连接生命周期、退避策略)
  • 管理订阅状态(加载中/已生效、EOSE状态流转)
  • 对多个中继返回的事件做去重处理
  • 实现事件发布场景的乐观UI
  • 正确处理OK/EOSE/CLOSED/NOTICE中继消息
  • 构建不会丢失事件的重连逻辑
  • 本地缓存事件以实现离线使用或快速加载
不适用场景:
  • 构造事件JSON结构(请使用nostr-event-builder)
  • 构建中继服务器软件(本内容仅针对客户端侧模式)
  • 处理NIP-19编码/解码(属于bech32相关范畴)
  • 设计订阅过滤器(请使用nostr-filter-designer)

Workflow

工作流程

1. Design the Relay Pool

1. 设计中继池

A relay pool manages WebSocket connections to multiple relays. Each relay connection has a lifecycle that must be tracked independently.
Connection states:
disconnected → connecting → connected → disconnecting → disconnected
                    ↓                        ↑
                  failed ──(backoff)──→ connecting
Key rules:
  • One WebSocket per relay (NIP-01). Never open parallel connections to the same relay URL.
  • Normalize relay URLs before comparing: lowercase scheme/host, remove trailing slash, default port 443 for wss.
  • Track state per relay:
    { url, ws, state, retryCount, lastConnected, activeSubscriptions, pendingPublishes }
    .
  • Implement connection limits (e.g., max 10 concurrent connections).
  • Use NIP-65 relay lists (kind:10002) to determine which relays to connect to for each user. Write relays for fetching a user's events, read relays for fetching events that mention them.
typescript
interface RelayConnection {
  url: string;
  ws: WebSocket | null;
  state: "disconnected" | "connecting" | "connected" | "disconnecting";
  retryCount: number;
  lastConnectedAt: number | null;
  lastEoseTimestamps: Map<string, number>; // subId → timestamp
  authChallenge: string | null;
}
See references/relay-pool.md for full implementation patterns including backoff and NIP-42 auth.
中继池负责管理与多个中继的WebSocket连接,每个中继连接的生命周期需要独立追踪。
连接状态:
disconnected → connecting → connected → disconnecting → disconnected
                    ↓                        ↑
                  failed ──(backoff)──→ connecting
核心规则:
  • 每个中继仅建立一个WebSocket连接(遵循NIP-01),禁止对同一个中继URL建立并行连接。
  • 比较中继URL前先做标准化处理:协议/主机名转小写、移除末尾斜杠、wss协议默认端口为443。
  • 按中继追踪状态:
    { url, ws, state, retryCount, lastConnected, activeSubscriptions, pendingPublishes }
  • 实现连接数限制(例如最多同时建立10个连接)。
  • 使用NIP-65中继列表(kind:10002)为每个用户确定需要连接的中继:写中继用于拉取用户发布的事件,读中继用于拉取提及该用户的事件。
typescript
interface RelayConnection {
  url: string;
  ws: WebSocket | null;
  state: "disconnected" | "connecting" | "connected" | "disconnecting";
  retryCount: number;
  lastConnectedAt: number | null;
  lastEoseTimestamps: Map<string, number>; // subId → timestamp
  authChallenge: string | null;
}
查看 references/relay-pool.md 了解完整实现模式,包含退避策略和NIP-42认证逻辑。

2. Implement the Subscription Lifecycle

2. 实现订阅生命周期

Subscriptions follow a state machine with distinct phases. Getting this wrong causes either missing events or infinite loading states.
Subscription states:
idle → loading → live → closed
                  ↑       ↓
                  └─ replacing (new REQ with same sub-id)
The lifecycle:
  1. Open: Send
    ["REQ", "<sub-id>", <filters...>]
    to relay(s)
  2. Loading (stored events): Receive
    ["EVENT", "<sub-id>", <event>]
    for historical matches. UI shows loading indicator.
  3. EOSE received:
    ["EOSE", "<sub-id>"]
    — transition from "loading" to "live". Remove loading indicator, display stored events.
  4. Live events: Continue receiving EVENTs. These are new, real-time events. Display immediately.
  5. Close: Send
    ["CLOSE", "<sub-id>"]
    when the view unmounts or the subscription is no longer needed.
Critical transitions:
  • EOSE is per-relay. If subscribed to 5 relays, you get 5 EOSE messages. Track EOSE per relay per subscription. Transition to "live" when ALL relays have sent EOSE (or timed out).
  • Replacing: Send a new REQ with the same sub-id to change filters without closing. The relay replaces the old subscription. Reset EOSE tracking.
  • CLOSED from relay:
    ["CLOSED", "<sub-id>", "<reason>"]
    means the relay terminated your subscription. Handle by reason prefix:
    • auth-required:
      → authenticate with NIP-42, then re-subscribe
    • error:
      → log error, maybe retry after backoff
    • restricted:
      → user lacks permission, don't retry
  • Timeout: If a relay doesn't send EOSE within a reasonable time (e.g., 10s), treat it as EOSE for that relay to avoid infinite loading.
See references/subscription-patterns.md for state machine implementation and multi-relay coordination.
订阅遵循包含明确阶段的状态机,实现错误会导致事件丢失或无限加载状态。
订阅状态:
idle → loading → live → closed
                  ↑       ↓
                  └─ replacing (new REQ with same sub-id)
生命周期:
  1. 开启: 向中继发送
    ["REQ", "<sub-id>", <filters...>]
  2. 加载中(存储事件): 接收历史匹配的
    ["EVENT", "<sub-id>", <event>]
    ,UI展示加载指示器。
  3. 收到EOSE: 收到
    ["EOSE", "<sub-id>"]
    — 从「加载中」切换为「已生效」状态,移除加载指示器,展示已存储的事件。
  4. 实时事件: 持续接收EVENT消息,这些是新的实时事件,收到后立即展示。
  5. 关闭: 当视图卸载或不再需要订阅时,发送
    ["CLOSE", "<sub-id>"]
关键状态流转:
  • EOSE是中继维度的: 如果订阅了5个中继,会收到5条EOSE消息。需要按订阅+中继维度追踪EOSE,当所有中继都返回EOSE(或超时)时再切换为「已生效」状态。
  • 替换: 发送相同sub-id的新REQ请求,可以在不关闭订阅的前提下修改过滤器,中继会替换旧的订阅,需要重置EOSE追踪。
  • 收到中继返回的CLOSED:
    ["CLOSED", "<sub-id>", "<reason>"]
    表示中继终止了你的订阅,根据原因前缀处理:
    • auth-required:
      → 完成NIP-42认证后重新订阅
    • error:
      → 记录错误,可在退避后重试
    • restricted:
      → 用户无权限,无需重试
  • 超时: 如果中继在合理时间内(例如10秒)未返回EOSE,将该中继视为已返回EOSE,避免无限加载。
查看 references/subscription-patterns.md 了解状态机实现和多中继协调逻辑。

3. Deduplicate Events

3. 事件去重

The same event can arrive from multiple relays. Events have globally unique IDs (SHA-256 of serialized content), so deduplication is straightforward.
Regular events (kinds 1-9999 excluding replaceable):
typescript
const seen = new Set<string>();

function processEvent(event: NostrEvent): boolean {
  if (seen.has(event.id)) return false; // duplicate
  seen.add(event.id);
  // process event...
  return true;
}
Replaceable events (kinds 0, 3, 10000-19999):
Keep only the latest per
pubkey + kind
. When a newer event arrives, replace the old one. Break ties by lowest
id
(lexicographic comparison).
typescript
const replaceableKey = `${event.pubkey}:${event.kind}`;
const existing = replaceableStore.get(replaceableKey);
if (existing) {
  if (event.created_at < existing.created_at) return false;
  if (event.created_at === existing.created_at && event.id >= existing.id) {
    return false;
  }
}
replaceableStore.set(replaceableKey, event);
Addressable events (kinds 30000-39999):
Same as replaceable, but key includes the
d
tag value:
typescript
const dTag = event.tags.find((t) => t[0] === "d")?.[1] ?? "";
const addressableKey = `${event.pubkey}:${event.kind}:${dTag}`;
Memory management: Use an LRU cache or periodic cleanup for the
seen
set. In long-running clients, unbounded sets will leak memory.
同一个事件可能会从多个中继返回,事件具备全局唯一ID(序列化内容的SHA-256值),因此去重逻辑非常简单。
普通事件(类型1-9999,不含可替换事件):
typescript
const seen = new Set<string>();

function processEvent(event: NostrEvent): boolean {
  if (seen.has(event.id)) return false; // duplicate
  seen.add(event.id);
  // process event...
  return true;
}
可替换事件(类型0、3、10000-19999):
仅保留每个
pubkey + kind
对应的最新版本,当收到更新的事件时替换旧版本,时间戳相同时保留
id
更小的事件(字典序比较)。
typescript
const replaceableKey = `${event.pubkey}:${event.kind}`;
const existing = replaceableStore.get(replaceableKey);
if (existing) {
  if (event.created_at < existing.created_at) return false;
  if (event.created_at === existing.created_at && event.id >= existing.id) {
    return false;
  }
}
replaceableStore.set(replaceableKey, event);
可寻址事件(类型30000-39999):
逻辑与可替换事件一致,但key需要包含
d
标签的值:
typescript
const dTag = event.tags.find((t) => t[0] === "d")?.[1] ?? "";
const addressableKey = `${event.pubkey}:${event.kind}:${dTag}`;
内存管理:
seen
集合使用LRU缓存或定期清理机制,长时间运行的客户端中,无边界的集合会导致内存泄漏。

4. Implement Optimistic UI for Publishing

4. 实现发布场景的乐观UI

Show events immediately in the UI before relay confirmation. Handle failures gracefully.
The flow:
User action → Create event → Show in UI (optimistic) → Sign → Publish
                                                          Wait for OK
                                                         ↙          ↘
                                                   OK:true        OK:false
                                                   Confirm        Show error
                                                                  Allow retry
Implementation:
  1. Create the unsigned event from user input
  2. Add to local state with status
    "pending"
  3. Sign the event (NIP-07 browser extension or local key)
  4. Send
    ["EVENT", <signed-event>]
    to connected relays
  5. Track OK responses per relay:
    • ["OK", "<id>", true, ""]
      → mark relay as confirmed
    • ["OK", "<id>", true, "duplicate:"]
      → also success (relay already had it)
    • ["OK", "<id>", false, "reason"]
      → track failure reason
  6. Update UI status:
    • At least one
      true
      → status
      "confirmed"
    • All relays responded
      false
      → status
      "failed"
      , show error, allow retry
    • Timeout (e.g., 10s) with no OK → status
      "timeout"
      , allow retry
OK message reason prefixes:
PrefixMeaningAction
duplicate:
Already have itTreat as success
pow:
Proof of work issueAdd PoW and retry
blocked:
Client/user blockedShow error, don't retry
rate-limited:
Too many eventsBackoff and retry
invalid:
Protocol violationFix event and retry
restricted:
Permission deniedShow error, don't retry
auth-required:
Need NIP-42 auth firstAuthenticate, then retry
error:
General relay errorRetry after backoff
在收到中继确认前就将事件展示在UI中,优雅处理失败场景。
流程:
用户操作 → 创建事件 → 展示在UI中(乐观更新) → 签名 → 发布
                                                          等待OK响应
                                                         ↙          ↘
                                                   OK:true        OK:false
                                                   确认成功        展示错误
                                                                  允许重试
实现逻辑:
  1. 根据用户输入创建未签名事件
  2. 添加到本地状态,标记状态为
    "pending"
  3. 对事件签名(通过NIP-07浏览器扩展或本地密钥)
  4. 向已连接的中继发送
    ["EVENT", <signed-event>]
  5. 按中继追踪OK响应:
    • ["OK", "<id>", true, ""]
      → 标记该中继已确认
    • ["OK", "<id>", true, "duplicate:"]
      → 同样视为成功(中继已存储该事件)
    • ["OK", "<id>", false, "reason"]
      → 记录失败原因
  6. 更新UI状态:
    • 至少收到一个
      true
      响应 → 状态改为
      "confirmed"
    • 所有中继都返回
      false
      → 状态改为
      "failed"
      ,展示错误,允许重试
    • 超时(例如10秒)未收到OK响应 → 状态改为
      "timeout"
      ,允许重试
OK消息原因前缀:
前缀含义处理方式
duplicate:
中继已存储该事件视为成功
pow:
工作量证明不符合要求添加PoW后重试
blocked:
客户端/用户被封禁展示错误,无需重试
rate-limited:
事件发送频率过高退避后重试
invalid:
协议违规修复事件后重试
restricted:
无权限展示错误,无需重试
auth-required:
需要先完成NIP-42认证完成认证后重试
error:
中继通用错误退避后重试

5. Handle Reconnection

5. 处理重连

When a relay disconnects, reconnect without losing events or duplicating subscriptions.
Reconnection strategy:
  1. Detect disconnect (WebSocket
    close
    or
    error
    event)
  2. Set relay state to
    disconnected
  3. Calculate backoff:
    min(baseDelay * 2^retryCount + jitter, maxDelay)
    • Recommended: base=1s, max=60s, jitter=0-1s random
  4. After backoff, set state to
    connecting
    , open new WebSocket
  5. On successful connect:
    • Reset
      retryCount
      to 0
    • Re-authenticate if relay previously required NIP-42 auth
    • Re-send all active subscriptions with
      since
      parameter set to the last EOSE timestamp for that relay + subscription
  6. On failed connect: increment
    retryCount
    , go to step 3
Gap-free event delivery:
The key insight: track the
created_at
of the last event received before disconnect (or the EOSE timestamp). On reconnect, add
since: lastTimestamp
to the filter to fetch only events you missed. This avoids re-fetching the entire history.
typescript
function reconnectSubscription(
  relay: RelayConnection,
  subId: string,
  originalFilter: Filter,
) {
  const lastSeen = relay.lastEoseTimestamps.get(subId);
  const reconnectFilter = lastSeen
    ? { ...originalFilter, since: lastSeen }
    : originalFilter;
  relay.ws.send(JSON.stringify(["REQ", subId, reconnectFilter]));
}
当中继断开连接时,在不丢失事件、不重复订阅的前提下完成重连。
重连策略:
  1. 检测到断开连接(WebSocket
    close
    error
    事件)
  2. 将中继状态设为
    disconnected
  3. 计算退避时间:
    min(baseDelay * 2^retryCount + jitter, maxDelay)
    • 推荐配置:base=1s,max=60s,jitter=0-1s随机值
  4. 退避时间结束后,将状态设为
    connecting
    ,建立新的WebSocket连接
  5. 连接成功后:
    • retryCount
      重置为0
    • 如果中继之前要求NIP-42认证,重新完成认证
    • 重新发送所有活跃订阅,将
      since
      参数设为该中继+订阅对应的最后一次EOSE时间戳
  6. 连接失败后:增加
    retryCount
    ,回到步骤3
无缺口事件投递:
核心思路:记录断开连接前收到的最后一个事件的
created_at
(或EOSE时间戳),重连时在过滤器中添加
since: lastTimestamp
,仅拉取缺失的事件,避免重新拉取全部历史数据。
typescript
function reconnectSubscription(
  relay: RelayConnection,
  subId: string,
  originalFilter: Filter,
) {
  const lastSeen = relay.lastEoseTimestamps.get(subId);
  const reconnectFilter = lastSeen
    ? { ...originalFilter, since: lastSeen }
    : originalFilter;
  relay.ws.send(JSON.stringify(["REQ", subId, reconnectFilter]));
}

6. Cache Events Locally

6. 本地缓存事件

Reduce bandwidth and improve load times by caching events.
Cache strategies:
  • IndexedDB (browser): Store events by id, index by kind, pubkey, created_at. Good for offline-first clients.
  • SQLite (desktop/mobile): Same schema, better query performance.
  • In-memory LRU (ephemeral): For deduplication and short-term caching.
Cache-first loading pattern:
  1. Load cached events matching the filter → display immediately
  2. Open subscription with
    since: latestCachedTimestamp
  3. Merge new events into cache and UI
  4. On EOSE, cache is now up-to-date
For replaceable events: Only cache the latest version. When a newer version arrives, replace the cached entry.
通过缓存事件减少带宽占用,提升加载速度。
缓存策略:
  • IndexedDB(浏览器端):按ID存储事件,按类型、公钥、创建时间建索引,适合离线优先的客户端。
  • SQLite(桌面/移动端):相同的 schema,查询性能更好。
  • 内存LRU缓存(临时存储):用于去重和短期缓存。
缓存优先加载模式:
  1. 加载匹配过滤器的缓存事件 → 立即展示
  2. 开启订阅,设置
    since: latestCachedTimestamp
  3. 将新事件合并到缓存和UI中
  4. 收到EOSE后,缓存已更新到最新状态
可替换事件处理: 仅缓存最新版本,收到更新版本时替换缓存条目。

Checklist

检查清单

  • Relay pool tracks per-relay connection state with proper lifecycle
  • One WebSocket per relay URL (normalized)
  • Exponential backoff with jitter on reconnection
  • Subscriptions track EOSE per relay, transition loading → live correctly
  • CLOSED messages handled by reason prefix (auth, error, restricted)
  • Events deduplicated by id before processing
  • Replaceable events keep only latest (by created_at, then lowest id)
  • Optimistic UI shows events before relay confirmation
  • OK messages parsed with reason prefix for error handling
  • Reconnection re-subscribes with
    since
    to avoid gaps
  • Event cache used for faster initial loads
  • 中继池按中继追踪连接状态,具备完整的生命周期管理
  • 每个标准化后的中继URL仅建立一个WebSocket连接
  • 重连时使用带抖动的指数退避策略
  • 订阅按中继追踪EOSE,正确完成加载中→已生效的状态流转
  • 根据原因前缀处理CLOSED消息(认证、错误、权限限制)
  • 事件处理前先按ID去重
  • 可替换事件仅保留最新版本(按创建时间,时间戳相同时取ID更小的)
  • 乐观UI在收到中继确认前就展示事件
  • 解析OK消息的原因前缀做错误处理
  • 重连时携带
    since
    参数避免事件缺口
  • 使用事件缓存提升首次加载速度

Common Mistakes

常见错误

MistakeWhy It BreaksFix
Opening multiple WebSockets to same relayViolates NIP-01, wastes resources, causes duplicate eventsNormalize URL and enforce one connection per relay
Treating EOSE as global (not per-relay)Loading state never resolves if one relay is slowTrack EOSE per relay per subscription, use timeout fallback
No deduplication of eventsSame event processed multiple times, corrupts counts/UIDeduplicate by
event.id
using a Set before processing
Replacing events by
created_at
only
Tie-breaking is undefined without
id
comparison
On equal
created_at
, keep the event with the lowest
id
Showing "failed" on
duplicate:
OK
Duplicate means the relay already has it — that's successCheck the reason prefix, not just the boolean
Fixed retry delay (no backoff)Hammers relay during outages, may get IP-bannedUse exponential backoff:
min(base * 2^n + jitter, max)
Not re-authenticating after reconnectNIP-42 auth is per-connection, lost on disconnectStore challenge, re-send AUTH event after reconnect
Reconnecting without
since
filter
Re-fetches entire history, wastes bandwidthTrack last EOSE timestamp, use
since
on reconnect
Unbounded dedup SetMemory leak in long-running clientsUse LRU cache or periodic cleanup
Ignoring CLOSED messagesSubscription silently stops receiving eventsHandle CLOSED, re-subscribe if appropriate
错误影响修复方案
对同一个中继建立多个WebSocket连接违反NIP-01,浪费资源,导致重复事件标准化URL,强制每个中继仅建立一个连接
将EOSE视为全局状态而非中继维度的状态如果某个中继响应慢,加载状态永远不会结束按订阅+中继维度追踪EOSE,使用超时兜底
未做事件去重同一个事件被多次处理,导致计数/UI异常处理前用Set按
event.id
去重
仅按
created_at
判断可替换事件的版本
没有
id
比较的话,时间戳相同时版本取舍逻辑未定义
时间戳相同时保留
id
更小的事件
将带
duplicate:
前缀的OK视为失败
重复表示中继已存储该事件,属于成功场景检查原因前缀,而不是仅看布尔值
使用固定重试延迟(无退避)中继故障时频繁请求,可能导致IP被封禁使用指数退避:
min(base * 2^n + jitter, max)
重连后未重新认证NIP-42认证是连接维度的,断开连接后会失效存储挑战值,重连后重新发送AUTH事件
重连时未携带
since
过滤器
重新拉取全部历史数据,浪费带宽记录最后一次EOSE时间戳,重连时使用
since
参数
去重Set无大小限制长时间运行的客户端出现内存泄漏使用LRU缓存或定期清理
忽略CLOSED消息订阅静默停止接收事件处理CLOSED消息,必要时重新订阅

Quick Reference

快速参考

MessageDirectionFormatPurpose
REQ
Client→Relay
["REQ", subId, ...filters]
Subscribe to events
EVENT
(send)
Client→Relay
["EVENT", event]
Publish an event
CLOSE
Client→Relay
["CLOSE", subId]
End a subscription
AUTH
Client→Relay
["AUTH", signedEvent]
Authenticate (NIP-42)
EVENT
(recv)
Relay→Client
["EVENT", subId, event]
Deliver matching event
OK
Relay→Client
["OK", eventId, bool, msg]
Publish acknowledgment
EOSE
Relay→Client
["EOSE", subId]
End of stored events
CLOSED
Relay→Client
["CLOSED", subId, msg]
Subscription terminated
NOTICE
Relay→Client
["NOTICE", msg]
Human-readable info
AUTH
Relay→Client
["AUTH", challenge]
Auth challenge (NIP-42)
消息类型方向格式用途
REQ
客户端→中继
["REQ", subId, ...filters]
订阅事件
EVENT
(发送)
客户端→中继
["EVENT", event]
发布事件
CLOSE
客户端→中继
["CLOSE", subId]
结束订阅
AUTH
客户端→中继
["AUTH", signedEvent]
认证(NIP-42)
EVENT
(接收)
中继→客户端
["EVENT", subId, event]
投递匹配的事件
OK
中继→客户端
["OK", eventId, bool, msg]
发布确认
EOSE
中继→客户端
["EOSE", subId]
存储事件已拉取完毕
CLOSED
中继→客户端
["CLOSED", subId, msg]
订阅已终止
NOTICE
中继→客户端
["NOTICE", msg]
人类可读通知
AUTH
中继→客户端
["AUTH", challenge]
认证挑战(NIP-42)

Key Principles

核心原则

  1. One connection per relay — Normalize URLs and enforce a single WebSocket per relay. Multiple connections cause duplicate events, wasted bandwidth, and violate NIP-01.
  2. EOSE is the loading/live boundary — Before EOSE, you're receiving stored history. After EOSE, you're receiving live events. This distinction drives UI state (loading spinners, "new event" indicators).
  3. Deduplicate before processing — Events have globally unique IDs. Check the dedup set before any processing, state updates, or UI rendering. For replaceable events, also compare
    created_at
    and
    id
    for tie-breaking.
  4. Optimistic with recovery — Show events immediately, confirm via OK. Parse OK reason prefixes to distinguish retriable errors (rate-limited, auth) from permanent failures (blocked, restricted).
  5. Reconnect without gaps — Track the last-seen timestamp per relay per subscription. On reconnect, use
    since
    to fetch only missed events. Always re-authenticate and re-subscribe after reconnection.
  1. 每个中继仅一个连接 — 标准化URL,强制每个中继仅建立一个WebSocket连接,多连接会导致重复事件、带宽浪费,且违反NIP-01规范。
  2. EOSE是加载/实时状态的边界 — 收到EOSE前拉取的是存储的历史数据,收到EOSE后接收的是实时事件,这个区分是UI状态的核心依据(加载指示器、「新事件」提示)。
  3. 处理前先去重 — 事件具备全局唯一ID,在任何处理、状态更新、UI渲染前先检查去重集合。对于可替换事件,还需要比较
    created_at
    id
    做版本取舍。
  4. 乐观更新+错误恢复 — 立即展示事件,通过OK消息确认,解析OK原因前缀区分可重试错误(频率限制、需认证)和永久失败(被封禁、无权限)。
  5. 无缺口重连 — 按中继+订阅维度记录最后一次收到事件的时间戳,重连时使用
    since
    参数仅拉取缺失的事件,重连后始终重新完成认证和订阅。