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

nostr-relay-builder

Nostrリレーの構築に必要なWebSocket処理、イベント検証、フィルター処理、購読管理、NIP拡張などを一から実装し、Nostrプロトコルに準拠したリレー開発を支援するSkill。

📜 元の英語説明(参考)

Build a Nostr relay from scratch with WebSocket handling, NIP-01 event validation (id computation, Schnorr signature verification), filter matching, subscription management, and progressive NIP support (NIP-11, NIP-09, NIP-42, NIP-50). Use when building a Nostr relay, implementing the Nostr relay protocol, handling Nostr WebSocket connections, validating Nostr events, matching Nostr filters, or adding NIP support to a relay. Also use when the user mentions relay development, nostr server, event storage, or subscription handling.

🇯🇵 日本人クリエイター向け解説

一言でいうと

Nostrリレーの構築に必要なWebSocket処理、イベント検証、フィルター処理、購読管理、NIP拡張などを一から実装し、Nostrプロトコルに準拠したリレー開発を支援するSkill。

※ jpskill.com 編集部が日本のビジネス現場向けに補足した解説です。Skill本体の挙動とは独立した参考情報です。

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

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

🍎 Mac / 🐧 Linux
mkdir -p ~/.claude/skills && cd ~/.claude/skills && curl -L -o nostr-relay-builder.zip https://jpskill.com/download/9140.zip && unzip -o nostr-relay-builder.zip && rm nostr-relay-builder.zip
🪟 Windows (PowerShell)
$d = "$env:USERPROFILE\.claude\skills"; ni -Force -ItemType Directory $d | Out-Null; iwr https://jpskill.com/download/9140.zip -OutFile "$d\nostr-relay-builder.zip"; Expand-Archive "$d\nostr-relay-builder.zip" -DestinationPath $d -Force; ri "$d\nostr-relay-builder.zip"

完了後、Claude Code を再起動 → 普通に「動画プロンプト作って」のように話しかけるだけで自動発動します。

💾 手動でダウンロードしたい(コマンドが難しい人向け)
  1. 1. 下の青いボタンを押して nostr-relay-builder.zip をダウンロード
  2. 2. ZIPファイルをダブルクリックで解凍 → nostr-relay-builder フォルダができる
  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 Relay Builder

Nostrリレーをゼロから構築します。WebSocketサーバー、NIP-01メッセージプロトコル、イベント検証(id + signature)、フィルターマッチング、サブスクリプション管理、および段階的なNIPサポートが含まれます。

概要

Nostrリレーは、イベントを受信、検証、保存、および配信するWebSocketサーバーです。このスキルでは、必須のNIP-01プロトコルから始めて、オプションのNIPを段階的に追加しながら、ステップバイステップでリレーを構築する方法を説明します。

どのような時に使うか

  • Nostrリレーをゼロから構築する場合
  • 既存のリレーにNIPサポートを追加する場合
  • イベント検証(id計算、署名検証)を実装する場合
  • Nostrサブスクリプションのフィルターマッチングロジックを構築する場合
  • NostrプロトコルのWebSocket接続を処理する場合
  • 置換可能/アドレス指定可能なイベントストレージを実装する場合

使用すべきでない場合:

  • Nostrクライアントを構築する場合(代わりに nostr-client-patterns を使用してください)
  • イベントの作成/署名のみを扱う場合(代わりに nostr-event-builder を使用してください)
  • NIP-05検証を設定する場合(代わりに nostr-nip05-setup を使用してください)

ワークフロー

1. WebSocketサーバーのセットアップ

接続を受け入れるWebSocketエンドポイントを作成します。リレーは以下を満たす必要があります。

  • ルートパスでWebSocketアップグレードリクエストを受け入れる
  • 複数の同時接続を処理する
  • 接続ごとの状態(サブスクリプション、認証ステータス)を追跡する
  • 接続の健全性のためにping/pongを実装する
  • 受信メッセージをJSON配列として解析する
