nostr-client-patterns
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseNostr 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)──→ connectingKey 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:
- Open: Send to relay(s)
["REQ", "<sub-id>", <filters...>] - Loading (stored events): Receive for historical matches. UI shows loading indicator.
["EVENT", "<sub-id>", <event>] - EOSE received: — transition from "loading" to "live". Remove loading indicator, display stored events.
["EOSE", "<sub-id>"] - Live events: Continue receiving EVENTs. These are new, real-time events. Display immediately.
- Close: Send when the view unmounts or the subscription is no longer needed.
["CLOSE", "<sub-id>"]
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: means the relay terminated your subscription. Handle by reason prefix:
["CLOSED", "<sub-id>", "<reason>"]- → authenticate with NIP-42, then re-subscribe
auth-required: - → log error, maybe retry after backoff
error: - → user lacks permission, don't retry
restricted:
- 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)生命周期:
- 开启: 向中继发送
["REQ", "<sub-id>", <filters...>] - 加载中(存储事件): 接收历史匹配的 ,UI展示加载指示器。
["EVENT", "<sub-id>", <event>] - 收到EOSE: 收到 — 从「加载中」切换为「已生效」状态,移除加载指示器,展示已存储的事件。
["EOSE", "<sub-id>"] - 实时事件: 持续接收EVENT消息,这些是新的实时事件,收到后立即展示。
- 关闭: 当视图卸载或不再需要订阅时,发送 。
["CLOSE", "<sub-id>"]
关键状态流转:
- EOSE是中继维度的: 如果订阅了5个中继,会收到5条EOSE消息。需要按订阅+中继维度追踪EOSE,当所有中继都返回EOSE(或超时)时再切换为「已生效」状态。
- 替换: 发送相同sub-id的新REQ请求,可以在不关闭订阅的前提下修改过滤器,中继会替换旧的订阅,需要重置EOSE追踪。
- 收到中继返回的CLOSED: 表示中继终止了你的订阅,根据原因前缀处理:
["CLOSED", "<sub-id>", "<reason>"]- → 完成NIP-42认证后重新订阅
auth-required: - → 记录错误,可在退避后重试
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 . When a newer event arrives, replace
the old one. Break ties by lowest (lexicographic comparison).
pubkey + kindidtypescript
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 tag value:
dtypescript
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 set.
In long-running clients, unbounded sets will leak memory.
seen同一个事件可能会从多个中继返回,事件具备全局唯一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 + kindidtypescript
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需要包含标签的值:
dtypescript
const dTag = event.tags.find((t) => t[0] === "d")?.[1] ?? "";
const addressableKey = `${event.pubkey}:${event.kind}:${dTag}`;内存管理: 对集合使用LRU缓存或定期清理机制,长时间运行的客户端中,无边界的集合会导致内存泄漏。
seen4. 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 retryImplementation:
- Create the unsigned event from user input
- Add to local state with status
"pending" - Sign the event (NIP-07 browser extension or local key)
- Send to connected relays
["EVENT", <signed-event>] - Track OK responses per relay:
- → mark relay as confirmed
["OK", "<id>", true, ""] - → also success (relay already had it)
["OK", "<id>", true, "duplicate:"] - → track failure reason
["OK", "<id>", false, "reason"]
- Update UI status:
- At least one → status
true"confirmed" - All relays responded → status
false, show error, allow retry"failed" - Timeout (e.g., 10s) with no OK → status , allow retry
"timeout"
- At least one
OK message reason prefixes:
| Prefix | Meaning | Action |
|---|---|---|
| Already have it | Treat as success |
| Proof of work issue | Add PoW and retry |
| Client/user blocked | Show error, don't retry |
| Too many events | Backoff and retry |
| Protocol violation | Fix event and retry |
| Permission denied | Show error, don't retry |
| Need NIP-42 auth first | Authenticate, then retry |
| General relay error | Retry after backoff |
在收到中继确认前就将事件展示在UI中,优雅处理失败场景。
流程:
用户操作 → 创建事件 → 展示在UI中(乐观更新) → 签名 → 发布
↓
等待OK响应
↙ ↘
OK:true OK:false
确认成功 展示错误
允许重试实现逻辑:
- 根据用户输入创建未签名事件
- 添加到本地状态,标记状态为
"pending" - 对事件签名(通过NIP-07浏览器扩展或本地密钥)
- 向已连接的中继发送
["EVENT", <signed-event>] - 按中继追踪OK响应:
- → 标记该中继已确认
["OK", "<id>", true, ""] - → 同样视为成功(中继已存储该事件)
["OK", "<id>", true, "duplicate:"] - → 记录失败原因
["OK", "<id>", false, "reason"]
- 更新UI状态:
- 至少收到一个响应 → 状态改为
true"confirmed" - 所有中继都返回→ 状态改为
false,展示错误,允许重试"failed" - 超时(例如10秒)未收到OK响应 → 状态改为,允许重试
"timeout"
- 至少收到一个
OK消息原因前缀:
| 前缀 | 含义 | 处理方式 |
|---|---|---|
| 中继已存储该事件 | 视为成功 |
| 工作量证明不符合要求 | 添加PoW后重试 |
| 客户端/用户被封禁 | 展示错误,无需重试 |
| 事件发送频率过高 | 退避后重试 |
| 协议违规 | 修复事件后重试 |
| 无权限 | 展示错误,无需重试 |
| 需要先完成NIP-42认证 | 完成认证后重试 |
| 中继通用错误 | 退避后重试 |
5. Handle Reconnection
5. 处理重连
When a relay disconnects, reconnect without losing events or duplicating
subscriptions.
Reconnection strategy:
- Detect disconnect (WebSocket or
closeevent)error - Set relay state to
disconnected - Calculate backoff:
min(baseDelay * 2^retryCount + jitter, maxDelay)- Recommended: base=1s, max=60s, jitter=0-1s random
- After backoff, set state to , open new WebSocket
connecting - On successful connect:
- Reset to 0
retryCount - Re-authenticate if relay previously required NIP-42 auth
- Re-send all active subscriptions with parameter set to the last EOSE timestamp for that relay + subscription
since
- Reset
- On failed connect: increment , go to step 3
retryCount
Gap-free event delivery:
The key insight: track the of the last event received before
disconnect (or the EOSE timestamp). On reconnect, add to
the filter to fetch only events you missed. This avoids re-fetching the entire
history.
created_atsince: lastTimestamptypescript
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]));
}当中继断开连接时,在不丢失事件、不重复订阅的前提下完成重连。
重连策略:
- 检测到断开连接(WebSocket 或
close事件)error - 将中继状态设为
disconnected - 计算退避时间:
min(baseDelay * 2^retryCount + jitter, maxDelay)- 推荐配置:base=1s,max=60s,jitter=0-1s随机值
- 退避时间结束后,将状态设为,建立新的WebSocket连接
connecting - 连接成功后:
- 将重置为0
retryCount - 如果中继之前要求NIP-42认证,重新完成认证
- 重新发送所有活跃订阅,将参数设为该中继+订阅对应的最后一次EOSE时间戳
since
- 将
- 连接失败后:增加,回到步骤3
retryCount
无缺口事件投递:
核心思路:记录断开连接前收到的最后一个事件的(或EOSE时间戳),重连时在过滤器中添加,仅拉取缺失的事件,避免重新拉取全部历史数据。
created_atsince: lastTimestamptypescript
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:
- Load cached events matching the filter → display immediately
- Open subscription with
since: latestCachedTimestamp - Merge new events into cache and UI
- 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缓存(临时存储):用于去重和短期缓存。
缓存优先加载模式:
- 加载匹配过滤器的缓存事件 → 立即展示
- 开启订阅,设置
since: latestCachedTimestamp - 将新事件合并到缓存和UI中
- 收到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 to avoid gaps
since - Event cache used for faster initial loads
- 中继池按中继追踪连接状态,具备完整的生命周期管理
- 每个标准化后的中继URL仅建立一个WebSocket连接
- 重连时使用带抖动的指数退避策略
- 订阅按中继追踪EOSE,正确完成加载中→已生效的状态流转
- 根据原因前缀处理CLOSED消息(认证、错误、权限限制)
- 事件处理前先按ID去重
- 可替换事件仅保留最新版本(按创建时间,时间戳相同时取ID更小的)
- 乐观UI在收到中继确认前就展示事件
- 解析OK消息的原因前缀做错误处理
- 重连时携带参数避免事件缺口
since - 使用事件缓存提升首次加载速度
Common Mistakes
常见错误
| Mistake | Why It Breaks | Fix |
|---|---|---|
| Opening multiple WebSockets to same relay | Violates NIP-01, wastes resources, causes duplicate events | Normalize URL and enforce one connection per relay |
| Treating EOSE as global (not per-relay) | Loading state never resolves if one relay is slow | Track EOSE per relay per subscription, use timeout fallback |
| No deduplication of events | Same event processed multiple times, corrupts counts/UI | Deduplicate by |
Replacing events by | Tie-breaking is undefined without | On equal |
Showing "failed" on | Duplicate means the relay already has it — that's success | Check the reason prefix, not just the boolean |
| Fixed retry delay (no backoff) | Hammers relay during outages, may get IP-banned | Use exponential backoff: |
| Not re-authenticating after reconnect | NIP-42 auth is per-connection, lost on disconnect | Store challenge, re-send AUTH event after reconnect |
Reconnecting without | Re-fetches entire history, wastes bandwidth | Track last EOSE timestamp, use |
| Unbounded dedup Set | Memory leak in long-running clients | Use LRU cache or periodic cleanup |
| Ignoring CLOSED messages | Subscription silently stops receiving events | Handle CLOSED, re-subscribe if appropriate |
| 错误 | 影响 | 修复方案 |
|---|---|---|
| 对同一个中继建立多个WebSocket连接 | 违反NIP-01,浪费资源,导致重复事件 | 标准化URL,强制每个中继仅建立一个连接 |
| 将EOSE视为全局状态而非中继维度的状态 | 如果某个中继响应慢,加载状态永远不会结束 | 按订阅+中继维度追踪EOSE,使用超时兜底 |
| 未做事件去重 | 同一个事件被多次处理,导致计数/UI异常 | 处理前用Set按 |
仅按 | 没有 | 时间戳相同时保留 |
将带 | 重复表示中继已存储该事件,属于成功场景 | 检查原因前缀,而不是仅看布尔值 |
| 使用固定重试延迟(无退避) | 中继故障时频繁请求,可能导致IP被封禁 | 使用指数退避: |
| 重连后未重新认证 | NIP-42认证是连接维度的,断开连接后会失效 | 存储挑战值,重连后重新发送AUTH事件 |
重连时未携带 | 重新拉取全部历史数据,浪费带宽 | 记录最后一次EOSE时间戳,重连时使用 |
| 去重Set无大小限制 | 长时间运行的客户端出现内存泄漏 | 使用LRU缓存或定期清理 |
| 忽略CLOSED消息 | 订阅静默停止接收事件 | 处理CLOSED消息,必要时重新订阅 |
Quick Reference
快速参考
| Message | Direction | Format | Purpose |
|---|---|---|---|
| Client→Relay | | Subscribe to events |
| Client→Relay | | Publish an event |
| Client→Relay | | End a subscription |
| Client→Relay | | Authenticate (NIP-42) |
| Relay→Client | | Deliver matching event |
| Relay→Client | | Publish acknowledgment |
| Relay→Client | | End of stored events |
| Relay→Client | | Subscription terminated |
| Relay→Client | | Human-readable info |
| Relay→Client | | Auth challenge (NIP-42) |
| 消息类型 | 方向 | 格式 | 用途 |
|---|---|---|---|
| 客户端→中继 | | 订阅事件 |
| 客户端→中继 | | 发布事件 |
| 客户端→中继 | | 结束订阅 |
| 客户端→中继 | | 认证(NIP-42) |
| 中继→客户端 | | 投递匹配的事件 |
| 中继→客户端 | | 发布确认 |
| 中继→客户端 | | 存储事件已拉取完毕 |
| 中继→客户端 | | 订阅已终止 |
| 中继→客户端 | | 人类可读通知 |
| 中继→客户端 | | 认证挑战(NIP-42) |
Key Principles
核心原则
-
One connection per relay — Normalize URLs and enforce a single WebSocket per relay. Multiple connections cause duplicate events, wasted bandwidth, and violate NIP-01.
-
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).
-
Deduplicate before processing — Events have globally unique IDs. Check the dedup set before any processing, state updates, or UI rendering. For replaceable events, also compareand
created_atfor tie-breaking.id -
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).
-
Reconnect without gaps — Track the last-seen timestamp per relay per subscription. On reconnect, useto fetch only missed events. Always re-authenticate and re-subscribe after reconnection.
since
-
每个中继仅一个连接 — 标准化URL,强制每个中继仅建立一个WebSocket连接,多连接会导致重复事件、带宽浪费,且违反NIP-01规范。
-
EOSE是加载/实时状态的边界 — 收到EOSE前拉取的是存储的历史数据,收到EOSE后接收的是实时事件,这个区分是UI状态的核心依据(加载指示器、「新事件」提示)。
-
处理前先去重 — 事件具备全局唯一ID,在任何处理、状态更新、UI渲染前先检查去重集合。对于可替换事件,还需要比较和
created_at做版本取舍。id -
乐观更新+错误恢复 — 立即展示事件,通过OK消息确认,解析OK原因前缀区分可重试错误(频率限制、需认证)和永久失败(被封禁、无权限)。
-
无缺口重连 — 按中继+订阅维度记录最后一次收到事件的时间戳,重连时使用参数仅拉取缺失的事件,重连后始终重新完成认证和订阅。
since