外部仕様(別ドキュメント)を実装として成立させるための、構成・状態管理・通信フロー・関数責務・安全設計・リファレンスをまとめる。
本書は PoC 実装(単一 HTML)を前提とし、将来の分割(モジュール化・ビルド導入)も見越した「引き継ぎ可能な粒度」を目標とする。
nip19.decode → secretKey(Uint8Array)index.html(単一ファイル)
<style>:ライト調の UI<body>:screenList / screenEdit と各 modal<script type="module">:状態管理・Nostr 通信・レンダリング将来の推奨分割(任意):
ui/:DOMレンダリング、モーダル制御nostr/:リレー通信、イベント構築、暗号化state/:状態・reducerssecurity/:URLサニタイズ、CSP管理screenList:リスト一覧
listCards:d ごとに最新の kind:30000 をカード表示settingsPanelList:リレー設定、デバッグログscreenEdit:リスト編集
entryList:エントリ行(プロフィール + petname + 非公開チェック + 削除)settingsPanelEdit:title/description/d とデバッグログfooterBar:追加・publish(常時表示)loginModal:nsec 入力 + 初期 dnewListModal:新規作成(d 必須、title/description 任意)addModal:npub 追加(kind:0 プレビュー → 追加)publishModal:リレー別 publish 結果deleteListModal:kind:5 削除(a/k タグ)sk:secret key(Uint8Array)。メモリのみ。pkHex:自分の pubkey(hex)npub:自分の npubprofileCache: Map<hex, profileJson>
listIndex: Map<d, {event, d, title, description, created_at, id}>
existingDs: Set<d>
dirty: boolean:未反映変更ありstate:現在編集中のリスト
d: stringtitle: stringdescription: stringentries: Entry[]Entry 例:
hex: string(必須、ユニーク)npub: string(表示用)display: string(kind:0 由来)name: string(kind:0 由来)avatar: string(kind:0 由来、https/data:image のみ許可)petname: string(任意。未入力は空欄維持)relayHint: string(現状は “” 固定、将来拡張余地)isPrivate: booleanpendingDelete: boolean(削除予定グレー表示)不変条件:
entries.hex は重複しない(追加時に弾く)petname は自動補完しないpendingDelete は publish で除去されるSimplePool:複数リレー購読・publishfinalizeEvent:署名 + id 計算getPublicKey:pubkey 算出nip19:npub/nsec decodenip04:暗号化/復号(自分宛)relayInput に設定fetchOne で取得
tags: ["r", <url>] を抽出normalizeRelayUrls() にかけて最大20件relayInput を置換fetchMany(filter, relays, timeoutMs)
pool.subscribeMany(relays, [filter], {onevent, oneose})fetchOne(...)
fetchMany の結果を created_at desc でソートして先頭利用箇所:
loadAllLists():kind:30000 を複数取得 → d ごとに latest を indexfetchProfilesForEntries():kind:0 を必要分だけ取得previewBtn:kind:0 取得 → 追加のプレビューpublishToRelays(ev, relays)
pool.publish(relays, ev) が返す Promise 群を個別に timeout 監視{url, ok, note} に正規化バグ対応の経験則:
pool.publish の Promise が resolve/reject しない場合があるため withTimeout を必ず噛ませる。buildKind30000Event()
kept = entries.filter(!pendingDelete)publicTags:!isPrivate の p タグprivateTags:isPrivate の p タグ配列["d", state.d]["title", titleOrD]description は任意...publicTagsprivateTags.length ? nip04.encrypt(sk, pkHex, JSON.stringify(privateTags)) : ""buildDeleteEvent()
a = "30000:<pkHex>:<state.d>"[["a", a], ["k", "30000"]]textContent / 属性セットのみ。innerHTML は固定SVG以外使わない。sanitizeImageUrl() を通す(https と data:image のみ)switchScreen(which):list/edit の切替、戻るボタン制御renderListCards():listIndex から一覧カード生成renderEditAll():編集画面全体再描画renderRow(entry):エントリ行を生成markDirty(true/false) / updateDirtyUI():変更表示レンダリング方針:
LIMITS に集約。clampStr(s, max) で truncate。normalizeRelayUrls(raw)
new URL() で parsewss: / ws: のみ許可hash/search を削除、末尾スラッシュ除去bech32ToSk(nsec):nip19.decode type チェックdecodeNpubToHex(npub):同上textContent を使用sanitizeImageUrl(https と data:image のみ)wss: を許可、object-src/ base-uri/ frame-ancestors を制限debugLog(msg, obj)
推奨ログ追加ポイント:
fetchMany の onevent / oneose 回数loadAllLists() の latest 選定、fetchOne() のソート後フィルタ本節では、PoC 実装内で主要となる関数について、役割・引数・戻り値・例外・注意点を整理する。
記載の「例外」は JavaScript の
throwによるものに加え、Promiseの reject(非同期例外)も含む。
clampStr(s, max)max に切り詰めて返す。非文字列は空文字として扱う。s: 任意(通常は string)。typeof s !== "string" の場合は ““。max: number(>0 を想定)。max 以下)。safeShort(s, n)... 付きで短縮表示する(表示用途)。s: string(空/undefined は “” 扱い)n: number(表示目標長。最小 6 程度を想定)sanitizeImageUrl(url)url: string(kind:0 の picture 等)。new URL() 失敗を内部で catch し、例外は外に出さない。https:、および data:image/(png|jpeg|jpg|webp|gif) のみ。http:、javascript:、その他のスキーム。normalizeRelayUrls(raw)raw: string(改行区切りを想定)。new URL() 失敗は内部で握りつぶし、その行を無視。wss: / ws:。hash / search は削除。/ を削除して接続の重複を減らす。setRelayUrls(urls) / getRelayUrls()setRelayUrls(urls): string[](未正規化でも可。内部で正規化)。setRelayUrls: なし。getRelayUrls: string[](空なら bootstrap relay を返す)。getRelayUrls() は「空なら bootstrap」を保証し、以降の読取/書込が必ず何かのリレー集合を持つ。bech32ToSk(nsec)nsec: string(nsec1...)。nip19.decode が失敗した場合。type !== "nsec" の場合(”nsec ではありません”)。decodeNpubToHex(npubStr)npubStr: string(npub1...)。nip19.decode 失敗。type !== "npub" の場合(”npub ではありません”)。encryptPrivatePTags(privatePTags)privatePTags: any[](期待値:[["p", hex, relayHint, petname], ...])。nip04.encrypt が reject した場合(鍵不整合など)。pkHex)。「自分だけが復号可能」固定。JSON.stringify するため、循環参照は想定しない(privatePTags は単純配列のみ)。decryptPrivatePTags(content)content を復号し、非公開 p タグ配列を得る。content: string(暗号化された文字列)。[] を返す。[]。fetchMany(filter, relays, timeoutMs)filter: object(Nostr filter。例:{kinds:[30000], authors:[pkHex], limit:200})。relays: string[](対象リレーURL群)。timeoutMs: number(購読の最大待ち時間)。oneose で終了するが、リレーが EOSE を返さない場合に備え timeout を必ず設定。sub.close() を試みる。fetchOne(filter, relays, timeoutMs)fetchMany の結果から created_at 最大の1件を返す。fetchMany と同じ。| 戻り値:Promise<Event | null>(0件なら null)。 |
fetchMany 同様、外には投げにくい。withTimeout(promise, ms, label)promise: Promisems: numberlabel: string(ログ用)Error("timeout(label)") で reject。publishToRelays(ev, relays)ev: Event(finalizeEvent 済み)relays: string[]pool.publish が返す Promise 群に withTimeout を噛ませる。Promise.allSettled で安全に正規化。loadKind10002Relays()pkHex と getRelayUrls() を参照)。loadAllLists()listIndex に構築して一覧表示を更新する。pkHex, getRelayUrls() を参照)。existingDs を併せて構築し、新規作成時の重複チェックに使う。hydrateStateFromEvent(ev)state を構築する。ev: Event(kind:30000 を想定)。d/title/description を抽出。p タグを読み取り。decryptPrivatePTags(ev.content) で復号。entries に統合し、hex 重複は排除。fetchProfilesForEntries() / applyProfiles()fetchProfilesForEntries: PromiseapplyProfiles: voidprofileCache に無い pubkey のみ取得。sanitizeImageUrl を通す。buildKind30000Event()state から kind:30000 の tags と、暗号化対象の privateTags を生成する。state を参照)。{ tags: string[][], privateTags: any[] }pendingDelete は除外。publishNow()alert で中断(throw ではない)。finalizeEvent で署名し、publishToRelays の結果をUIに表示。pendingDelete を entries から除去し、dirty を false に戻す。buildDeleteEvent()pkHex, state.d を参照)。{ ev: Event, a: string }30000:<pkHex>:<d>30000switchScreen(which)which: “list” |
“edit” |
renderListCards()listIndex の内容から一覧カードDOMを生成して表示する。textContent を使用。renderEditAll()renderRow(entry)entry: Entryentry.petname に即時反映し dirty を立てる。markDirty(v) / updateDirtyUI()markDirty(v): booleanmarkDirty(false)。debugLog(msg, obj)msg: stringobj: any(任意、JSON化して追記)