jpskill.com
🛠️ 開発・MCP コミュニティ

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本体の挙動とは独立した参考情報です。

⚡ おすすめ: コマンド1行でインストール(60秒)

下記のコマンドをコピーしてターミナル(Mac/Linux)または PowerShell(Windows)に貼り付けてください。 ダウンロード → 解凍 → 配置まで全自動。

🍎 Mac / 🐧 Linux
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
🪟 Windows (PowerShell)
$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. 1. 下の青いボタンを押して nostr-client-patterns.zip をダウンロード
  2. 2. ZIPファイルをダブルクリックで解凍 → nostr-client-patterns フォルダができる
  3. 3. そのフォルダを C:\Users\あなたの名前\.claude\skills\(Win)または ~/.claude/skills/(Mac)へ移動
  4. 4. Claude Code を再起動

⚠️ ダウンロード・利用は自己責任でお願いします。当サイトは内容・動作・安全性について責任を負いません。

🎯 このSkillでできること

下記の説明文を読むと、このSkillがあなたに何をしてくれるかが分かります。Claudeにこの分野の依頼をすると、自動で発動します。

📦 インストール方法 (3ステップ)

  1. 1. 上の「ダウンロード」ボタンを押して .skill ファイルを取得
  2. 2. ファイル名の拡張子を .skill から .zip に変えて展開(macは自動展開可)
  3. 3. 展開してできたフォルダを、ホームフォルダの .claude/skills/ に置く
    • · macOS / Linux: ~/.claude/skills/
    • · Windows: %USERPROFILE%\.claude\skills\

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)

ライフサイクル:

  1. Open: ["REQ", "<sub-id>", <filters...>]をリレーに送信します。
  2. Loading (保存されたイベント): 履歴に一致する["EVENT", "<sub-id>", <event>]を受信します。UIにローディングインジケーターが表示されます。
  3. EOSE受信: ["EOSE", "<sub-id>"] — "loading"から"live"に移行します。ローディングインジケーターを削除し、保存されたイベントを表示します。
  4. ライブイベント: EVENTの受信を継続します。これらは新しいリアルタイムイベントです。すぐに表示します。
  5. 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:

  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.

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:

  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:

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:

  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.

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:

  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.

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

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

  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.