// Bunの例 — 最小限のWebSocketサーバー
Bun.serve({
  port: 3000,
  fetch(req, server) {
    // NIP-11: Accept: application/nostr+json を持つ HTTP GET でリレー情報を提供する
    if (req.headers.get("Accept") === "application/nostr+json") {
      return Response.json(relayInfo, {
        headers: {
          "Access-Control-Allow-Origin": "*",
          "Access-Control-Allow-Headers": "Accept",
          "Access-Control-Allow-Methods": "GET",
        },
      });
    }
    if (server.upgrade(req)) return;
    return new Response("Connect via WebSocket", { status: 400 });
  },
  websocket: {
    open(ws) {
      ws.data = { subscriptions: new Map() };
    },
    message(ws, raw) {
      handleMessage(ws, JSON.parse(raw));
    },
    close(ws) {/* サブスクリプションのクリーンアップ */},
  },
});

2. NIP-01メッセージプロトコルの実装

3つのクライアントメッセージタイプを処理し、5つのリレーメッセージタイプで応答します。完全な形式のリファレンスについては、references/message-protocol.mdを参照してください。

function handleMessage(ws, msg: unknown[]) {
  const verb = msg[0];
  switch (verb) {
    case "EVENT":
      return handleEvent(ws, msg[1]);
    case "REQ":
      return handleReq(ws, msg[1], msg.slice(2));
    case "CLOSE":
      return handleClose(ws, msg[1]);
    default:
      return send(ws, ["NOTICE", `unknown message type: ${verb}`]);
  }
}

重要なルール:

  • EVENT → 常に OK (true/false + メッセージ) で応答する
  • REQ → 一致する保存されたイベントを送信し、次に EOSE を送信し、次に新しい一致をストリーミングする
  • CLOSE → サブスクリプションを削除する。応答は不要
  • サブスクリプションIDは接続ごと、最大64文字、空でない文字列
  • 既存のサブスクリプションIDを持つ新しい REQ は、古いサブスクリプションを置き換える

3. イベント検証の実装

受信したすべてのイベントは、保存する前に検証する必要があります。references/event-validation.mdのチェックリストに従ってください。2つの重要なチェック:

ID検証 — 再計算して比較します。

import { sha256 } from "@noble/hashes/sha256";
import { bytesToHex } from "@noble/hashes/utils";

function computeEventId(event): string {
  const serialized = JSON.stringify([
    0,
    event.pubkey,
    event.created_at,
    event.kind,
    event.tags,
    event.content,
  ]);
  return bytesToHex(sha256(new TextEncoder().encode(serialized)));
}

署名検証 — secp256k1上のSchnorr:

import { schnorr } from "@noble/curves/secp256k1";

function verifySignature(event): boolean {
  return schnorr.verify(event.sig, event.id, event.pubkey);
}

検証に失敗した場合は、["OK", event.id, false, "invalid: <reason>"] で応答します。

4. フィルターマッチングの実装

