@nostr-wot/graph 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,835 @@
1
+ 'use strict';
2
+
3
+ var react = require('react');
4
+ var relay = require('@nostr-wot/relay');
5
+ var nostrTools = require('nostr-tools');
6
+ var pool = require('nostr-tools/pool');
7
+ var jsxRuntime = require('react/jsx-runtime');
8
+
9
+ // src/react/context.tsx
10
+
11
+ // src/storage.ts
12
+ var DB_PREFIX = "nostr-wot-graph";
13
+ var DB_VERSION = 1;
14
+ var STORE_PUBKEYS = "pubkeys";
15
+ var STORE_FOLLOWS = "follows";
16
+ var STORE_META = "meta";
17
+ function encodeFollows(followIds) {
18
+ if (followIds.length === 0) return new ArrayBuffer(0);
19
+ const sorted = Array.from(followIds).sort((a, b) => a - b);
20
+ const deltas = new Uint32Array(sorted.length);
21
+ deltas[0] = sorted[0];
22
+ for (let i = 1; i < sorted.length; i++) {
23
+ deltas[i] = sorted[i] - sorted[i - 1];
24
+ }
25
+ return deltas.buffer;
26
+ }
27
+ function decodeFollows(buffer) {
28
+ if (!buffer || buffer.byteLength === 0) return new Uint32Array(0);
29
+ const deltas = new Uint32Array(buffer);
30
+ const result = new Uint32Array(deltas.length);
31
+ result[0] = deltas[0];
32
+ for (let i = 1; i < deltas.length; i++) {
33
+ result[i] = result[i - 1] + deltas[i];
34
+ }
35
+ return result;
36
+ }
37
+ function hasIndexedDB() {
38
+ return typeof indexedDB !== "undefined" && indexedDB !== null;
39
+ }
40
+ var GraphStorage = class {
41
+ constructor(namespace) {
42
+ this.db = null;
43
+ this.memoryOnly = false;
44
+ this.opened = false;
45
+ // In-memory caches (source of truth for reads/BFS).
46
+ this.pubkeyToId = /* @__PURE__ */ new Map();
47
+ this.idToPubkey = /* @__PURE__ */ new Map();
48
+ this.nextId = 1;
49
+ this.graphCache = /* @__PURE__ */ new Map();
50
+ this.metaCache = /* @__PURE__ */ new Map();
51
+ // Pending persistence buffers (drained by flush()).
52
+ this.dirtyFollows = /* @__PURE__ */ new Map();
53
+ this.dirtyPubkeys = [];
54
+ if (!namespace) throw new Error("GraphStorage requires a namespace");
55
+ this.namespace = namespace;
56
+ }
57
+ dbName() {
58
+ return `${DB_PREFIX}:${this.namespace}`;
59
+ }
60
+ /** Open (or create) the namespace DB and hydrate in-memory caches. */
61
+ async open() {
62
+ if (this.opened) return;
63
+ if (!hasIndexedDB()) {
64
+ this.memoryOnly = true;
65
+ this.opened = true;
66
+ return;
67
+ }
68
+ await new Promise((resolve, reject) => {
69
+ const request = indexedDB.open(this.dbName(), DB_VERSION);
70
+ request.onerror = () => reject(request.error);
71
+ request.onupgradeneeded = (event) => {
72
+ const database = event.target.result;
73
+ if (!database.objectStoreNames.contains(STORE_PUBKEYS)) {
74
+ const store = database.createObjectStore(STORE_PUBKEYS, { keyPath: "id" });
75
+ store.createIndex("pubkey", "pubkey", { unique: true });
76
+ }
77
+ if (!database.objectStoreNames.contains(STORE_FOLLOWS)) {
78
+ database.createObjectStore(STORE_FOLLOWS, { keyPath: "id" });
79
+ }
80
+ if (!database.objectStoreNames.contains(STORE_META)) {
81
+ database.createObjectStore(STORE_META, { keyPath: "key" });
82
+ }
83
+ };
84
+ request.onsuccess = () => {
85
+ this.db = request.result;
86
+ resolve();
87
+ };
88
+ });
89
+ this.opened = true;
90
+ await this.loadAll();
91
+ }
92
+ /** Hydrate the in-memory interning + follow maps + meta from the DB. */
93
+ async loadAll() {
94
+ this.pubkeyToId.clear();
95
+ this.idToPubkey.clear();
96
+ this.graphCache.clear();
97
+ this.metaCache.clear();
98
+ this.nextId = 1;
99
+ if (this.memoryOnly || !this.db) return;
100
+ const db = this.db;
101
+ const pubkeys = await this.getAll(db, STORE_PUBKEYS);
102
+ for (const record of pubkeys) {
103
+ this.pubkeyToId.set(record.pubkey, record.id);
104
+ this.idToPubkey.set(record.id, record.pubkey);
105
+ if (record.id >= this.nextId) this.nextId = record.id + 1;
106
+ }
107
+ const follows = await this.getAll(db, STORE_FOLLOWS);
108
+ for (const record of follows) {
109
+ this.graphCache.set(record.id, decodeFollows(record.follows));
110
+ }
111
+ const meta = await this.getAll(db, STORE_META);
112
+ for (const record of meta) {
113
+ this.metaCache.set(record.key, record.value);
114
+ }
115
+ }
116
+ getAll(db, store) {
117
+ return new Promise((resolve, reject) => {
118
+ const tx = db.transaction(store, "readonly");
119
+ const request = tx.objectStore(store).getAll();
120
+ request.onsuccess = () => resolve(request.result);
121
+ request.onerror = () => reject(request.error);
122
+ });
123
+ }
124
+ // ── Interning ──
125
+ /** Numeric id for a pubkey, or null if never seen. */
126
+ getId(pubkey) {
127
+ var _a;
128
+ return (_a = this.pubkeyToId.get(pubkey)) != null ? _a : null;
129
+ }
130
+ /** Numeric id for a pubkey, minting and buffering a new one if needed. */
131
+ getOrCreateId(pubkey) {
132
+ const existing = this.pubkeyToId.get(pubkey);
133
+ if (existing !== void 0) return existing;
134
+ const id = this.nextId++;
135
+ this.pubkeyToId.set(pubkey, id);
136
+ this.idToPubkey.set(id, pubkey);
137
+ this.dirtyPubkeys.push({ id, pubkey });
138
+ return id;
139
+ }
140
+ /** Batch variant of {@link getOrCreateId}. */
141
+ getOrCreateIds(pubkeys) {
142
+ const ids = new Array(pubkeys.length);
143
+ for (let i = 0; i < pubkeys.length; i++) {
144
+ ids[i] = this.getOrCreateId(pubkeys[i]);
145
+ }
146
+ return ids;
147
+ }
148
+ /** Pubkey for a numeric id, or null. */
149
+ getHex(id) {
150
+ var _a;
151
+ return (_a = this.idToPubkey.get(id)) != null ? _a : null;
152
+ }
153
+ /** Highest assigned id (for typed-array sizing). */
154
+ getMaxId() {
155
+ return this.nextId - 1;
156
+ }
157
+ // ── Follows ──
158
+ /** Store `pubkey`'s follow list. Interns everything and updates the cache. */
159
+ saveFollows(pubkey, follows) {
160
+ const id = this.getOrCreateId(pubkey);
161
+ const followIds = this.getOrCreateIds(follows);
162
+ this.graphCache.set(id, new Uint32Array(followIds));
163
+ this.dirtyFollows.set(id, followIds);
164
+ }
165
+ /** Follow ids for a node id — sync, from the in-memory cache. */
166
+ getFollowIdsSync(id) {
167
+ var _a;
168
+ return (_a = this.graphCache.get(id)) != null ? _a : new Uint32Array(0);
169
+ }
170
+ /** Follow ids for a pubkey (interned). Empty if unknown. */
171
+ getFollowIds(pubkey) {
172
+ const id = this.getId(pubkey);
173
+ if (id === null) return new Uint32Array(0);
174
+ return this.getFollowIdsSync(id);
175
+ }
176
+ /** Follow list of `pubkey` as hex strings. */
177
+ getFollows(pubkey) {
178
+ const ids = this.getFollowIds(pubkey);
179
+ const out = [];
180
+ for (let i = 0; i < ids.length; i++) {
181
+ const hex = this.getHex(ids[i]);
182
+ if (hex) out.push(hex);
183
+ }
184
+ return out;
185
+ }
186
+ // ── Meta ──
187
+ async setMeta(key, value) {
188
+ this.metaCache.set(key, value);
189
+ if (this.memoryOnly || !this.db) return;
190
+ const db = this.db;
191
+ await new Promise((resolve, reject) => {
192
+ const tx = db.transaction(STORE_META, "readwrite");
193
+ tx.objectStore(STORE_META).put({ key, value });
194
+ tx.oncomplete = () => resolve();
195
+ tx.onerror = () => reject(tx.error);
196
+ });
197
+ }
198
+ getMeta(key) {
199
+ return this.metaCache.get(key);
200
+ }
201
+ /** Read the structured graph meta record. */
202
+ getGraphMeta() {
203
+ var _a, _b, _c, _d;
204
+ return {
205
+ root: (_a = this.getMeta("root")) != null ? _a : null,
206
+ lastCrawl: (_b = this.getMeta("lastCrawl")) != null ? _b : null,
207
+ maxDepth: (_c = this.getMeta("maxDepth")) != null ? _c : null,
208
+ version: (_d = this.getMeta("version")) != null ? _d : DB_VERSION
209
+ };
210
+ }
211
+ // ── Persistence ──
212
+ /** Flush buffered pubkey + follow writes to IndexedDB. No-op in memory mode. */
213
+ async flush() {
214
+ if (this.memoryOnly || !this.db) {
215
+ this.dirtyPubkeys.length = 0;
216
+ this.dirtyFollows.clear();
217
+ return;
218
+ }
219
+ const db = this.db;
220
+ const pubkeys = this.dirtyPubkeys.splice(0, this.dirtyPubkeys.length);
221
+ const follows = Array.from(this.dirtyFollows.entries());
222
+ this.dirtyFollows.clear();
223
+ if (pubkeys.length === 0 && follows.length === 0) return;
224
+ await new Promise((resolve, reject) => {
225
+ const stores = [];
226
+ if (pubkeys.length) stores.push(STORE_PUBKEYS);
227
+ if (follows.length) stores.push(STORE_FOLLOWS);
228
+ const tx = db.transaction(stores, "readwrite");
229
+ if (pubkeys.length) {
230
+ const store = tx.objectStore(STORE_PUBKEYS);
231
+ for (const row of pubkeys) store.put(row);
232
+ }
233
+ if (follows.length) {
234
+ const store = tx.objectStore(STORE_FOLLOWS);
235
+ for (const [id, followIds] of follows) {
236
+ store.put({ id, follows: encodeFollows(followIds), updated_at: Date.now() });
237
+ }
238
+ }
239
+ tx.oncomplete = () => resolve();
240
+ tx.onerror = () => reject(tx.error);
241
+ });
242
+ }
243
+ // ── Stats / clear ──
244
+ stats() {
245
+ let edges = 0;
246
+ for (const follows of this.graphCache.values()) edges += follows.length;
247
+ return {
248
+ nodes: this.graphCache.size,
249
+ edges,
250
+ uniquePubkeys: this.pubkeyToId.size
251
+ };
252
+ }
253
+ /** Wipe this namespace: memory caches + persisted stores. */
254
+ async clear() {
255
+ this.pubkeyToId.clear();
256
+ this.idToPubkey.clear();
257
+ this.graphCache.clear();
258
+ this.metaCache.clear();
259
+ this.dirtyFollows.clear();
260
+ this.dirtyPubkeys.length = 0;
261
+ this.nextId = 1;
262
+ if (this.memoryOnly || !this.db) return;
263
+ const db = this.db;
264
+ await new Promise((resolve, reject) => {
265
+ const tx = db.transaction([STORE_FOLLOWS, STORE_PUBKEYS, STORE_META], "readwrite");
266
+ tx.objectStore(STORE_FOLLOWS).clear();
267
+ tx.objectStore(STORE_PUBKEYS).clear();
268
+ tx.objectStore(STORE_META).clear();
269
+ tx.oncomplete = () => resolve();
270
+ tx.onerror = () => reject(tx.error);
271
+ });
272
+ }
273
+ /** Close the underlying DB connection. */
274
+ close() {
275
+ if (this.db) {
276
+ this.db.close();
277
+ this.db = null;
278
+ }
279
+ this.opened = false;
280
+ }
281
+ };
282
+
283
+ // src/graph.ts
284
+ var DEFAULT_MAX_HOPS = 6;
285
+ var LocalGraph = class {
286
+ constructor(storage) {
287
+ this.cache = null;
288
+ this.cachedRoot = null;
289
+ this.storage = storage;
290
+ }
291
+ /** Invalidate the precomputed cache (called on crawl / root change / clear). */
292
+ invalidateCache() {
293
+ this.cache = null;
294
+ this.cachedRoot = null;
295
+ }
296
+ /**
297
+ * Precompute hops and paths from a root pubkey using a single BFS pass.
298
+ * Results stored in typed arrays indexed by node id for O(1) lookup.
299
+ */
300
+ buildCache(rootPubkey, maxHops = DEFAULT_MAX_HOPS) {
301
+ const rootId = this.storage.getId(rootPubkey);
302
+ if (rootId === null) {
303
+ this.cache = null;
304
+ this.cachedRoot = null;
305
+ return;
306
+ }
307
+ const maxId = this.storage.getMaxId();
308
+ const hops = new Uint8Array(maxId + 1);
309
+ const paths = new Uint32Array(maxId + 1);
310
+ hops[rootId] = 1;
311
+ paths[rootId] = 1;
312
+ let frontier = [rootId];
313
+ let hop = 0;
314
+ while (frontier.length > 0 && hop < maxHops) {
315
+ hop++;
316
+ const hopStored = hop + 1;
317
+ const nextFrontier = [];
318
+ for (let f = 0; f < frontier.length; f++) {
319
+ const nodeId = frontier[f];
320
+ const nodePaths = paths[nodeId];
321
+ const followIds = this.storage.getFollowIdsSync(nodeId);
322
+ for (let i = 0; i < followIds.length; i++) {
323
+ const fid = followIds[i];
324
+ if (fid > maxId) continue;
325
+ if (hops[fid] === 0) {
326
+ hops[fid] = hopStored;
327
+ paths[fid] = nodePaths;
328
+ nextFrontier.push(fid);
329
+ } else if (hops[fid] === hopStored) {
330
+ paths[fid] += nodePaths;
331
+ }
332
+ }
333
+ }
334
+ frontier = nextFrontier;
335
+ }
336
+ this.cache = { rootId, hops, paths, maxId };
337
+ this.cachedRoot = rootPubkey;
338
+ }
339
+ /** Ensure the cache is built for `root`. */
340
+ ensureCache(root, maxHops) {
341
+ if (this.cachedRoot !== root || !this.cache) {
342
+ this.buildCache(root, maxHops);
343
+ }
344
+ }
345
+ /**
346
+ * Distance info from `root` to `pubkey`. Returns `{ hops, paths }`, or `null`
347
+ * when unreached / unknown. Self → `{ hops: 0, paths: 1 }`.
348
+ */
349
+ getDistance(root, pubkey, maxHops = DEFAULT_MAX_HOPS) {
350
+ if (root === pubkey) return { hops: 0, paths: 1 };
351
+ this.ensureCache(root, maxHops);
352
+ if (!this.cache || this.cachedRoot !== root) return null;
353
+ const toId = this.storage.getId(pubkey);
354
+ if (toId === null || toId > this.cache.maxId) return null;
355
+ const h = this.cache.hops[toId];
356
+ if (h === 0) return null;
357
+ return { hops: h - 1, paths: this.cache.paths[toId] };
358
+ }
359
+ /** Follow list of `pubkey` as hex strings. */
360
+ getFollows(pubkey) {
361
+ return this.storage.getFollows(pubkey);
362
+ }
363
+ };
364
+
365
+ // src/crawl.ts
366
+ var CrawlError = class extends Error {
367
+ constructor(message) {
368
+ super(message);
369
+ this.name = "CrawlError";
370
+ }
371
+ };
372
+ var DEFAULT_MAX_DEPTH = 2;
373
+ var GraphCrawler = class {
374
+ constructor(options) {
375
+ this.aborted = false;
376
+ var _a, _b, _c;
377
+ this.pool = options.pool;
378
+ this.storage = options.storage;
379
+ this.relays = options.relays;
380
+ this.baseDelayMs = (_a = options.baseDelayMs) != null ? _a : 50;
381
+ this.maxConcurrent = (_b = options.maxConcurrent) != null ? _b : 5;
382
+ this.requestTimeoutMs = (_c = options.requestTimeoutMs) != null ? _c : 1e4;
383
+ }
384
+ /** Abort an in-flight crawl. */
385
+ stop() {
386
+ this.aborted = true;
387
+ }
388
+ async crawl(rootPubkey, opts = {}) {
389
+ var _a;
390
+ if (this.relays.length === 0) {
391
+ throw new CrawlError("no relays connected");
392
+ }
393
+ const maxDepth = (_a = opts.maxDepth) != null ? _a : DEFAULT_MAX_DEPTH;
394
+ const start = Date.now();
395
+ this.aborted = false;
396
+ const signal = opts.signal;
397
+ const onAbort = () => this.stop();
398
+ if (signal) {
399
+ if (signal.aborted) this.aborted = true;
400
+ else signal.addEventListener("abort", onAbort);
401
+ }
402
+ const fetched = /* @__PURE__ */ new Set();
403
+ const failed = /* @__PURE__ */ new Set();
404
+ const seen = /* @__PURE__ */ new Set([rootPubkey]);
405
+ let currentLevel = [rootPubkey];
406
+ let reachedDepth = 0;
407
+ let stoppedEarly = false;
408
+ try {
409
+ for (let depth = 0; depth <= maxDepth; depth++) {
410
+ if (currentLevel.length === 0) break;
411
+ if (this.aborted) {
412
+ stoppedEarly = true;
413
+ break;
414
+ }
415
+ const nextSet = /* @__PURE__ */ new Set();
416
+ await this.mapLimited(currentLevel, async (pubkey) => {
417
+ var _a2;
418
+ if (this.aborted) return;
419
+ const follows = await this.fetchNewest(pubkey);
420
+ if (this.aborted) return;
421
+ if (follows === null) {
422
+ failed.add(pubkey);
423
+ } else {
424
+ fetched.add(pubkey);
425
+ reachedDepth = Math.max(reachedDepth, depth);
426
+ this.storage.saveFollows(pubkey, follows);
427
+ if (depth < maxDepth) {
428
+ for (const f of follows) {
429
+ if (!seen.has(f)) {
430
+ seen.add(f);
431
+ nextSet.add(f);
432
+ }
433
+ }
434
+ }
435
+ }
436
+ (_a2 = opts.onProgress) == null ? void 0 : _a2.call(opts, { depth, fetched: fetched.size, queued: nextSet.size });
437
+ });
438
+ if (this.aborted) {
439
+ stoppedEarly = true;
440
+ break;
441
+ }
442
+ currentLevel = Array.from(nextSet);
443
+ }
444
+ } finally {
445
+ if (signal) signal.removeEventListener("abort", onAbort);
446
+ await this.storage.flush();
447
+ }
448
+ const stats = this.storage.stats();
449
+ return {
450
+ fetched: fetched.size,
451
+ nodes: stats.nodes,
452
+ edges: stats.edges,
453
+ depth: reachedDepth,
454
+ durationMs: Date.now() - start,
455
+ stoppedEarly
456
+ };
457
+ }
458
+ /**
459
+ * Fetch a single pubkey's newest kind:3 follow list across relays.
460
+ * Resolves to the follow pubkeys, or `null` if no event arrived.
461
+ */
462
+ fetchNewest(pubkey) {
463
+ return new Promise((resolve) => {
464
+ let newestAt = 0;
465
+ let follows = null;
466
+ let settled = false;
467
+ let sub = null;
468
+ const finish = () => {
469
+ if (settled) return;
470
+ settled = true;
471
+ clearTimeout(timer);
472
+ try {
473
+ sub == null ? void 0 : sub.close();
474
+ } catch (e) {
475
+ }
476
+ resolve(follows);
477
+ };
478
+ const timer = setTimeout(finish, this.requestTimeoutMs);
479
+ sub = this.pool.subscribe(
480
+ { kinds: [3], authors: [pubkey], limit: 1 },
481
+ {
482
+ onEvent: (ev) => {
483
+ if (ev && ev.created_at > newestAt) {
484
+ newestAt = ev.created_at;
485
+ follows = (ev.tags || []).filter((tag) => tag[0] === "p" && tag[1]).map((tag) => tag[1]);
486
+ }
487
+ },
488
+ onEose: finish
489
+ }
490
+ );
491
+ if (settled) {
492
+ try {
493
+ sub == null ? void 0 : sub.close();
494
+ } catch (e) {
495
+ }
496
+ }
497
+ });
498
+ }
499
+ /**
500
+ * Run `worker` over `items` with a max-concurrency cap and a base delay
501
+ * before each dispatch (preserving the crawler's per-relay rate limiting
502
+ * intent, now applied at the pool boundary).
503
+ */
504
+ async mapLimited(items, worker) {
505
+ let index = 0;
506
+ const runNext = async () => {
507
+ while (index < items.length) {
508
+ if (this.aborted) return;
509
+ const item = items[index++];
510
+ if (this.baseDelayMs > 0) {
511
+ await new Promise((r) => setTimeout(r, this.baseDelayMs));
512
+ }
513
+ if (this.aborted) return;
514
+ await worker(item);
515
+ }
516
+ };
517
+ const lanes = Math.min(this.maxConcurrent, Math.max(1, items.length));
518
+ await Promise.all(Array.from({ length: lanes }, () => runNext()));
519
+ }
520
+ };
521
+
522
+ // src/scoring.ts
523
+ var DEFAULT_SCORING = {
524
+ distanceWeights: { 1: 1, 2: 0.5, 3: 0.25, 4: 0.1 },
525
+ pathBonus: { 2: 0.15, 3: 0.1, 4: 0.05 },
526
+ maxPathBonus: 0.5
527
+ };
528
+ function calculateScore(hops, paths, scoring = DEFAULT_SCORING) {
529
+ var _a, _b, _c, _d;
530
+ if (hops === 0) return 1;
531
+ if (hops === null || hops === void 0) return 0;
532
+ const { distanceWeights, pathBonus, maxPathBonus } = scoring;
533
+ const hopKey = Math.min(hops, 4);
534
+ const base = (_b = (_a = distanceWeights == null ? void 0 : distanceWeights[hopKey]) != null ? _a : DEFAULT_SCORING.distanceWeights[hopKey]) != null ? _b : 0.1;
535
+ let bonus = 0;
536
+ if (paths !== null && paths > 1 && hops > 1) {
537
+ let pathBonusValue;
538
+ if (typeof pathBonus === "object") {
539
+ pathBonusValue = (_d = (_c = pathBonus[hopKey]) != null ? _c : DEFAULT_SCORING.pathBonus[hopKey]) != null ? _d : 0.05;
540
+ } else {
541
+ pathBonusValue = pathBonus != null ? pathBonus : 0.1;
542
+ }
543
+ bonus = Math.min(pathBonusValue * (paths - 1), maxPathBonus != null ? maxPathBonus : 0.5);
544
+ }
545
+ const score = base + bonus;
546
+ return Math.min(Math.max(score, 0), 1);
547
+ }
548
+
549
+ // src/wot-source.ts
550
+ function createWoTSource(graph) {
551
+ return {
552
+ getDistance(target) {
553
+ const info = graph.getDistance(target);
554
+ return info ? info.hops : null;
555
+ },
556
+ isInMyWoT(target, maxHops) {
557
+ return graph.isInWoT(target, maxHops);
558
+ },
559
+ filterByWoT(pubkeys, opts) {
560
+ return graph.filterByWoT(pubkeys, opts);
561
+ }
562
+ };
563
+ }
564
+
565
+ // src/wot-graph.ts
566
+ var DEFAULT_MAX_HOPS2 = 2;
567
+ var STORAGE_VERSION = 1;
568
+ var WotGraph = class {
569
+ constructor(options) {
570
+ this.ownPool = null;
571
+ this.crawler = null;
572
+ this.root = null;
573
+ this.inFlight = null;
574
+ this.controller = null;
575
+ this.listeners = /* @__PURE__ */ new Set();
576
+ var _a;
577
+ this.namespace = options.namespace;
578
+ this.relays = [...options.relays];
579
+ this.scoring = mergeScoring(options.scoring);
580
+ this.websocketImplementation = options.websocketImplementation;
581
+ this.pool = (_a = options.pool) != null ? _a : null;
582
+ this.storage = new GraphStorage(this.namespace);
583
+ this.graph = new LocalGraph(this.storage);
584
+ }
585
+ /** Hydrate any cached graph from IndexedDB (fast path). */
586
+ async load() {
587
+ await this.storage.open();
588
+ this.root = this.storage.getGraphMeta().root;
589
+ this.graph.invalidateCache();
590
+ this.notify();
591
+ }
592
+ /**
593
+ * Build/refresh the graph by crawling kind:3 from `rootPubkey`.
594
+ * Concurrent calls return the same in-flight promise (idempotent).
595
+ */
596
+ crawl(rootPubkey, opts = {}) {
597
+ if (this.inFlight) return this.inFlight;
598
+ this.controller = new AbortController();
599
+ if (opts.signal) {
600
+ if (opts.signal.aborted) this.controller.abort();
601
+ else opts.signal.addEventListener("abort", () => {
602
+ var _a;
603
+ return (_a = this.controller) == null ? void 0 : _a.abort();
604
+ }, { once: true });
605
+ }
606
+ this.root = rootPubkey;
607
+ const run = (async () => {
608
+ var _a;
609
+ try {
610
+ await this.storage.open();
611
+ const pool = this.resolvePool();
612
+ this.crawler = new GraphCrawler({ pool, storage: this.storage, relays: this.relays });
613
+ const result = await this.crawler.crawl(rootPubkey, {
614
+ maxDepth: opts.maxDepth,
615
+ onProgress: opts.onProgress,
616
+ signal: this.controller.signal
617
+ });
618
+ await this.storage.setMeta("root", rootPubkey);
619
+ await this.storage.setMeta("lastCrawl", Date.now());
620
+ await this.storage.setMeta("maxDepth", (_a = opts.maxDepth) != null ? _a : DEFAULT_MAX_HOPS2);
621
+ await this.storage.setMeta("version", STORAGE_VERSION);
622
+ this.graph.invalidateCache();
623
+ this.notify();
624
+ return result;
625
+ } finally {
626
+ this.inFlight = null;
627
+ this.crawler = null;
628
+ this.controller = null;
629
+ }
630
+ })();
631
+ this.inFlight = run;
632
+ return run;
633
+ }
634
+ /** Distance info from the crawled root, or `null` if unreached/unknown. */
635
+ getDistance(pubkey) {
636
+ if (!this.root) return null;
637
+ return this.graph.getDistance(this.root, pubkey);
638
+ }
639
+ /** Trust score 0..1 via {@link calculateScore}. */
640
+ getScore(pubkey) {
641
+ const info = this.getDistance(pubkey);
642
+ return calculateScore(info ? info.hops : null, info ? info.paths : null, this.scoring);
643
+ }
644
+ /** Whether `pubkey` is within `maxHops` of the root. */
645
+ isInWoT(pubkey, maxHops = DEFAULT_MAX_HOPS2) {
646
+ const info = this.getDistance(pubkey);
647
+ return info !== null && info.hops <= maxHops;
648
+ }
649
+ /** Trusted subset of `pubkeys`, sorted by score descending. */
650
+ filterByWoT(pubkeys, opts) {
651
+ var _a;
652
+ const maxHops = (_a = opts == null ? void 0 : opts.maxHops) != null ? _a : DEFAULT_MAX_HOPS2;
653
+ const scored = [];
654
+ for (const pubkey of pubkeys) {
655
+ const info = this.getDistance(pubkey);
656
+ if (info !== null && info.hops <= maxHops) {
657
+ scored.push({ pubkey, score: calculateScore(info.hops, info.paths, this.scoring) });
658
+ }
659
+ }
660
+ scored.sort((a, b) => b.score - a.score);
661
+ return scored.map((s) => s.pubkey);
662
+ }
663
+ /** Follow list of `pubkey` as hex strings. */
664
+ getFollows(pubkey) {
665
+ return this.graph.getFollows(pubkey);
666
+ }
667
+ /** Aggregate stats + crawl meta. */
668
+ stats() {
669
+ const s = this.storage.stats();
670
+ const meta = this.storage.getGraphMeta();
671
+ return {
672
+ nodes: s.nodes,
673
+ edges: s.edges,
674
+ root: meta.root,
675
+ lastCrawl: meta.lastCrawl,
676
+ maxDepth: meta.maxDepth
677
+ };
678
+ }
679
+ /** True if the last crawl is older than `ttlMs` (or never happened). */
680
+ isStale(ttlMs) {
681
+ const last = this.storage.getGraphMeta().lastCrawl;
682
+ if (last === null) return true;
683
+ return Date.now() - last > ttlMs;
684
+ }
685
+ /** Wipe this namespace. */
686
+ async clear() {
687
+ await this.storage.clear();
688
+ this.root = null;
689
+ this.graph.invalidateCache();
690
+ this.notify();
691
+ }
692
+ /** Abort an in-flight crawl. Partial data stays usable. */
693
+ stop() {
694
+ var _a, _b;
695
+ (_a = this.controller) == null ? void 0 : _a.abort();
696
+ (_b = this.crawler) == null ? void 0 : _b.stop();
697
+ }
698
+ /** Adapter for `@nostr-wot/wot`'s `WoT` class. */
699
+ asWoTSource() {
700
+ return createWoTSource(this);
701
+ }
702
+ /** The root pubkey the graph is currently answering queries from. */
703
+ getRoot() {
704
+ return this.root;
705
+ }
706
+ /** Subscribe to graph changes (crawl / load / clear). Returns an unsubscribe. */
707
+ onChange(cb) {
708
+ this.listeners.add(cb);
709
+ return () => this.listeners.delete(cb);
710
+ }
711
+ /** Release the pool/storage this instance owns. */
712
+ destroy() {
713
+ this.storage.close();
714
+ if (this.ownPool) {
715
+ this.ownPool.destroy();
716
+ this.ownPool = null;
717
+ }
718
+ }
719
+ notify() {
720
+ for (const cb of this.listeners) cb();
721
+ }
722
+ resolvePool() {
723
+ if (this.pool) return this.pool;
724
+ if (this.relays.length === 0) throw new CrawlError("no relays connected");
725
+ if (this.websocketImplementation) {
726
+ pool.useWebSocketImplementation(this.websocketImplementation);
727
+ }
728
+ const rp = new relay.RelayPool({ urls: this.relays });
729
+ rp.ensurePool(() => new nostrTools.SimplePool());
730
+ this.ownPool = rp;
731
+ this.pool = rp;
732
+ return this.pool;
733
+ }
734
+ };
735
+ function mergeScoring(overrides) {
736
+ var _a;
737
+ if (!overrides) return { ...DEFAULT_SCORING };
738
+ return {
739
+ distanceWeights: { ...DEFAULT_SCORING.distanceWeights, ...overrides.distanceWeights },
740
+ pathBonus: { ...DEFAULT_SCORING.pathBonus, ...overrides.pathBonus },
741
+ maxPathBonus: (_a = overrides.maxPathBonus) != null ? _a : DEFAULT_SCORING.maxPathBonus
742
+ };
743
+ }
744
+ var WotGraphContext = react.createContext(null);
745
+ function WotGraphProvider({ children, ...options }) {
746
+ const [ready, setReady] = react.useState(false);
747
+ const [crawling, setCrawling] = react.useState(false);
748
+ const [version, setVersion] = react.useState(0);
749
+ const graph = react.useMemo(() => new WotGraph(options), []);
750
+ react.useEffect(() => {
751
+ let mounted = true;
752
+ const unsub = graph.onChange(() => {
753
+ if (mounted) setVersion((v) => v + 1);
754
+ });
755
+ graph.load().catch(() => {
756
+ }).finally(() => {
757
+ if (mounted) setReady(true);
758
+ });
759
+ return () => {
760
+ mounted = false;
761
+ unsub();
762
+ graph.stop();
763
+ graph.destroy();
764
+ };
765
+ }, []);
766
+ const value = react.useMemo(
767
+ () => ({ graph, ready, crawling, setCrawling, version }),
768
+ [graph, ready, crawling, version]
769
+ );
770
+ return /* @__PURE__ */ jsxRuntime.jsx(WotGraphContext.Provider, { value, children });
771
+ }
772
+ function useWotGraphContext() {
773
+ const ctx = react.useContext(WotGraphContext);
774
+ if (!ctx) throw new Error("useWotGraph must be used within a WotGraphProvider");
775
+ return ctx;
776
+ }
777
+ function useWotGraph() {
778
+ const { graph, ready, crawling } = useWotGraphContext();
779
+ return { graph, ready, crawling };
780
+ }
781
+ function useDistance(pubkey) {
782
+ const { graph, version } = useWotGraphContext();
783
+ return react.useMemo(
784
+ () => pubkey ? graph.getDistance(pubkey) : null,
785
+ // version participates so queries refresh after a crawl.
786
+ // eslint-disable-next-line react-hooks/exhaustive-deps
787
+ [graph, pubkey, version]
788
+ );
789
+ }
790
+ function useCrawl() {
791
+ const { graph, crawling, setCrawling } = useWotGraphContext();
792
+ const [progress, setProgress] = react.useState(null);
793
+ const [error, setError] = react.useState(null);
794
+ const mounted = react.useRef(true);
795
+ react.useEffect(() => {
796
+ mounted.current = true;
797
+ return () => {
798
+ mounted.current = false;
799
+ };
800
+ }, []);
801
+ const crawl = react.useCallback(
802
+ async (rootPubkey, opts) => {
803
+ setError(null);
804
+ setCrawling(true);
805
+ try {
806
+ const result = await graph.crawl(rootPubkey, {
807
+ ...opts,
808
+ onProgress: (p) => {
809
+ var _a;
810
+ if (mounted.current) setProgress(p);
811
+ (_a = opts == null ? void 0 : opts.onProgress) == null ? void 0 : _a.call(opts, p);
812
+ }
813
+ });
814
+ return result;
815
+ } catch (err) {
816
+ const e = err instanceof Error ? err : new Error(String(err));
817
+ if (mounted.current) setError(e);
818
+ return null;
819
+ } finally {
820
+ if (mounted.current) setCrawling(false);
821
+ }
822
+ },
823
+ [graph, setCrawling]
824
+ );
825
+ const stop = react.useCallback(() => graph.stop(), [graph]);
826
+ return { crawl, stop, progress, crawling, error };
827
+ }
828
+
829
+ exports.WotGraph = WotGraph;
830
+ exports.WotGraphProvider = WotGraphProvider;
831
+ exports.useCrawl = useCrawl;
832
+ exports.useDistance = useDistance;
833
+ exports.useWotGraph = useWotGraph;
834
+ //# sourceMappingURL=index.cjs.map
835
+ //# sourceMappingURL=index.cjs.map