LocalHtmlDataTest/nostr/src/nostr-transport.ts
Jason Tudisco b5528b0ecf Add Nostr dual-sync variant (folder + relay)
Fifth library variant that keeps folder sync for offline/local use
AND adds Nostr relay sync for cross-device reach via WebSocket.
Both transports run simultaneously - writes go to folder AND Nostr,
sync imports from both and bridges events between them.

Works from file:// since Nostr uses WebSocket (not fetch/WebRTC).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:44:39 -06:00

180 lines
5.3 KiB
TypeScript

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<string, SyncEvent>();
/** 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<void> {
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<void> {
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<string[]> {
return Array.from(this.eventCache.keys()).sort();
}
async readEventFile(filename: string): Promise<SyncEvent | null> {
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;
}
}
}