フィルターは、どのイベントがサブスクリプションに一致するかを決定します。ロジックは次のとおりです。

  • 単一のフィルター内: 指定されたすべての条件が一致する必要がある(AND)
  • REQ内の複数のフィルター間: 一致するフィルターがあれば十分(OR)
  • リストフィールド (ids, authors, kinds, #tags): イベント値はリストに含まれている必要がある(フィールド内でOR)
function matchesFilter(event, filter): boolean {
  if (filter.ids && !filter.ids.includes(event.id)) return false;
  if (filter.authors && !filter.authors.includes(event.pubkey)) return false;
  if (filter.kinds && !filter.kinds.includes(event.kind)) return false;
  if (filter.since && event.created_at < filter.since) return false;
  if (filter.until && event.created_at > filter.until) return false;

  // タグフィルター: #e, #p, #a, など
  for (const [key, values] of Object.entries(filter)) {
    if (key.startsWith("#") && key.length === 2) {
      const tagName = key.slice(1);
      const eventTagValues = event.tags
        .filter((t) => t[0] === tagName)
        .map((t) => t[1]);
      if (!values.some((v) => eventTagValues.includes(v))) return false;
    }
  }
  return true;
}

limit の処理: 初期クエリにのみ適用されます(ストリーミングには適用されません)。最新の limit イベントを、created_at の降順で返します。タイの場合、最初に最も低い id (辞書順)。

5. 種類に基づいたルールによるイベントストレージの実装

種類範囲が異なると、ストレージセマンティクスが異なります。

Kind Range Type Storage Rule
1, 2, 4-44, 1000-9999 Regular すべてのイベントを保存する
0, 3, 10000-19999 Replaceable pubkey + kind ごとに最新のもののみを保持する
20000-29999 Ephemeral 保存しないでください。ブロードキャスト

(原文がここで切り詰められています)

📜 原文 SKILL.md(Claudeが読む英語/中国語)を展開

Nostr Relay Builder

Build a Nostr relay from scratch: WebSocket server, NIP-01 message protocol, event validation (id + signature), filter matching, subscription management, and progressive NIP support.

Overview

A Nostr relay is a WebSocket server that receives, validates, stores, and distributes events. This skill walks through building one step by step, starting with the mandatory NIP-01 protocol and progressively adding optional NIPs.

When to use

  • When building a Nostr relay from scratch
  • When adding NIP support to an existing relay
  • When implementing event validation (id computation, signature verification)
  • When building filter matching logic for Nostr subscriptions
  • When handling WebSocket connections for the Nostr protocol
  • When implementing replaceable/addressable event storage

Do NOT use when:

  • Building a Nostr client (use nostr-client-patterns instead)
  • Working only with event creation/signing (use nostr-event-builder instead)
  • Setting up NIP-05 verification (use nostr-nip05-setup instead)

Workflow

1. Set up the WebSocket server

Create a WebSocket endpoint that accepts connections. The relay MUST:

  • Accept WebSocket upgrade requests on the root path
  • Handle multiple concurrent connections
  • Track per-connection state (subscriptions, auth status)
  • Implement ping/pong for connection health
  • Parse incoming messages as JSON arrays
// Bun example — minimal WebSocket server
Bun.serve({
  port: 3000,
  fetch(req, server) {
    // NIP-11: serve relay info on HTTP GET with Accept: application/nostr+json
    if (req.headers.get("Accept") === "application/nostr+json") {
      return Response.json(relayInfo, {
        headers: {
          "Access-Control-Allow-Origin": "*",
          "Access-Control-Allow-Headers": "Accept",
          "Access-Control-Allow-Methods": "GET",
        },
      });
    }
    if (server.upgrade(req)) return;
    return new Response("Connect via WebSocket", { status: 400 });
  },
  websocket: {
    open(ws) {
      ws.data = { subscriptions: new Map() };
    },
    message(ws, raw) {
      handleMessage(ws, JSON.parse(raw));
    },
    close(ws) {/* cleanup subscriptions */},
  },
});

2. Implement the NIP-01 message protocol

Handle the three client message types and respond with the five relay message types. See references/message-protocol.md for the complete format reference.

function handleMessage(ws, msg: unknown[]) {
  const verb = msg[0];
  switch (verb) {
    case "EVENT":
      return handleEvent(ws, msg[1]);
    case "REQ":
      return handleReq(ws, msg[1], msg.slice(2));
    case "CLOSE":
      return handleClose(ws, msg[1]);
    default:
      return send(ws, ["NOTICE", `unknown message type: ${verb}`]);
  }
}

Critical rules:

  • EVENT → always respond with OK (true/false + message)
  • REQ → send matching stored events, then EOSE, then stream new matches
  • CLOSE → remove the subscription, no response required
  • Subscription IDs are per-connection, max 64 chars, non-empty strings
  • A new REQ with an existing subscription ID replaces the old subscription

3. Implement event validation

Every received event MUST be validated before storage. Follow the checklist in references/event-validation.md. The two critical checks:

ID verification — recompute and compare:

import { sha256 } from "@noble/hashes/sha256";
import { bytesToHex } from "@noble/hashes/utils";

function computeEventId(event): string {
  const serialized = JSON.stringify([
    0,
    event.pubkey,
    event.created_at,
    event.kind,
    event.tags,
    event.content,
  ]);
  return bytesToHex(sha256(new TextEncoder().encode(serialized)));
}

Signature verification — Schnorr over secp256k1:

import { schnorr } from "@noble/curves/secp256k1";

function verifySignature(event): boolean {
  return schnorr.verify(event.sig, event.id, event.pubkey);
}

If validation fails, respond with ["OK", event.id, false, "invalid: <reason>"].

4. Implement filter matching

Filters determine which events match a subscription. The logic is:

  • Within a single filter: all specified conditions must match (AND)
  • Across multiple filters in a REQ: any filter matching is sufficient (OR)
  • List fields (ids, authors, kinds, #tags): event value must be in the list (OR within field)
function matchesFilter(event, filter): boolean {
  if (filter.ids && !filter.ids.includes(event.id)) return false;
  if (filter.authors && !filter.authors.includes(event.pubkey)) return false;
  if (filter.kinds && !filter.kinds.includes(event.kind)) return false;
  if (filter.since && event.created_at < filter.since) return false;
  if (filter.until && event.created_at > filter.until) return false;

  // Tag filters: #e, #p, #a, etc.
  for (const [key, values] of Object.entries(filter)) {
    if (key.startsWith("#") && key.length === 2) {
      const tagName = key.slice(1);
      const eventTagValues = event.tags
        .filter((t) => t[0] === tagName)
        .map((t) => t[1]);
      if (!values.some((v) => eventTagValues.includes(v))) return false;
    }
  }
  return true;
}

limit handling: only applies to the initial query (not streaming). Return the newest limit events, ordered by created_at descending. On ties, lowest id (lexicographic) first.

5. Implement event storage with kind-based rules

Different kind ranges have different storage semantics:

Kind Range Type Storage Rule
1, 2, 4-44, 1000-9999 Regular Store all events
0, 3, 10000-19999 Replaceable Keep only latest per pubkey + kind
20000-29999 Ephemeral Do NOT store; broadcast only
30000-39999 Addressable Keep only latest per pubkey + kind + d tag

For replaceable/addressable events with the same created_at, keep the one with the lowest id (lexicographic order).

function getEventDTag(event): string {
  const dTag = event.tags.find((t) => t[0] === "d");
  return dTag ? dTag[1] : "";
}

function isReplaceable(kind: number): boolean {
  return kind === 0 || kind === 3 || (kind >= 10000 && kind < 20000);
}

function isAddressable(kind: number): boolean {
  return kind >= 30000 && kind < 40000;
}

function isEphemeral(kind: number): boolean {
  return kind >= 20000 && kind < 30000;
}

6. Implement subscription management

Track active subscriptions per connection:

function handleReq(ws, subId: string, filters: object[]) {
  if (!subId || subId.length > 64) {
    return send(ws, ["CLOSED", subId, "invalid: bad subscription id"]);
  }

  // Replace existing subscription with same ID
  ws.data.subscriptions.set(subId, filters);

  // Query stored events matching any filter
  const matches = queryEvents(filters);
  for (const event of matches) {
    send(ws, ["EVENT", subId, event]);
  }
  send(ws, ["EOSE", subId]);

  // New events will be checked against this subscription in real-time
}

function handleClose(ws, subId: string) {
  ws.data.subscriptions.delete(subId);
}

When a new event is stored, broadcast it to all connections with matching subscriptions (skip the limit check — it only applies to initial queries).

7. Add progressive NIP support

After NIP-01 is solid, add NIPs in this order:

NIP-11 — Relay information document: Serve JSON at the WebSocket URL when the HTTP request has Accept: application/nostr+json. Must include CORS headers. See the example in Step 1.

NIP-09 — Event deletion: Handle kind 5 events. Delete referenced events (by e and a tags) only if the deletion request's pubkey matches the referenced event's pubkey.

NIP-42 — Client authentication: Send ["AUTH", "<challenge>"] to clients. Accept ["AUTH", <signed-event>] responses. The auth event must be kind 22242 with relay and challenge tags. Verify created_at is within ~10 minutes. Use auth-required: prefix in OK/CLOSED messages when auth is needed.

NIP-45 — Event counting: Handle ["COUNT", subId, ...filters] messages. Respond with ["COUNT", subId, {"count": N}].

NIP-50 — Search: Support a search field in filters. Implement full-text search over event content.

Checklist

  • [ ] WebSocket server accepts connections and parses JSON arrays
  • [ ] EVENT messages are validated (id + signature) and stored
  • [ ] OK responses sent for every EVENT (true/false + prefix message)
  • [ ] REQ creates subscriptions, returns matching events + EOSE
  • [ ] CLOSE removes subscriptions
  • [ ] Filter matching handles ids, authors, kinds, #tags, since, until, limit
  • [ ] Replaceable events (kind 0, 3, 10000-19999) keep only latest per pubkey+kind
  • [ ] Addressable events (kind 30000-39999) keep only latest per pubkey+kind+d-tag
  • [ ] Ephemeral events (kind 20000-29999) are broadcast but not stored
  • [ ] New events broadcast to connections with matching subscriptions
  • [ ] NIP-11 info document served on HTTP GET with correct Accept header

Common Mistakes

Mistake Fix
Computing event ID with whitespace in JSON Use JSON.stringify with no spacer argument — zero whitespace
Forgetting to verify signature after ID check Both checks are mandatory; an event with valid ID but bad sig is invalid
Applying limit to streaming events limit only applies to the initial stored-event query, not real-time
Storing ephemeral events (kind 20000-29999) Ephemeral events must be broadcast only, never persisted
Using global subscription IDs Subscription IDs are scoped per WebSocket connection
Not replacing subscription on duplicate REQ ID A new REQ with the same sub ID must replace the old subscription
Missing CORS headers on NIP-11 response NIP-11 requires Access-Control-Allow-Origin: * and related headers
Tag filter matching all tag elements Only the first value (index 1) of each tag is indexed/matched
Returning OK without the machine-readable prefix Failed OK messages must use prefixes: invalid:, duplicate:, error:, etc.
Not handling replaceable event timestamp ties When created_at is equal, keep the event with the lowest id (lexicographic)

Quick Reference

Message Direction Format
EVENT (client) Client → Relay ["EVENT", <event>]
REQ Client → Relay ["REQ", <sub_id>, <filter1>, ...]
CLOSE Client → Relay ["CLOSE", <sub_id>]
EVENT (relay) Relay → Client ["EVENT", <sub_id>, <event>]
OK Relay → Client ["OK", <event_id>, <bool>, <message>]
EOSE Relay → Client ["EOSE", <sub_id>]
CLOSED Relay → Client ["CLOSED", <sub_id>, <message>]
NOTICE Relay → Client ["NOTICE", <message>]
AUTH (relay) Relay → Client ["AUTH", <challenge>]
AUTH (client) Client → Relay ["AUTH", <signed-event>]
OK Prefix Meaning
duplicate: Event already stored
invalid: Failed validation (bad id, bad sig, bad format)
blocked: Pubkey or IP is blocked
rate-limited: Too many events
restricted: Not authorized to write
pow: Proof-of-work related
error: Internal relay error
auth-required: Must authenticate first (NIP-42)

Key Principles

  1. Validate everything — Never store an event without verifying both the id (SHA-256 of canonical serialization) and the signature (Schnorr secp256k1). A relay that skips validation poisons the network.

  2. OK is mandatory — Every EVENT from a client MUST receive an OK response, whether accepted or rejected. Silent drops break client retry logic.

  3. Subscriptions are per-connection — Never share subscription state across WebSocket connections. Each connection maintains its own subscription map.

  4. Kind semantics are non-negotiable — Replaceable events (0, 3, 10000-19999) keep only the latest. Addressable events (30000-39999) key on pubkey+kind+d-tag. Ephemeral events (20000-29999) are never stored. Getting this wrong corrupts user data.

  5. Progressive enhancement — Start with NIP-01 only. Add NIPs one at a time, updating supported_nips in the NIP-11 info document as you go. A relay that does NIP-01 perfectly is more useful than one that does 10 NIPs poorly.