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>
180 lines
5.3 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|