import type { SyncEvent } from './types.js'; /** * Nostr relay transport layer. * * Implements the same event I/O interface as FolderStore so the SyncEngine * can treat it as just another transport. * * Uses raw WebSocket + minimal Nostr protocol (NIP-01) to avoid * heavy dependencies. Works from file:// origins. */ const NOSTR_EVENT_KIND = 4078; // custom regular kind (stored by relays) // ── Minimal Nostr crypto (secp256k1 via nostr-tools) ───────── import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { SimplePool, type SubCloser } from 'nostr-tools/pool'; import type { Filter } from 'nostr-tools/filter'; export class NostrTransport { private relays: string[]; private pool: SimplePool; private roomKey: string | null = null; private secretKey: Uint8Array | null = null; /** Cached events keyed by our standard filename. */ private eventCache = new Map(); /** Active relay subscription (closeable). */ private sub: SubCloser | null = null; /** Callback fired when a new event arrives in real time. */ private _onNewEvent?: () => void; constructor(relays: string[]) { this.relays = relays; this.pool = new SimplePool(); } // ── Key management ────────────────────────────────────────── /** * Set the keypair. Called by FolderSyncDB after loading from IDB * (or generating a new one on first run). */ setKeypair(sk: Uint8Array): void { this.secretKey = sk; // Derive pubkey (used internally by finalizeEvent) getPublicKey(sk); } generateKeypair(): Uint8Array { const sk = generateSecretKey(); this.setKeypair(sk); return sk; } // ── Room management ───────────────────────────────────────── async joinRoom(roomKey: string): Promise { if (this.roomKey === roomKey && this.sub) return; // already joined this.leaveRoom(); this.roomKey = roomKey; // Fetch existing events from relays const fetchFilter: Filter = { kinds: [NOSTR_EVENT_KIND], '#channel': [roomKey], limit: 5000, }; try { const events = await this.pool.querySync(this.relays, fetchFilter as Filter); for (const ev of events) { this.cacheNostrEvent(ev); } } catch { // relay might be unreachable — continue with empty cache } // Subscribe for new real-time events const subFilter: Filter = { kinds: [NOSTR_EVENT_KIND], '#channel': [roomKey], since: Math.floor(Date.now() / 1000), }; this.sub = this.pool.subscribeMany(this.relays, subFilter, { onevent: (ev) => { const isNew = this.cacheNostrEvent(ev); if (isNew) this._onNewEvent?.(); }, }); } leaveRoom(): void { if (this.sub) { this.sub.close(); this.sub = null; } this.roomKey = null; this.eventCache.clear(); } get isConnected(): boolean { return this.roomKey !== null && this.sub !== null; } get currentRoom(): string | null { return this.roomKey; } // ── Callback for real-time push ───────────────────────────── onNewEvent(cb: () => void): void { this._onNewEvent = cb; } // ── Event I/O (same interface as FolderStore) ─────────────── async writeEvent(filename: string, event: SyncEvent): Promise { if (!this.roomKey || !this.secretKey) return; this.eventCache.set(filename, event); const nostrEvent = finalizeEvent({ kind: NOSTR_EVENT_KIND, created_at: Math.floor(Date.now() / 1000), tags: [ ['channel', this.roomKey], ['filename', filename], ], content: JSON.stringify(event), }, this.secretKey); // Publish to all relays, don't wait for all to confirm try { await Promise.any(this.pool.publish(this.relays, nostrEvent as any)); } catch { // All relays failed — event is still in local cache } } async scanEventFilenames(): Promise { return Array.from(this.eventCache.keys()).sort(); } async readEventFile(filename: string): Promise { return this.eventCache.get(filename) ?? null; } // ── Cleanup ───────────────────────────────────────────────── close(): void { this.leaveRoom(); this.pool.close(this.relays); } // ── Internals ─────────────────────────────────────────────── /** * Parse a Nostr event and cache it. Returns true if the event * was new (not already cached). */ private cacheNostrEvent(nostrEvent: any): boolean { try { const filename = nostrEvent.tags?.find( (t: string[]) => t[0] === 'filename', )?.[1]; if (!filename) return false; if (this.eventCache.has(filename)) return false; const syncEvent = JSON.parse(nostrEvent.content) as SyncEvent; this.eventCache.set(filename, syncEvent); return true; } catch { return false; } } }