nostr-client-patterns
Nostrクライアント構築に必要な、リレー接続管理、イベント重複排除、UI最適化、再接続戦略などを実装し、安定した通信と快適なユーザー体験を実現するSkill。
📜 元の英語説明(参考)
Implement Nostr client architecture including relay pool management, subscription lifecycle with EOSE/CLOSED handling, event deduplication, optimistic UI for publishing, and reconnection strategies. Use when building Nostr clients, managing WebSocket relay connections, handling subscription state machines, implementing event caches, or debugging relay communication issues like missed events or broken reconnections.
🇯🇵 日本人クリエイター向け解説
Nostrクライアント構築に必要な、リレー接続管理、イベント重複排除、UI最適化、再接続戦略などを実装し、安定した通信と快適なユーザー体験を実現するSkill。
※ jpskill.com 編集部が日本のビジネス現場向けに補足した解説です。Skill本体の挙動とは独立した参考情報です。
下記のコマンドをコピーしてターミナル(Mac/Linux)または PowerShell(Windows)に貼り付けてください。 ダウンロード → 解凍 → 配置まで全自動。
mkdir -p ~/.claude/skills && cd ~/.claude/skills && curl -L -o nostr-client-patterns.zip https://jpskill.com/download/9132.zip && unzip -o nostr-client-patterns.zip && rm nostr-client-patterns.zip
$d = "$env:USERPROFILE\.claude\skills"; ni -Force -ItemType Directory $d | Out-Null; iwr https://jpskill.com/download/9132.zip -OutFile "$d\nostr-client-patterns.zip"; Expand-Archive "$d\nostr-client-patterns.zip" -DestinationPath $d -Force; ri "$d\nostr-client-patterns.zip"
完了後、Claude Code を再起動 → 普通に「動画プロンプト作って」のように話しかけるだけで自動発動します。
💾 手動でダウンロードしたい(コマンドが難しい人向け)
- 1. 下の青いボタンを押して
nostr-client-patterns.zipをダウンロード - 2. ZIPファイルをダブルクリックで解凍 →
nostr-client-patternsフォルダができる - 3. そのフォルダを
C:\Users\あなたの名前\.claude\skills\(Win)または~/.claude/skills/(Mac)へ移動 - 4. Claude Code を再起動
⚠️ ダウンロード・利用は自己責任でお願いします。当サイトは内容・動作・安全性について責任を負いません。
🎯 このSkillでできること
下記の説明文を読むと、このSkillがあなたに何をしてくれるかが分かります。Claudeにこの分野の依頼をすると、自動で発動します。
📦 インストール方法 (3ステップ)
- 1. 上の「ダウンロード」ボタンを押して .skill ファイルを取得
- 2. ファイル名の拡張子を .skill から .zip に変えて展開(macは自動展開可)
- 3. 展開してできたフォルダを、ホームフォルダの
.claude/skills/に置く- · macOS / Linux:
~/.claude/skills/ - · Windows:
%USERPROFILE%\.claude\skills\
- · macOS / Linux:
Claude Code を再起動すれば完了。「このSkillを使って…」と話しかけなくても、関連する依頼で自動的に呼び出されます。
詳しい使い方ガイドを見る →- 最終更新
- 2026-05-18
- 取得日時
- 2026-05-18
- 同梱ファイル
- 1
📖 Skill本文(日本語訳)
※ 原文(英語/中国語)を Gemini で日本語化したものです。Claude 自身は原文を読みます。誤訳がある場合は原文をご確認ください。
Nostrクライアントパターン
概要
堅牢なNostrクライアントアーキテクチャを実装します。このスキルでは、エージェントが見落としがちなパターンを扱います。リレープールの接続管理、EOSE/CLOSEDトランジションを正しく処理するサブスクリプションステートマシン、リレーを跨いだイベントの重複排除、OKメッセージのエラー回復を伴う楽観的なUI、ギャップのないイベント配信による再接続などです。
使用する場面
- 複数のリレーに接続するNostrクライアントを構築する場合
- リレープールの管理(接続ライフサイクル、バックオフ)を実装する場合
- サブスクリプションの状態(ローディング対ライブ、EOSEトランジション)を管理する場合
- 複数のリレーから受信したイベントを重複排除する場合
- イベント公開のための楽観的なUIを実装する場合
- OK/EOSE/CLOSED/NOTICEリレーメッセージを正しく処理する場合
- イベントを失わない再接続ロジックを構築する場合
- オフラインまたは高速ロードのシナリオのためにイベントをローカルにキャッシュする場合
使用すべきでない場面:
- イベントのJSON構造を構築する場合(nostr-event-builderを使用)
- リレーサーバーソフトウェアを構築する場合(これはクライアント側のパターンです)
- NIP-19のエンコード/デコードを扱う場合(bech32に関する懸念)
- サブスクリプションフィルタを設計する場合(nostr-filter-designerを使用)
ワークフロー
1. リレープールの設計
リレープールは、複数のリレーへのWebSocket接続を管理します。各リレー接続には、個別に追跡する必要があるライフサイクルがあります。
接続状態:
disconnected → connecting → connected → disconnecting → disconnected
↓ ↑
failed ──(backoff)──→ connecting
重要なルール:
- リレーごとに1つのWebSocket (NIP-01)。同じリレーURLへの並列接続は絶対に開かないでください。
- 比較する前にリレーURLを正規化します。スキーム/ホストを小文字にし、末尾のスラッシュを削除し、wssの場合はデフォルトポート443を使用します。
- リレーごとに状態を追跡します。
{ url, ws, state, retryCount, lastConnected, activeSubscriptions, pendingPublishes }。 - 接続制限を実装します(例:最大10の同時接続)。
- NIP-65リレーリスト(kind:10002)を使用して、各ユーザーが接続するリレーを決定します。ユーザーのイベントを取得するための書き込みリレー、ユーザーに言及するイベントを取得するための読み取りリレー。
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;
}
バックオフとNIP-42認証を含む完全な実装パターンについては、references/relay-pool.mdを参照してください。
2. サブスクリプションライフサイクルの実装
サブスクリプションは、明確なフェーズを持つステートマシンに従います。これを間違えると、イベントが欠落したり、無限のローディング状態になったりします。
サブスクリプションの状態:
idle → loading → live → closed
↑ ↓
└─ replacing (同じsub-idでの新しいREQ)
ライフサイクル:
- Open:
["REQ", "<sub-id>", <filters...>]をリレーに送信します。 - Loading (保存されたイベント): 履歴に一致する
["EVENT", "<sub-id>", <event>]を受信します。UIにローディングインジケーターが表示されます。 - EOSE受信:
["EOSE", "<sub-id>"]— "loading"から"live"に移行します。ローディングインジケーターを削除し、保存されたイベントを表示します。 - ライブイベント: EVENTの受信を継続します。これらは新しいリアルタイムイベントです。すぐに表示します。
- Close: ビューがアンマウントされたとき、またはサブスクリプションが不要になったときに、
["CLOSE", "<sub-id>"]を送信します。
重要なトランジション:
- EOSEはリレーごとです。 5つのリレーにサブスクライブしている場合、5つのEOSEメッセージを受信します。リレーごとに、サブスクリプションごとにEOSEを追跡します。すべてのリレーがEOSEを送信した(またはタイムアウトした)ときに、"live"に移行します。
- Replacing: 閉じずにフィルタを変更するために、同じsub-idで新しいREQを送信します。リレーは古いサブスクリプションを置き換えます。EOSEトラッキングをリセットします。
- リレーからのCLOSED:
["CLOSED", "<sub-id>", "<reason>"]は、リレーがサブスクリプションを終了したことを意味します。理由のプレフィックスで処理します。auth-required:→ NIP-42で認証し、再度サブスクライブしますerror:→ エラーをログに記録し、バックオフ後に再試行する可能性がありますrestricted:→ ユーザーに権限がないため、再試行しないでください
- Timeout: リレーが妥当な時間(例:10秒)以内にEOSEを送信しない場合は、無限のローディングを避けるために、そのリレーに対してEOSEとして扱います。
ステートマシンの実装とマルチリレーの連携については、references/subscription-patterns.mdを参照してください。
3. イベントの重複排除
同じイベントが複数のリレーから到着する可能性があります。イベントにはグローバルに一意なID(シリアル化されたコンテンツのSHA-256)があるため、重複排除は簡単です。
通常のイベント (kinds 1-9999, replaceableを除く):
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イベント (kinds 0, 3, 10000-19999):
pubkey + kindごとに最新のもののみを保持します。新しいイベントが到着したら、古いイベントを置き換えます。タイブレークは、最も低いid(辞書式比較)で行います。
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イベント (kinds 30000-39999):
replaceableと同じですが、キーにdタグの値が含まれます。
const dTag = event.tags.find((t) => t[0] === "d")?.[1] ?? "";
const addressableKey = `${event.pubkey}:${event.kind}:${dTag}`;
メモリ管理: seenセットには、LRUキャッシュまたは定期的なクリーンアップを使用します。長期間実行されるクライアントでは、上限のないセットはメモリリークを引き起こします。
4. 公開のための楽観的なUIの実装
リレーの確認前に、UIにイベントをすぐに表示します。エラーを適切に処理します。
フロー:
ユーザーアクション → イベントの作成 → UIに表示 (optimis 📜 原文 SKILL.md(Claudeが読む英語/中国語)を展開
Nostr Client Patterns
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.
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)
Workflow
1. Design the Relay Pool
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.
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.
2. Implement the Subscription Lifecycle
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
["REQ", "<sub-id>", <filters...>]to relay(s) - Loading (stored events): Receive
["EVENT", "<sub-id>", <event>]for historical matches. UI shows loading indicator. - EOSE received:
["EOSE", "<sub-id>"]— transition from "loading" to "live". Remove loading indicator, display stored events. - Live events: Continue receiving EVENTs. These are new, real-time events. Display immediately.
- 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-subscribeerror:→ log error, maybe retry after backoffrestricted:→ 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.
3. Deduplicate Events
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):
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).
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:
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.
4. Implement Optimistic UI for Publishing
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:
- 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
["EVENT", <signed-event>]to connected relays - 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
- 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
- At least one
OK message reason prefixes:
| Prefix | Meaning | Action |
|---|---|---|
duplicate: |
Already have it | Treat as success |
pow: |
Proof of work issue | Add PoW and retry |
blocked: |
Client/user blocked | Show error, don't retry |
rate-limited: |
Too many events | Backoff and retry |
invalid: |
Protocol violation | Fix event and retry |
restricted: |
Permission denied | Show error, don't retry |
auth-required: |
Need NIP-42 auth first | Authenticate, then retry |
error: |
General relay error | Retry after backoff |
5. Handle Reconnection
When a relay disconnects, reconnect without losing events or duplicating subscriptions.
Reconnection strategy:
- Detect disconnect (WebSocket
closeorerrorevent) - 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
connecting, open new WebSocket - On successful connect:
- Reset
retryCountto 0 - Re-authenticate if relay previously required NIP-42 auth
- Re-send all active subscriptions with
sinceparameter set to the last EOSE timestamp for that relay + subscription
- Reset
- 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.
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
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.
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
sinceto avoid gaps - [ ] Event cache used for faster initial loads
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 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 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: min(base * 2^n + jitter, max) |
| Not re-authenticating after reconnect | NIP-42 auth is per-connection, lost on disconnect | Store challenge, re-send AUTH event after reconnect |
Reconnecting without since filter |
Re-fetches entire history, wastes bandwidth | Track last EOSE timestamp, use since on reconnect |
| 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 |
Quick Reference
| Message | Direction | Format | Purpose |
|---|---|---|---|
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) |
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 compare
created_atandidfor tie-breaking. -
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, use
sinceto fetch only missed events. Always re-authenticate and re-subscribe after reconnection.