@hashtree/worker 0.1.1

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.
Files changed (151) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +38 -0
  3. package/dist/capabilities/blossomBandwidthTracker.d.ts +26 -0
  4. package/dist/capabilities/blossomBandwidthTracker.d.ts.map +1 -0
  5. package/dist/capabilities/blossomBandwidthTracker.js +53 -0
  6. package/dist/capabilities/blossomBandwidthTracker.js.map +1 -0
  7. package/dist/capabilities/blossomTransport.d.ts +22 -0
  8. package/dist/capabilities/blossomTransport.d.ts.map +1 -0
  9. package/dist/capabilities/blossomTransport.js +124 -0
  10. package/dist/capabilities/blossomTransport.js.map +1 -0
  11. package/dist/capabilities/connectivity.d.ts +3 -0
  12. package/dist/capabilities/connectivity.d.ts.map +1 -0
  13. package/dist/capabilities/connectivity.js +49 -0
  14. package/dist/capabilities/connectivity.js.map +1 -0
  15. package/dist/capabilities/idbStorage.d.ts +25 -0
  16. package/dist/capabilities/idbStorage.d.ts.map +1 -0
  17. package/dist/capabilities/idbStorage.js +73 -0
  18. package/dist/capabilities/idbStorage.js.map +1 -0
  19. package/dist/client.d.ts +54 -0
  20. package/dist/client.d.ts.map +1 -0
  21. package/dist/client.js +336 -0
  22. package/dist/client.js.map +1 -0
  23. package/dist/entry.d.ts +2 -0
  24. package/dist/entry.d.ts.map +1 -0
  25. package/dist/entry.js +2 -0
  26. package/dist/entry.js.map +1 -0
  27. package/dist/index.d.ts +6 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +3 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/iris/identity.d.ts +36 -0
  32. package/dist/iris/identity.d.ts.map +1 -0
  33. package/dist/iris/identity.js +78 -0
  34. package/dist/iris/identity.js.map +1 -0
  35. package/dist/iris/mediaHandler.d.ts +16 -0
  36. package/dist/iris/mediaHandler.d.ts.map +1 -0
  37. package/dist/iris/mediaHandler.js +529 -0
  38. package/dist/iris/mediaHandler.js.map +1 -0
  39. package/dist/iris/ndk.d.ts +95 -0
  40. package/dist/iris/ndk.d.ts.map +1 -0
  41. package/dist/iris/ndk.js +496 -0
  42. package/dist/iris/ndk.js.map +1 -0
  43. package/dist/iris/nostr-wasm.d.ts +14 -0
  44. package/dist/iris/nostr-wasm.d.ts.map +1 -0
  45. package/dist/iris/nostr-wasm.js +246 -0
  46. package/dist/iris/nostr-wasm.js.map +1 -0
  47. package/dist/iris/nostr.d.ts +60 -0
  48. package/dist/iris/nostr.d.ts.map +1 -0
  49. package/dist/iris/nostr.js +207 -0
  50. package/dist/iris/nostr.js.map +1 -0
  51. package/dist/iris/protocol.d.ts +574 -0
  52. package/dist/iris/protocol.d.ts.map +1 -0
  53. package/dist/iris/protocol.js +16 -0
  54. package/dist/iris/protocol.js.map +1 -0
  55. package/dist/iris/signing.d.ts +50 -0
  56. package/dist/iris/signing.d.ts.map +1 -0
  57. package/dist/iris/signing.js +299 -0
  58. package/dist/iris/signing.js.map +1 -0
  59. package/dist/iris/treeRootCache.d.ts +73 -0
  60. package/dist/iris/treeRootCache.d.ts.map +1 -0
  61. package/dist/iris/treeRootCache.js +191 -0
  62. package/dist/iris/treeRootCache.js.map +1 -0
  63. package/dist/iris/treeRootSubscription.d.ts +49 -0
  64. package/dist/iris/treeRootSubscription.d.ts.map +1 -0
  65. package/dist/iris/treeRootSubscription.js +185 -0
  66. package/dist/iris/treeRootSubscription.js.map +1 -0
  67. package/dist/iris/utils/constants.d.ts +76 -0
  68. package/dist/iris/utils/constants.d.ts.map +1 -0
  69. package/dist/iris/utils/constants.js +113 -0
  70. package/dist/iris/utils/constants.js.map +1 -0
  71. package/dist/iris/utils/errorMessage.d.ts +5 -0
  72. package/dist/iris/utils/errorMessage.d.ts.map +1 -0
  73. package/dist/iris/utils/errorMessage.js +8 -0
  74. package/dist/iris/utils/errorMessage.js.map +1 -0
  75. package/dist/iris/utils/lruCache.d.ts +26 -0
  76. package/dist/iris/utils/lruCache.d.ts.map +1 -0
  77. package/dist/iris/utils/lruCache.js +66 -0
  78. package/dist/iris/utils/lruCache.js.map +1 -0
  79. package/dist/iris/webrtc.d.ts +2 -0
  80. package/dist/iris/webrtc.d.ts.map +1 -0
  81. package/dist/iris/webrtc.js +3 -0
  82. package/dist/iris/webrtc.js.map +1 -0
  83. package/dist/iris/webrtcSignaling.d.ts +37 -0
  84. package/dist/iris/webrtcSignaling.d.ts.map +1 -0
  85. package/dist/iris/webrtcSignaling.js +86 -0
  86. package/dist/iris/webrtcSignaling.js.map +1 -0
  87. package/dist/iris/worker.d.ts +12 -0
  88. package/dist/iris/worker.d.ts.map +1 -0
  89. package/dist/iris/worker.js +1582 -0
  90. package/dist/iris/worker.js.map +1 -0
  91. package/dist/iris-entry.d.ts +2 -0
  92. package/dist/iris-entry.d.ts.map +1 -0
  93. package/dist/iris-entry.js +2 -0
  94. package/dist/iris-entry.js.map +1 -0
  95. package/dist/mediaStreaming.d.ts +7 -0
  96. package/dist/mediaStreaming.d.ts.map +1 -0
  97. package/dist/mediaStreaming.js +48 -0
  98. package/dist/mediaStreaming.js.map +1 -0
  99. package/dist/p2p/boundedQueue.d.ts +74 -0
  100. package/dist/p2p/boundedQueue.d.ts.map +1 -0
  101. package/dist/p2p/boundedQueue.js +112 -0
  102. package/dist/p2p/boundedQueue.js.map +1 -0
  103. package/dist/p2p/errorMessage.d.ts +5 -0
  104. package/dist/p2p/errorMessage.d.ts.map +1 -0
  105. package/dist/p2p/errorMessage.js +7 -0
  106. package/dist/p2p/errorMessage.js.map +1 -0
  107. package/dist/p2p/index.d.ts +7 -0
  108. package/dist/p2p/index.d.ts.map +1 -0
  109. package/dist/p2p/index.js +5 -0
  110. package/dist/p2p/index.js.map +1 -0
  111. package/dist/p2p/lruCache.d.ts +26 -0
  112. package/dist/p2p/lruCache.d.ts.map +1 -0
  113. package/dist/p2p/lruCache.js +65 -0
  114. package/dist/p2p/lruCache.js.map +1 -0
  115. package/dist/p2p/protocol.d.ts +10 -0
  116. package/dist/p2p/protocol.d.ts.map +1 -0
  117. package/dist/p2p/protocol.js +2 -0
  118. package/dist/p2p/protocol.js.map +1 -0
  119. package/dist/p2p/queryForwardingMachine.d.ts +46 -0
  120. package/dist/p2p/queryForwardingMachine.d.ts.map +1 -0
  121. package/dist/p2p/queryForwardingMachine.js +144 -0
  122. package/dist/p2p/queryForwardingMachine.js.map +1 -0
  123. package/dist/p2p/signaling.d.ts +63 -0
  124. package/dist/p2p/signaling.d.ts.map +1 -0
  125. package/dist/p2p/signaling.js +165 -0
  126. package/dist/p2p/signaling.js.map +1 -0
  127. package/dist/p2p/webrtcController.d.ts +152 -0
  128. package/dist/p2p/webrtcController.d.ts.map +1 -0
  129. package/dist/p2p/webrtcController.js +813 -0
  130. package/dist/p2p/webrtcController.js.map +1 -0
  131. package/dist/p2p/webrtcProxy.d.ts +55 -0
  132. package/dist/p2p/webrtcProxy.d.ts.map +1 -0
  133. package/dist/p2p/webrtcProxy.js +386 -0
  134. package/dist/p2p/webrtcProxy.js.map +1 -0
  135. package/dist/privacyGuards.d.ts +14 -0
  136. package/dist/privacyGuards.d.ts.map +1 -0
  137. package/dist/privacyGuards.js +27 -0
  138. package/dist/privacyGuards.js.map +1 -0
  139. package/dist/protocol.d.ts +171 -0
  140. package/dist/protocol.d.ts.map +1 -0
  141. package/dist/protocol.js +2 -0
  142. package/dist/protocol.js.map +1 -0
  143. package/dist/types.d.ts +2 -0
  144. package/dist/types.d.ts.map +1 -0
  145. package/dist/types.js +2 -0
  146. package/dist/types.js.map +1 -0
  147. package/dist/worker.d.ts +2 -0
  148. package/dist/worker.d.ts.map +1 -0
  149. package/dist/worker.js +616 -0
  150. package/dist/worker.js.map +1 -0
  151. package/package.json +64 -0
@@ -0,0 +1,1582 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * Hashtree Worker
4
+ *
5
+ * Dedicated worker that owns:
6
+ * - HashTree + DexieStore (IndexedDB storage)
7
+ * - WebRTC peer connections (P2P data transfer)
8
+ *
9
+ * Main thread communicates via postMessage.
10
+ * NIP-07 signing/encryption delegated back to main thread.
11
+ */
12
+ import { HashTree, BlossomStore, FallbackStore } from '@hashtree/core';
13
+ import { DexieStore } from '@hashtree/dexie';
14
+ import { initTreeRootCache, getCachedRoot, getCachedRootInfo, setCachedRoot, mergeCachedRootKey, clearMemoryCache } from './treeRootCache';
15
+ import { handleTreeRootEvent, isTreeRootEvent, setNotifyCallback as setTreeRootNotifyCallback, subscribeToTreeRoots, unsubscribeFromTreeRoots } from './treeRootSubscription';
16
+ import { initNdk, closeNdk, subscribe as ndkSubscribe, unsubscribe as ndkUnsubscribe, publish as ndkPublish, setOnEvent, setOnEose, getRelayStats as getNdkRelayStats, republishTrees, republishTree, setRelays as ndkSetRelays, } from './ndk';
17
+ import { initIdentity, setIdentity, clearIdentity } from './identity';
18
+ import { setResponseSender, signEvent, handleSignedResponse, handleEncryptedResponse, handleDecryptedResponse, } from './signing';
19
+ import { WebRTCController } from './webrtc';
20
+ import { SocialGraph } from 'nostr-social-graph';
21
+ import Dexie from 'dexie';
22
+ import { LRUCache } from './utils/lruCache';
23
+ import { getErrorMessage } from './utils/errorMessage';
24
+ import { DEFAULT_BOOTSTRAP_PUBKEY } from './utils/constants';
25
+ import { nip19 } from 'nostr-tools';
26
+ // Dexie database for social graph persistence
27
+ class SocialGraphDB extends Dexie {
28
+ socialGraph;
29
+ constructor() {
30
+ super('hashtree-social-graph');
31
+ this.version(1).stores({
32
+ socialGraph: '&id',
33
+ });
34
+ }
35
+ }
36
+ const socialGraphDB = new SocialGraphDB();
37
+ import { initMediaHandler, registerMediaPort } from './mediaHandler';
38
+ import { initWebRTCSignaling, sendWebRTCSignaling, setupWebRTCSignalingSubscription, handleWebRTCSignalingEvent, resubscribeWebRTCSignaling, } from './webrtcSignaling';
39
+ import { BlossomBandwidthTracker } from '../capabilities/blossomBandwidthTracker';
40
+ // Worker state
41
+ let tree = null;
42
+ let store = null;
43
+ let fallbackStore = null;
44
+ let blossomStore = null;
45
+ let blossomBandwidthTracker = new BlossomBandwidthTracker((stats) => {
46
+ respond({ type: 'blossomBandwidth', stats });
47
+ });
48
+ let webrtc = null;
49
+ let webrtcStarted = false;
50
+ let _config = null;
51
+ const WEBRTC_REQUEST_TIMEOUT_MS = 5000;
52
+ const treeRootSubscriptionRefs = new Map();
53
+ // Storage quota management
54
+ let storageMaxBytes = 1024 * 1024 * 1024; // Default 1GB
55
+ let evictionCheckPending = false;
56
+ const EVICTION_CHECK_DEBOUNCE_MS = 5000; // Debounce eviction checks
57
+ /**
58
+ * Run storage eviction if over limit.
59
+ * Debounced to avoid running too frequently after many puts.
60
+ */
61
+ function runEvictionCheck() {
62
+ if (evictionCheckPending || !store)
63
+ return;
64
+ evictionCheckPending = true;
65
+ setTimeout(async () => {
66
+ evictionCheckPending = false;
67
+ if (!store)
68
+ return;
69
+ try {
70
+ const evicted = await store.evict(storageMaxBytes);
71
+ if (evicted > 0) {
72
+ console.log(`[Worker] Eviction completed, removed ${evicted} entries`);
73
+ }
74
+ }
75
+ catch (err) {
76
+ console.error('[Worker] Eviction error:', err);
77
+ }
78
+ }, EVICTION_CHECK_DEBOUNCE_MS);
79
+ }
80
+ let blossomProgress = null;
81
+ const BLOSSOM_PROGRESS_THROTTLE_MS = 100; // Throttle progress updates to every 100ms
82
+ function initBlossomProgress(sessionId, totalChunks, serverUrls) {
83
+ const serverStatus = new Map();
84
+ for (const url of serverUrls) {
85
+ serverStatus.set(url, { url, uploaded: 0, failed: 0, skipped: 0 });
86
+ }
87
+ blossomProgress = {
88
+ sessionId,
89
+ totalChunks,
90
+ processedChunks: 0,
91
+ serverStatus,
92
+ lastNotifyTime: 0,
93
+ };
94
+ }
95
+ function updateBlossomProgress(serverUrl, status) {
96
+ if (!blossomProgress)
97
+ return;
98
+ const serverStat = blossomProgress.serverStatus.get(serverUrl);
99
+ if (serverStat) {
100
+ serverStat[status]++;
101
+ }
102
+ // Count as processed when all servers have responded for this chunk
103
+ // We track per-server, so check if any server completed this chunk
104
+ const allServersDone = Array.from(blossomProgress.serverStatus.values()).every(s => (s.uploaded + s.skipped + s.failed) > (blossomProgress.processedChunks));
105
+ if (allServersDone) {
106
+ blossomProgress.processedChunks++;
107
+ }
108
+ // Throttle progress updates
109
+ const now = Date.now();
110
+ if (now - blossomProgress.lastNotifyTime >= BLOSSOM_PROGRESS_THROTTLE_MS ||
111
+ blossomProgress.processedChunks >= blossomProgress.totalChunks) {
112
+ blossomProgress.lastNotifyTime = now;
113
+ notifyBlossomProgress();
114
+ }
115
+ }
116
+ function notifyBlossomProgress() {
117
+ if (!blossomProgress)
118
+ return;
119
+ const progress = {
120
+ sessionId: blossomProgress.sessionId,
121
+ totalChunks: blossomProgress.totalChunks,
122
+ processedChunks: blossomProgress.processedChunks,
123
+ servers: Array.from(blossomProgress.serverStatus.values()),
124
+ };
125
+ respond({ type: 'blossomUploadProgress', progress });
126
+ }
127
+ function clearBlossomProgress() {
128
+ blossomProgress = null;
129
+ }
130
+ /**
131
+ * Push a tree to blossom servers explicitly
132
+ * Uses tree.push() to walk the tree and upload all chunks
133
+ */
134
+ async function handlePushToBlossom(id, cidHash, cidKey, treeName) {
135
+ if (!tree || !blossomStore) {
136
+ respond({ type: 'blossomPushResult', id, pushed: 0, skipped: 0, failed: 0, error: 'Tree or BlossomStore not initialized' });
137
+ return;
138
+ }
139
+ const cid = cidKey ? { hash: cidHash, key: cidKey } : { hash: cidHash };
140
+ const name = treeName || 'unknown';
141
+ let lastNotify = 0;
142
+ try {
143
+ console.log('[Worker] Starting blossom push for:', name);
144
+ const result = await tree.push(cid, blossomStore, {
145
+ onProgress: (current, total) => {
146
+ // Update old blossom progress if session is active
147
+ if (blossomProgress) {
148
+ blossomProgress.processedChunks = current;
149
+ blossomProgress.totalChunks = total;
150
+ notifyBlossomProgress();
151
+ }
152
+ // Also emit new-style progress events (throttled)
153
+ const now = Date.now();
154
+ if (now - lastNotify >= 100 || current === total) {
155
+ lastNotify = now;
156
+ respond({ type: 'blossomPushProgress', treeName: name, current, total });
157
+ }
158
+ },
159
+ });
160
+ console.log('[Worker] Blossom push complete:', name, result);
161
+ // Extract error messages from failed uploads
162
+ const errorMessages = result.errors.map(e => e.error.message);
163
+ // Emit completion event
164
+ respond({
165
+ type: 'blossomPushComplete',
166
+ treeName: name,
167
+ pushed: result.pushed,
168
+ skipped: result.skipped,
169
+ failed: result.failed,
170
+ });
171
+ respond({
172
+ type: 'blossomPushResult',
173
+ id,
174
+ pushed: result.pushed,
175
+ skipped: result.skipped,
176
+ failed: result.failed,
177
+ errors: errorMessages.length > 0 ? errorMessages : undefined,
178
+ });
179
+ }
180
+ catch (err) {
181
+ const error = getErrorMessage(err);
182
+ console.error('[Worker] Blossom push failed:', error);
183
+ respond({ type: 'blossomPushResult', id, pushed: 0, skipped: 0, failed: 0, error });
184
+ }
185
+ }
186
+ /**
187
+ * Count unique bytes in first 256 bytes (entropy check)
188
+ */
189
+ function countUniqueBytes(data) {
190
+ const sampleSize = Math.min(data.length, 256);
191
+ const seen = new Set();
192
+ for (let i = 0; i < sampleSize; i++) {
193
+ seen.add(data[i]);
194
+ }
195
+ return seen.size;
196
+ }
197
+ const ENTROPY_THRESHOLD = 111; // From Blossom error "Unique: 97 (min: 111)"
198
+ /**
199
+ * Push to Blossom with progress reporting
200
+ */
201
+ async function pushToBlossomWithProgress(treeName, hash, key) {
202
+ if (!tree || !blossomStore) {
203
+ return { pushed: 0, skipped: 0, failed: 0, errors: [] };
204
+ }
205
+ const cid = key ? { hash, key } : { hash };
206
+ let lastNotify = 0;
207
+ // Pre-check: walk blocks and check entropy before uploading
208
+ let lowEntropyCount = 0;
209
+ for await (const block of tree.walkBlocks(cid)) {
210
+ if (block.data.length >= 256) {
211
+ const uniqueBytes = countUniqueBytes(block.data);
212
+ if (uniqueBytes < ENTROPY_THRESHOLD) {
213
+ lowEntropyCount++;
214
+ console.warn(`[Worker] Low entropy blob detected: ${uniqueBytes} unique bytes (min: ${ENTROPY_THRESHOLD}), size: ${block.data.length}`);
215
+ }
216
+ }
217
+ }
218
+ if (lowEntropyCount > 0) {
219
+ console.error(`[Worker] Found ${lowEntropyCount} low-entropy blobs in ${treeName} - data may not be encrypted!`);
220
+ return {
221
+ pushed: 0,
222
+ skipped: 0,
223
+ failed: lowEntropyCount,
224
+ errors: [`Data not encrypted. Found ${lowEntropyCount} blobs with low entropy (< ${ENTROPY_THRESHOLD} unique bytes). Re-encryption required.`],
225
+ };
226
+ }
227
+ const result = await tree.push(cid, blossomStore, {
228
+ onProgress: (current, total) => {
229
+ const now = Date.now();
230
+ // Throttle to every 100ms
231
+ if (now - lastNotify >= 100 || current === total) {
232
+ lastNotify = now;
233
+ respond({ type: 'blossomPushProgress', treeName, current, total });
234
+ }
235
+ },
236
+ });
237
+ const errorMessages = result.errors.map(e => e.error.message);
238
+ respond({
239
+ type: 'blossomPushComplete',
240
+ treeName,
241
+ pushed: result.pushed,
242
+ skipped: result.skipped,
243
+ failed: result.failed,
244
+ });
245
+ return { pushed: result.pushed, skipped: result.skipped, failed: result.failed, errors: errorMessages };
246
+ }
247
+ /**
248
+ * Republish all cached tree events to relays and push blobs to Blossom
249
+ * @param prefix - Optional URL-encoded prefix to filter trees by d-tag
250
+ */
251
+ async function handleRepublishTrees(id, prefix) {
252
+ if (!_config?.pubkey) {
253
+ respond({ type: 'republishResult', id, count: 0, error: 'Not logged in' });
254
+ return;
255
+ }
256
+ // Track trees with encryption errors
257
+ const encryptionErrors = [];
258
+ // Create a push function that uses tree.push() with progress reporting
259
+ const pushToBlossom = async (hash, key, treeName) => {
260
+ // If no key, tree is unencrypted - add to encryption errors list
261
+ if (!key) {
262
+ console.log(`[Worker] Tree ${treeName} has no key - needs encryption`);
263
+ encryptionErrors.push(treeName || 'unknown');
264
+ return { pushed: 0, skipped: 0, failed: 1 };
265
+ }
266
+ const result = await pushToBlossomWithProgress(treeName || 'unknown', hash, key);
267
+ // Check for encryption errors
268
+ if (result.failed > 0 && result.errors.some(e => e.includes('not encrypted') || e.includes('Unique:'))) {
269
+ encryptionErrors.push(treeName || 'unknown');
270
+ }
271
+ return result;
272
+ };
273
+ try {
274
+ const count = await republishTrees(_config.pubkey, signEvent, pushToBlossom, prefix);
275
+ respond({
276
+ type: 'republishResult',
277
+ id,
278
+ count,
279
+ encryptionErrors: encryptionErrors.length > 0 ? encryptionErrors : undefined,
280
+ });
281
+ }
282
+ catch (err) {
283
+ const error = getErrorMessage(err);
284
+ console.error('[Worker] Republish failed:', error);
285
+ respond({ type: 'republishResult', id, count: 0, error });
286
+ }
287
+ }
288
+ async function handleRepublishTree(id, pubkey, treeName) {
289
+ try {
290
+ const success = await republishTree(pubkey, treeName);
291
+ respond({ type: 'bool', id, value: success });
292
+ }
293
+ catch (err) {
294
+ const error = getErrorMessage(err);
295
+ console.error('[Worker] Republish tree failed:', error);
296
+ respond({ type: 'bool', id, value: false, error });
297
+ }
298
+ }
299
+ // Follows set for WebRTC peer classification
300
+ let followsSet = new Set();
301
+ function getFollows() {
302
+ return followsSet;
303
+ }
304
+ // SocialGraph state
305
+ const KIND_CONTACTS = 3; // kind:3 = contact list
306
+ let socialGraph = new SocialGraph(DEFAULT_BOOTSTRAP_PUBKEY);
307
+ let socialGraphVersion = 0;
308
+ let socialGraphSaveTimeout = null;
309
+ let socialGraphDirty = false;
310
+ function notifySocialGraphVersionUpdate() {
311
+ socialGraphVersion++;
312
+ socialGraphDirty = true;
313
+ self.postMessage({ type: 'socialGraphVersion', version: socialGraphVersion });
314
+ // Debounce save - save 5 seconds after last update
315
+ if (socialGraphSaveTimeout) {
316
+ clearTimeout(socialGraphSaveTimeout);
317
+ }
318
+ socialGraphSaveTimeout = setTimeout(() => {
319
+ saveSocialGraph();
320
+ }, 5000);
321
+ }
322
+ /**
323
+ * Save social graph to IndexedDB
324
+ */
325
+ async function saveSocialGraph() {
326
+ if (!socialGraphDirty)
327
+ return;
328
+ try {
329
+ const data = await socialGraph.toBinary();
330
+ await socialGraphDB.socialGraph.put({
331
+ id: 'main',
332
+ data,
333
+ updatedAt: Date.now(),
334
+ });
335
+ socialGraphDirty = false;
336
+ console.log('[Worker] Social graph saved to IndexedDB:', data.byteLength, 'bytes');
337
+ }
338
+ catch (err) {
339
+ console.error('[Worker] Failed to save social graph:', err);
340
+ }
341
+ }
342
+ // Track if social graph is still loading - don't subscribe until done
343
+ let socialGraphLoading = false;
344
+ /**
345
+ * Load social graph from IndexedDB
346
+ * Waits for completion (no timeout race condition)
347
+ */
348
+ async function loadSocialGraph(rootPubkey) {
349
+ socialGraphLoading = true;
350
+ try {
351
+ const row = await socialGraphDB.socialGraph.get('main');
352
+ if (row?.data) {
353
+ const loaded = await SocialGraph.fromBinary(rootPubkey, row.data);
354
+ await socialGraph.merge(loaded);
355
+ console.log('[Worker] Loaded social graph from IndexedDB, age:', Math.round((Date.now() - row.updatedAt) / 1000), 'seconds, size:', socialGraph.size());
356
+ return true;
357
+ }
358
+ console.log('[Worker] No saved social graph found, starting fresh');
359
+ return false;
360
+ }
361
+ catch (err) {
362
+ console.error('[Worker] Failed to load social graph:', err);
363
+ return false;
364
+ }
365
+ finally {
366
+ socialGraphLoading = false;
367
+ }
368
+ }
369
+ // Set up response sender for signing module
370
+ setResponseSender((msg) => self.postMessage(msg));
371
+ // ============================================================================
372
+ // Message Handler
373
+ // ============================================================================
374
+ self.onmessage = async (e) => {
375
+ const msg = e.data;
376
+ try {
377
+ switch (msg.type) {
378
+ // Lifecycle
379
+ case 'init':
380
+ await handleInit(msg.id, msg.config);
381
+ break;
382
+ case 'close':
383
+ await handleClose(msg.id);
384
+ break;
385
+ case 'setIdentity':
386
+ handleSetIdentity(msg.id, msg.pubkey, msg.nsec);
387
+ break;
388
+ // Heartbeat
389
+ case 'ping':
390
+ respond({ type: 'pong', id: msg.id });
391
+ break;
392
+ // Store operations
393
+ case 'get':
394
+ await handleGet(msg.id, msg.hash);
395
+ break;
396
+ case 'put':
397
+ await handlePut(msg.id, msg.hash, msg.data);
398
+ break;
399
+ case 'has':
400
+ await handleHas(msg.id, msg.hash);
401
+ break;
402
+ case 'delete':
403
+ await handleDelete(msg.id, msg.hash);
404
+ break;
405
+ // Tree operations
406
+ case 'readFile':
407
+ await handleReadFile(msg.id, msg.cid);
408
+ break;
409
+ case 'readFileRange':
410
+ await handleReadFileRange(msg.id, msg.cid, msg.start, msg.end);
411
+ break;
412
+ case 'readFileStream':
413
+ await handleReadFileStream(msg.id, msg.cid);
414
+ break;
415
+ case 'writeFile':
416
+ await handleWriteFile(msg.id, msg.parentCid, msg.path, msg.data);
417
+ break;
418
+ case 'deleteFile':
419
+ await handleDeleteFile(msg.id, msg.parentCid, msg.path);
420
+ break;
421
+ case 'listDir':
422
+ await handleListDir(msg.id, msg.cid);
423
+ break;
424
+ case 'resolveRoot':
425
+ await handleResolveRoot(msg.id, msg.npub, msg.path);
426
+ break;
427
+ case 'setTreeRootCache':
428
+ await handleSetTreeRootCache(msg.id, msg.npub, msg.treeName, msg.hash, msg.key, msg.visibility);
429
+ break;
430
+ case 'getTreeRootInfo':
431
+ await handleGetTreeRootInfo(msg.id, msg.npub, msg.treeName);
432
+ break;
433
+ case 'mergeTreeRootKey':
434
+ await handleMergeTreeRootKey(msg.id, msg.npub, msg.treeName, msg.hash, msg.key);
435
+ break;
436
+ case 'subscribeTreeRoots':
437
+ await handleSubscribeTreeRoots(msg.id, msg.pubkey);
438
+ break;
439
+ case 'unsubscribeTreeRoots':
440
+ await handleUnsubscribeTreeRoots(msg.id, msg.pubkey);
441
+ break;
442
+ // Nostr (TODO: Phase 2)
443
+ case 'subscribe':
444
+ await handleSubscribe(msg.id, msg.filters);
445
+ break;
446
+ case 'unsubscribe':
447
+ await handleUnsubscribe(msg.id, msg.subId);
448
+ break;
449
+ case 'publish':
450
+ await handlePublish(msg.id, msg.event);
451
+ break;
452
+ // Media streaming
453
+ case 'registerMediaPort':
454
+ registerMediaPort(msg.port, msg.debug);
455
+ break;
456
+ // Stats
457
+ case 'getPeerStats':
458
+ await handleGetPeerStats(msg.id);
459
+ break;
460
+ case 'getRelayStats':
461
+ await handleGetRelayStats(msg.id);
462
+ break;
463
+ case 'getStorageStats':
464
+ await handleGetStorageStats(msg.id);
465
+ break;
466
+ // WebRTC pool configuration
467
+ case 'setWebRTCPools':
468
+ if (webrtc) {
469
+ webrtc.setPoolConfig(msg.pools);
470
+ // Start WebRTC on first pool config (waits for settings to load)
471
+ if (!webrtcStarted) {
472
+ webrtc.start();
473
+ webrtcStarted = true;
474
+ console.log('[Worker] WebRTC controller started (after pool config)');
475
+ }
476
+ }
477
+ respond({ type: 'void', id: msg.id });
478
+ break;
479
+ case 'sendWebRTCHello':
480
+ webrtc?.broadcastHello();
481
+ respond({ type: 'void', id: msg.id });
482
+ break;
483
+ case 'setFollows':
484
+ followsSet = new Set(msg.follows);
485
+ console.log('[Worker] Follows updated:', followsSet.size, 'pubkeys:', Array.from(followsSet).map(p => p.slice(0, 16)));
486
+ respond({ type: 'void', id: msg.id });
487
+ break;
488
+ // Blossom configuration
489
+ case 'setBlossomServers':
490
+ if (_config) {
491
+ _config.blossomServers = msg.servers;
492
+ }
493
+ if (fallbackStore && blossomStore) {
494
+ fallbackStore.removeFallback(blossomStore);
495
+ }
496
+ if (msg.servers && msg.servers.length > 0) {
497
+ blossomStore = createTrackedBlossomStore(msg.servers);
498
+ fallbackStore?.addFallback(blossomStore);
499
+ console.log('[Worker] BlossomStore updated with', msg.servers.length, 'servers');
500
+ }
501
+ else {
502
+ blossomStore = null;
503
+ console.log('[Worker] BlossomStore disabled (no servers configured)');
504
+ }
505
+ emitBlossomBandwidthSnapshot();
506
+ respond({ type: 'void', id: msg.id });
507
+ break;
508
+ // Storage quota configuration
509
+ case 'setStorageMaxBytes':
510
+ storageMaxBytes = msg.maxBytes;
511
+ console.log('[Worker] Storage limit set to', Math.round(storageMaxBytes / 1024 / 1024), 'MB');
512
+ // Run eviction check immediately when limit changes
513
+ runEvictionCheck();
514
+ respond({ type: 'void', id: msg.id });
515
+ break;
516
+ // Relay configuration
517
+ case 'setRelays':
518
+ await ndkSetRelays(msg.relays);
519
+ // Re-subscribe to WebRTC signaling on new relays
520
+ resubscribeWebRTCSignaling();
521
+ console.log('[Worker] Relays updated to', msg.relays.length, 'relays');
522
+ respond({ type: 'void', id: msg.id });
523
+ break;
524
+ // Blossom upload
525
+ case 'pushToBlossom':
526
+ handlePushToBlossom(msg.id, msg.cidHash, msg.cidKey, msg.treeName);
527
+ break;
528
+ case 'startBlossomSession':
529
+ {
530
+ const serverUrls = blossomStore?.getWriteServers() || [];
531
+ initBlossomProgress(msg.sessionId, msg.totalChunks, serverUrls);
532
+ console.log('[Worker] Blossom session started:', msg.sessionId, 'chunks:', msg.totalChunks, 'servers:', serverUrls.length);
533
+ respond({ type: 'void', id: msg.id });
534
+ }
535
+ break;
536
+ case 'endBlossomSession':
537
+ clearBlossomProgress();
538
+ console.log('[Worker] Blossom session ended');
539
+ respond({ type: 'void', id: msg.id });
540
+ break;
541
+ // Republish cached tree events
542
+ case 'republishTrees':
543
+ handleRepublishTrees(msg.id, msg.prefix);
544
+ break;
545
+ case 'republishTree':
546
+ handleRepublishTree(msg.id, msg.pubkey, msg.treeName);
547
+ break;
548
+ // SocialGraph operations
549
+ case 'initSocialGraph':
550
+ handleInitSocialGraph(msg.id, msg.rootPubkey);
551
+ break;
552
+ case 'setSocialGraphRoot':
553
+ handleSetSocialGraphRoot(msg.id, msg.pubkey);
554
+ break;
555
+ case 'handleSocialGraphEvents':
556
+ handleSocialGraphEvents(msg.id, msg.events);
557
+ break;
558
+ case 'getFollowDistance':
559
+ handleGetFollowDistance(msg.id, msg.pubkey);
560
+ break;
561
+ case 'isFollowing':
562
+ handleIsFollowing(msg.id, msg.follower, msg.followed);
563
+ break;
564
+ case 'getFollows':
565
+ handleGetFollowsList(msg.id, msg.pubkey);
566
+ break;
567
+ case 'getFollowers':
568
+ handleGetFollowers(msg.id, msg.pubkey);
569
+ break;
570
+ case 'getFollowedByFriends':
571
+ handleGetFollowedByFriends(msg.id, msg.pubkey);
572
+ break;
573
+ case 'fetchUserFollows':
574
+ handleFetchUserFollows(msg.id, msg.pubkey);
575
+ break;
576
+ case 'fetchUserFollowers':
577
+ handleFetchUserFollowers(msg.id, msg.pubkey);
578
+ break;
579
+ case 'getSocialGraphSize':
580
+ handleGetSocialGraphSize(msg.id);
581
+ break;
582
+ // NIP-07 responses from main thread
583
+ case 'signed':
584
+ handleSignedResponse(msg.id, msg.event, msg.error);
585
+ break;
586
+ case 'encrypted':
587
+ handleEncryptedResponse(msg.id, msg.ciphertext, msg.error);
588
+ break;
589
+ case 'decrypted':
590
+ handleDecryptedResponse(msg.id, msg.plaintext, msg.error);
591
+ break;
592
+ // WebRTC proxy events from main thread
593
+ case 'rtc:peerCreated':
594
+ case 'rtc:peerStateChange':
595
+ case 'rtc:peerClosed':
596
+ case 'rtc:offerCreated':
597
+ case 'rtc:answerCreated':
598
+ case 'rtc:descriptionSet':
599
+ case 'rtc:iceCandidate':
600
+ case 'rtc:iceGatheringComplete':
601
+ case 'rtc:dataChannelOpen':
602
+ case 'rtc:dataChannelMessage':
603
+ case 'rtc:dataChannelClose':
604
+ case 'rtc:dataChannelError':
605
+ webrtc?.handleProxyEvent(msg);
606
+ break;
607
+ default:
608
+ console.warn('[Worker] Unknown message type:', msg.type);
609
+ }
610
+ }
611
+ catch (err) {
612
+ const error = getErrorMessage(err);
613
+ console.error('[Worker] Error handling message:', error);
614
+ respond({ type: 'error', id: msg.id, error });
615
+ }
616
+ };
617
+ // ============================================================================
618
+ // Response Helper
619
+ // ============================================================================
620
+ function respond(msg) {
621
+ self.postMessage(msg);
622
+ }
623
+ function respondWithTransfer(msg, transfer) {
624
+ // Worker scope postMessage takes options object with transfer property
625
+ self.postMessage(msg, { transfer });
626
+ }
627
+ // ============================================================================
628
+ // Lifecycle Handlers
629
+ // ============================================================================
630
+ async function handleInit(id, cfg) {
631
+ try {
632
+ _config = cfg;
633
+ blossomBandwidthTracker.reset();
634
+ emitBlossomBandwidthSnapshot();
635
+ // Initialize Dexie/IndexedDB store
636
+ const storeName = cfg.storeName || 'hashtree-worker';
637
+ store = new DexieStore(storeName);
638
+ // Initialize FallbackStore with local store (WebRTC and Blossom added dynamically)
639
+ fallbackStore = new FallbackStore({ primary: store, fallbacks: [] });
640
+ // Initialize HashTree with fallback store (enables remote fetching)
641
+ tree = new HashTree({ store: fallbackStore });
642
+ // Initialize tree root cache
643
+ initTreeRootCache(store);
644
+ console.log('[Worker] Initialized with DexieStore:', storeName);
645
+ // Initialize identity
646
+ initIdentity(cfg.pubkey, cfg.nsec);
647
+ console.log('[Worker] User pubkey:', cfg.pubkey.slice(0, 16) + '...');
648
+ // Initialize Blossom store with signer for uploads and progress callback
649
+ if (cfg.blossomServers && cfg.blossomServers.length > 0) {
650
+ blossomStore = createTrackedBlossomStore(cfg.blossomServers);
651
+ // Add Blossom to fallback chain for remote chunk fetching
652
+ fallbackStore?.addFallback(blossomStore);
653
+ console.log('[Worker] Initialized BlossomStore with', cfg.blossomServers.length, 'servers');
654
+ }
655
+ // Initialize NDK with relays, cache, and nostr-wasm verification
656
+ await initNdk(cfg.relays, {
657
+ pubkey: cfg.pubkey,
658
+ nsec: cfg.nsec,
659
+ });
660
+ console.log('[Worker] NDK initialized with', cfg.relays.length, 'relays');
661
+ // Set up unified event handler for all subscriptions
662
+ setOnEvent(async (subId, event) => {
663
+ const isTreeRoot = isTreeRootEvent(event);
664
+ if (isTreeRoot) {
665
+ try {
666
+ await handleTreeRootEvent(event);
667
+ }
668
+ catch (err) {
669
+ console.warn('[Worker] Failed to handle tree root event:', err);
670
+ }
671
+ }
672
+ // Forward to main thread
673
+ respond({ type: 'event', subId, event });
674
+ // Route to WebRTC handler
675
+ if (subId.startsWith('webrtc-')) {
676
+ handleWebRTCSignalingEvent(event);
677
+ }
678
+ // Route to SocialGraph handler (all socialgraph-* subscriptions)
679
+ if (subId.startsWith('socialgraph-') && event.kind === KIND_CONTACTS) {
680
+ handleSocialGraphEvent(event);
681
+ }
682
+ });
683
+ // Set up tree root notification callback to notify main thread
684
+ setTreeRootNotifyCallback((npub, treeName, record) => {
685
+ respond({
686
+ type: 'treeRootUpdate',
687
+ npub,
688
+ treeName,
689
+ hash: record.hash,
690
+ key: record.key,
691
+ visibility: record.visibility,
692
+ updatedAt: record.updatedAt,
693
+ encryptedKey: record.encryptedKey,
694
+ keyId: record.keyId,
695
+ selfEncryptedKey: record.selfEncryptedKey,
696
+ selfEncryptedLinkKey: record.selfEncryptedLinkKey,
697
+ });
698
+ });
699
+ // Set up EOSE handler
700
+ setOnEose((subId) => {
701
+ respond({ type: 'eose', subId });
702
+ });
703
+ // Initialize WebRTC controller (RTCPeerConnection runs in main thread proxy)
704
+ webrtc = new WebRTCController({
705
+ pubkey: cfg.pubkey,
706
+ localStore: store,
707
+ sendCommand: (cmd) => respond(cmd),
708
+ sendSignaling: async (msg, recipientPubkey) => {
709
+ await sendWebRTCSignaling(msg, recipientPubkey);
710
+ },
711
+ getFollows, // Used to classify peers into follows/other pools
712
+ debug: false,
713
+ requestTimeout: WEBRTC_REQUEST_TIMEOUT_MS,
714
+ });
715
+ // Add WebRTC to fallback chain for remote chunk fetching
716
+ fallbackStore?.addFallback(webrtc);
717
+ // Initialize media handler with the tree
718
+ initMediaHandler(tree);
719
+ // Initialize WebRTC signaling with the controller
720
+ initWebRTCSignaling(webrtc);
721
+ // Subscribe to WebRTC signaling events (kind 25050)
722
+ setupWebRTCSignalingSubscription(cfg.pubkey);
723
+ // WebRTC starts when pool config is received (waits for settings to load)
724
+ console.log('[Worker] WebRTC controller ready (waiting for pool config)');
725
+ // Initialize SocialGraph with user's pubkey as root
726
+ socialGraph = new SocialGraph(cfg.pubkey);
727
+ console.log('[Worker] SocialGraph initialized with root:', cfg.pubkey.slice(0, 16) + '...');
728
+ // Respond ready immediately so page can render
729
+ respond({ type: 'ready' });
730
+ // Load persisted social graph from IndexedDB, THEN set up subscriptions
731
+ // This ensures we only subscribe for users whose follow lists we don't already have
732
+ const loaded = await loadSocialGraph(cfg.pubkey);
733
+ if (loaded) {
734
+ notifySocialGraphVersionUpdate();
735
+ console.log('[Worker] Social graph loaded, setting up subscriptions with existing data');
736
+ }
737
+ // Subscribe to kind:3 (contact list) events for social graph
738
+ // Do this AFTER load so we can skip users we already have
739
+ setupSocialGraphSubscription(cfg.pubkey);
740
+ }
741
+ catch (err) {
742
+ const error = getErrorMessage(err);
743
+ respond({ type: 'error', id, error });
744
+ }
745
+ }
746
+ // NOTE: WebRTC cannot run in workers - RTCPeerConnection is not available
747
+ // See: https://github.com/w3c/webrtc-extensions/issues/77
748
+ // WebRTC must run in main thread and proxy to worker for storage
749
+ /**
750
+ * Handle identity change (account switch)
751
+ */
752
+ function handleSetIdentity(id, pubkey, nsec) {
753
+ setIdentity(pubkey, nsec);
754
+ console.log('[Worker] Identity updated:', pubkey.slice(0, 16) + '...');
755
+ if (_config) {
756
+ _config.pubkey = pubkey;
757
+ _config.nsec = nsec;
758
+ }
759
+ if (webrtc) {
760
+ webrtc.setIdentity(pubkey);
761
+ setupWebRTCSignalingSubscription(pubkey);
762
+ }
763
+ // Update SocialGraph root
764
+ socialGraph.setRoot(pubkey);
765
+ notifySocialGraphVersionUpdate();
766
+ console.log('[Worker] SocialGraph root updated:', pubkey.slice(0, 16) + '...');
767
+ // Reinitialize Blossom with new signer and progress callback
768
+ const previousBlossomStore = blossomStore;
769
+ if (fallbackStore && previousBlossomStore) {
770
+ fallbackStore.removeFallback(previousBlossomStore);
771
+ }
772
+ if (_config?.blossomServers && _config.blossomServers.length > 0) {
773
+ blossomStore = createTrackedBlossomStore(_config.blossomServers);
774
+ if (fallbackStore) {
775
+ fallbackStore.addFallback(blossomStore);
776
+ }
777
+ }
778
+ else {
779
+ blossomStore = null;
780
+ }
781
+ // NOTE: WebRTC not available in workers
782
+ respond({ type: 'void', id });
783
+ }
784
+ /**
785
+ * Create Blossom signer using current identity
786
+ */
787
+ function createBlossomSigner() {
788
+ return async (event) => {
789
+ const signed = await signEvent({
790
+ kind: event.kind,
791
+ created_at: event.created_at,
792
+ content: event.content,
793
+ tags: event.tags,
794
+ });
795
+ return signed;
796
+ };
797
+ }
798
+ function createTrackedBlossomStore(servers) {
799
+ return new BlossomStore({
800
+ servers,
801
+ signer: createBlossomSigner(),
802
+ onUploadProgress: updateBlossomProgress,
803
+ logger: (entry) => {
804
+ blossomBandwidthTracker.apply(entry);
805
+ },
806
+ });
807
+ }
808
+ function emitBlossomBandwidthSnapshot() {
809
+ respond({ type: 'blossomBandwidth', stats: blossomBandwidthTracker.getStats() });
810
+ }
811
+ async function handleClose(id) {
812
+ // NOTE: WebRTC not available in workers
813
+ // Close NDK connections
814
+ closeNdk();
815
+ // Clear identity
816
+ clearIdentity();
817
+ // Clear caches
818
+ clearMemoryCache();
819
+ store = null;
820
+ tree = null;
821
+ blossomStore = null;
822
+ blossomBandwidthTracker.reset();
823
+ _config = null;
824
+ respond({ type: 'void', id });
825
+ }
826
+ // ============================================================================
827
+ // Store Handlers (low-level)
828
+ // ============================================================================
829
+ async function handleGet(id, hash) {
830
+ if (!store) {
831
+ respond({ type: 'result', id, error: 'Store not initialized' });
832
+ return;
833
+ }
834
+ const raceForData = (promises) => {
835
+ if (promises.length === 0)
836
+ return Promise.resolve(null);
837
+ return new Promise((resolve) => {
838
+ let remaining = promises.length;
839
+ let settled = false;
840
+ for (const promise of promises) {
841
+ promise.then((result) => {
842
+ if (settled)
843
+ return;
844
+ if (result) {
845
+ settled = true;
846
+ resolve(result);
847
+ return;
848
+ }
849
+ remaining -= 1;
850
+ if (remaining === 0)
851
+ resolve(null);
852
+ }).catch(() => {
853
+ if (settled)
854
+ return;
855
+ remaining -= 1;
856
+ if (remaining === 0)
857
+ resolve(null);
858
+ });
859
+ }
860
+ });
861
+ };
862
+ // 1. Try local store first
863
+ let data = await store.get(hash);
864
+ // 2. If not found locally, race Blossom and WebRTC (when available).
865
+ // Prefer the first non-null response to avoid WebRTC timeouts delaying Blossom.
866
+ if (!data) {
867
+ const fetches = [];
868
+ let webrtcPromise = null;
869
+ if (webrtc) {
870
+ const connectedPeers = webrtc.getConnectedCount();
871
+ if (connectedPeers > 0) {
872
+ const WEBRTC_TIMEOUT = WEBRTC_REQUEST_TIMEOUT_MS;
873
+ webrtcPromise = webrtc.get(hash);
874
+ void webrtcPromise.catch(() => { });
875
+ const timeoutPromise = new Promise(resolve => setTimeout(() => resolve(null), WEBRTC_TIMEOUT));
876
+ fetches.push(Promise.race([webrtcPromise, timeoutPromise]));
877
+ }
878
+ }
879
+ if (blossomStore) {
880
+ fetches.push(blossomStore.get(hash).catch(() => null));
881
+ }
882
+ data = await raceForData(fetches);
883
+ if (data) {
884
+ await store.put(hash, data);
885
+ }
886
+ if (!data && webrtcPromise) {
887
+ // WebRTC timed out or lost the race; let it continue in background and cache if it succeeds.
888
+ webrtcPromise.then(async (lateData) => {
889
+ if (lateData && store) {
890
+ await store.put(hash, lateData);
891
+ }
892
+ }).catch(() => { });
893
+ }
894
+ }
895
+ if (data) {
896
+ // Transfer the ArrayBuffer to avoid copying
897
+ respondWithTransfer({ type: 'result', id, data }, [data.buffer]);
898
+ }
899
+ else {
900
+ respond({ type: 'result', id, data: undefined });
901
+ }
902
+ }
903
+ async function handlePut(id, hash, data) {
904
+ if (!store) {
905
+ respond({ type: 'bool', id, value: false, error: 'Store not initialized' });
906
+ return;
907
+ }
908
+ const success = await store.put(hash, data);
909
+ respond({ type: 'bool', id, value: success });
910
+ // Trigger eviction check (debounced) after successful put
911
+ if (success) {
912
+ runEvictionCheck();
913
+ }
914
+ // Fire-and-forget push to blossom (don't await - optimistic upload)
915
+ if (blossomStore && success) {
916
+ blossomStore.put(hash, data).catch((err) => {
917
+ const hashHex = Array.from(hash.slice(0, 8)).map(b => b.toString(16).padStart(2, '0')).join('');
918
+ console.warn(`[Worker] Blossom upload failed for ${hashHex}...:`, err instanceof Error ? err.message : err);
919
+ respond({ type: 'blossomUploadError', hash: hashHex, error: getErrorMessage(err) });
920
+ });
921
+ }
922
+ }
923
+ async function handleHas(id, hash) {
924
+ if (!store) {
925
+ respond({ type: 'bool', id, value: false, error: 'Store not initialized' });
926
+ return;
927
+ }
928
+ const exists = await store.has(hash);
929
+ respond({ type: 'bool', id, value: exists });
930
+ }
931
+ async function handleDelete(id, hash) {
932
+ if (!store) {
933
+ respond({ type: 'bool', id, value: false, error: 'Store not initialized' });
934
+ return;
935
+ }
936
+ const success = await store.delete(hash);
937
+ respond({ type: 'bool', id, value: success });
938
+ }
939
+ // ============================================================================
940
+ // Tree Handlers (high-level)
941
+ // ============================================================================
942
+ async function handleReadFile(id, cid) {
943
+ if (!tree) {
944
+ respond({ type: 'result', id, error: 'Tree not initialized' });
945
+ return;
946
+ }
947
+ try {
948
+ const data = await tree.readFile(cid);
949
+ if (data) {
950
+ respondWithTransfer({ type: 'result', id, data }, [data.buffer]);
951
+ }
952
+ else {
953
+ respond({ type: 'result', id, error: 'File not found' });
954
+ }
955
+ }
956
+ catch (err) {
957
+ respond({ type: 'result', id, error: getErrorMessage(err) });
958
+ }
959
+ }
960
+ async function handleReadFileRange(id, cid, start, end) {
961
+ if (!tree) {
962
+ respond({ type: 'result', id, error: 'Tree not initialized' });
963
+ return;
964
+ }
965
+ try {
966
+ const data = await tree.readFileRange(cid, start, end);
967
+ if (data) {
968
+ respondWithTransfer({ type: 'result', id, data }, [data.buffer]);
969
+ }
970
+ else {
971
+ respond({ type: 'result', id, error: 'File not found' });
972
+ }
973
+ }
974
+ catch (err) {
975
+ respond({ type: 'result', id, error: getErrorMessage(err) });
976
+ }
977
+ }
978
+ async function handleReadFileStream(id, cid) {
979
+ if (!tree) {
980
+ respond({ type: 'streamChunk', id, chunk: new Uint8Array(0), done: true });
981
+ return;
982
+ }
983
+ try {
984
+ for await (const chunk of tree.readFileStream(cid)) {
985
+ // Send each chunk, transferring ownership
986
+ respondWithTransfer({ type: 'streamChunk', id, chunk, done: false }, [chunk.buffer]);
987
+ }
988
+ // Signal completion
989
+ respond({ type: 'streamChunk', id, chunk: new Uint8Array(0), done: true });
990
+ }
991
+ catch (err) {
992
+ respond({ type: 'error', id, error: getErrorMessage(err) });
993
+ }
994
+ }
995
+ async function handleWriteFile(id, parentCid, path, data) {
996
+ if (!tree) {
997
+ respond({ type: 'cid', id, error: 'Tree not initialized' });
998
+ return;
999
+ }
1000
+ try {
1001
+ // Parse path to get directory path and filename
1002
+ const parts = path.split('/').filter(Boolean);
1003
+ const fileName = parts.pop();
1004
+ if (!fileName) {
1005
+ respond({ type: 'cid', id, error: 'Invalid path' });
1006
+ return;
1007
+ }
1008
+ // First, create a file CID from the data
1009
+ const fileResult = await tree.putFile(data);
1010
+ const fileCid = fileResult.cid;
1011
+ // If no parent, just return the file CID (no directory structure)
1012
+ if (!parentCid) {
1013
+ respond({ type: 'cid', id, cid: fileCid });
1014
+ return;
1015
+ }
1016
+ // Add the file to the parent directory
1017
+ const newRootCid = await tree.setEntry(parentCid, parts, fileName, fileCid, data.length, 1 // LinkType.File
1018
+ );
1019
+ respond({ type: 'cid', id, cid: newRootCid });
1020
+ }
1021
+ catch (err) {
1022
+ respond({ type: 'cid', id, error: getErrorMessage(err) });
1023
+ }
1024
+ }
1025
+ async function handleDeleteFile(id, parentCid, path) {
1026
+ if (!tree) {
1027
+ respond({ type: 'cid', id, error: 'Tree not initialized' });
1028
+ return;
1029
+ }
1030
+ try {
1031
+ // Parse path to get directory path and filename
1032
+ const parts = path.split('/').filter(Boolean);
1033
+ const fileName = parts.pop();
1034
+ if (!fileName) {
1035
+ respond({ type: 'cid', id, error: 'Invalid path' });
1036
+ return;
1037
+ }
1038
+ const newCid = await tree.removeEntry(parentCid, parts, fileName);
1039
+ respond({ type: 'cid', id, cid: newCid });
1040
+ }
1041
+ catch (err) {
1042
+ respond({ type: 'cid', id, error: getErrorMessage(err) });
1043
+ }
1044
+ }
1045
+ async function handleListDir(id, cidArg) {
1046
+ if (!tree) {
1047
+ respond({ type: 'dirListing', id, error: 'Tree not initialized' });
1048
+ return;
1049
+ }
1050
+ try {
1051
+ const entries = await tree.listDirectory(cidArg);
1052
+ const dirEntries = entries.map((entry) => ({
1053
+ name: entry.name,
1054
+ isDir: entry.type === 2, // LinkType.Dir
1055
+ size: entry.size,
1056
+ cid: entry.cid,
1057
+ }));
1058
+ respond({ type: 'dirListing', id, entries: dirEntries });
1059
+ }
1060
+ catch (err) {
1061
+ respond({ type: 'dirListing', id, error: getErrorMessage(err) });
1062
+ }
1063
+ }
1064
+ async function handleResolveRoot(id, npub, path) {
1065
+ try {
1066
+ // Parse path to get tree name (first segment)
1067
+ const pathParts = path?.split('/').filter(Boolean) ?? [];
1068
+ const treeName = pathParts[0] || 'public'; // Default to 'public' tree
1069
+ // Look up in cache
1070
+ const cachedCid = await getCachedRoot(npub, treeName);
1071
+ if (!cachedCid) {
1072
+ // Not in cache - main thread should request tree root subscription
1073
+ respond({ type: 'cid', id, cid: undefined });
1074
+ return;
1075
+ }
1076
+ const subPath = pathParts.slice(1);
1077
+ if (subPath.length === 0) {
1078
+ respond({ type: 'cid', id, cid: cachedCid });
1079
+ return;
1080
+ }
1081
+ if (!tree) {
1082
+ respond({ type: 'cid', id, error: 'Tree not initialized' });
1083
+ return;
1084
+ }
1085
+ const resolved = await tree.resolvePath(cachedCid, subPath);
1086
+ respond({ type: 'cid', id, cid: resolved?.cid });
1087
+ }
1088
+ catch (err) {
1089
+ respond({ type: 'cid', id, error: getErrorMessage(err) });
1090
+ }
1091
+ }
1092
+ async function handleSetTreeRootCache(id, npub, treeName, hash, key, visibility) {
1093
+ try {
1094
+ await setCachedRoot(npub, treeName, { hash, key }, visibility);
1095
+ respond({ type: 'void', id });
1096
+ }
1097
+ catch (err) {
1098
+ respond({ type: 'void', id, error: getErrorMessage(err) });
1099
+ }
1100
+ }
1101
+ async function handleGetTreeRootInfo(id, npub, treeName) {
1102
+ try {
1103
+ const cached = await getCachedRootInfo(npub, treeName);
1104
+ if (!cached) {
1105
+ respond({ type: 'treeRootInfo', id });
1106
+ return;
1107
+ }
1108
+ respond({
1109
+ type: 'treeRootInfo',
1110
+ id,
1111
+ record: {
1112
+ hash: cached.hash,
1113
+ key: cached.key,
1114
+ visibility: cached.visibility,
1115
+ updatedAt: cached.updatedAt,
1116
+ encryptedKey: cached.encryptedKey,
1117
+ keyId: cached.keyId,
1118
+ selfEncryptedKey: cached.selfEncryptedKey,
1119
+ selfEncryptedLinkKey: cached.selfEncryptedLinkKey,
1120
+ },
1121
+ });
1122
+ }
1123
+ catch (err) {
1124
+ respond({ type: 'treeRootInfo', id, error: getErrorMessage(err) });
1125
+ }
1126
+ }
1127
+ async function handleMergeTreeRootKey(id, npub, treeName, hash, key) {
1128
+ try {
1129
+ const merged = await mergeCachedRootKey(npub, treeName, hash, key);
1130
+ respond({ type: 'bool', id, value: merged });
1131
+ }
1132
+ catch (err) {
1133
+ respond({ type: 'bool', id, value: false, error: getErrorMessage(err) });
1134
+ }
1135
+ }
1136
+ async function handleSubscribeTreeRoots(id, pubkey) {
1137
+ try {
1138
+ const normalized = normalizePubkey(pubkey);
1139
+ if (!normalized) {
1140
+ respond({ type: 'void', id, error: 'Invalid pubkey' });
1141
+ return;
1142
+ }
1143
+ const count = treeRootSubscriptionRefs.get(normalized) ?? 0;
1144
+ treeRootSubscriptionRefs.set(normalized, count + 1);
1145
+ if (count === 0) {
1146
+ subscribeToTreeRoots(normalized);
1147
+ }
1148
+ respond({ type: 'void', id });
1149
+ }
1150
+ catch (err) {
1151
+ respond({ type: 'void', id, error: getErrorMessage(err) });
1152
+ }
1153
+ }
1154
+ async function handleUnsubscribeTreeRoots(id, pubkey) {
1155
+ try {
1156
+ const normalized = normalizePubkey(pubkey);
1157
+ if (!normalized) {
1158
+ respond({ type: 'void', id, error: 'Invalid pubkey' });
1159
+ return;
1160
+ }
1161
+ const count = treeRootSubscriptionRefs.get(normalized);
1162
+ if (!count) {
1163
+ respond({ type: 'void', id });
1164
+ return;
1165
+ }
1166
+ if (count <= 1) {
1167
+ treeRootSubscriptionRefs.delete(normalized);
1168
+ unsubscribeFromTreeRoots(normalized);
1169
+ }
1170
+ else {
1171
+ treeRootSubscriptionRefs.set(normalized, count - 1);
1172
+ }
1173
+ respond({ type: 'void', id });
1174
+ }
1175
+ catch (err) {
1176
+ respond({ type: 'void', id, error: getErrorMessage(err) });
1177
+ }
1178
+ }
1179
+ function normalizePubkey(pubkey) {
1180
+ if (pubkey.startsWith('npub1')) {
1181
+ try {
1182
+ const decoded = nip19.decode(pubkey);
1183
+ if (decoded.type !== 'npub')
1184
+ return null;
1185
+ return decoded.data;
1186
+ }
1187
+ catch {
1188
+ return null;
1189
+ }
1190
+ }
1191
+ if (pubkey.length === 64) {
1192
+ return pubkey;
1193
+ }
1194
+ return null;
1195
+ }
1196
+ // ============================================================================
1197
+ // Nostr Handlers
1198
+ // ============================================================================
1199
+ async function handleSubscribe(id, filters) {
1200
+ try {
1201
+ // Use the request id as the subscription id
1202
+ ndkSubscribe(id, filters);
1203
+ respond({ type: 'void', id });
1204
+ }
1205
+ catch (err) {
1206
+ respond({ type: 'void', id, error: getErrorMessage(err) });
1207
+ }
1208
+ }
1209
+ async function handleUnsubscribe(id, subId) {
1210
+ try {
1211
+ ndkUnsubscribe(subId);
1212
+ respond({ type: 'void', id });
1213
+ }
1214
+ catch (err) {
1215
+ respond({ type: 'void', id, error: getErrorMessage(err) });
1216
+ }
1217
+ }
1218
+ async function handlePublish(id, event) {
1219
+ try {
1220
+ await ndkPublish(event);
1221
+ respond({ type: 'void', id });
1222
+ }
1223
+ catch (err) {
1224
+ respond({ type: 'void', id, error: getErrorMessage(err) });
1225
+ }
1226
+ }
1227
+ // ============================================================================
1228
+ // Stats Handlers
1229
+ // ============================================================================
1230
+ async function handleGetPeerStats(id) {
1231
+ if (!webrtc) {
1232
+ respond({ type: 'peerStats', id, stats: [] });
1233
+ return;
1234
+ }
1235
+ const controllerStats = webrtc.getPeerStats();
1236
+ const stats = controllerStats.map(s => ({
1237
+ peerId: s.peerId,
1238
+ pubkey: s.pubkey,
1239
+ connected: s.connected,
1240
+ pool: s.pool,
1241
+ requestsSent: s.requestsSent,
1242
+ requestsReceived: s.requestsReceived,
1243
+ responsesSent: s.responsesSent,
1244
+ responsesReceived: s.responsesReceived,
1245
+ bytesSent: s.bytesSent,
1246
+ bytesReceived: s.bytesReceived,
1247
+ forwardedRequests: s.forwardedRequests,
1248
+ forwardedResolved: s.forwardedResolved,
1249
+ forwardedSuppressed: s.forwardedSuppressed,
1250
+ }));
1251
+ respond({ type: 'peerStats', id, stats });
1252
+ }
1253
+ async function handleGetRelayStats(id) {
1254
+ try {
1255
+ const stats = getNdkRelayStats();
1256
+ respond({ type: 'relayStats', id, stats });
1257
+ }
1258
+ catch {
1259
+ respond({ type: 'relayStats', id, stats: [] });
1260
+ }
1261
+ }
1262
+ async function handleGetStorageStats(id) {
1263
+ try {
1264
+ if (!store) {
1265
+ respond({ type: 'storageStats', id, items: 0, bytes: 0 });
1266
+ return;
1267
+ }
1268
+ const items = await store.count();
1269
+ const bytes = await store.totalBytes();
1270
+ respond({ type: 'storageStats', id, items, bytes });
1271
+ }
1272
+ catch (e) {
1273
+ console.error('[Worker] getStorageStats error:', e);
1274
+ respond({ type: 'storageStats', id, items: 0, bytes: 0 });
1275
+ }
1276
+ }
1277
+ // ============================================================================
1278
+ // SocialGraph Handlers
1279
+ // ============================================================================
1280
+ function handleInitSocialGraph(id, rootPubkey) {
1281
+ try {
1282
+ if (rootPubkey) {
1283
+ socialGraph = new SocialGraph(rootPubkey);
1284
+ }
1285
+ const size = socialGraph.size();
1286
+ respond({ type: 'socialGraphInit', id, version: socialGraphVersion, size });
1287
+ }
1288
+ catch (err) {
1289
+ respond({ type: 'socialGraphInit', id, version: 0, size: 0, error: getErrorMessage(err) });
1290
+ }
1291
+ }
1292
+ function handleSetSocialGraphRoot(id, pubkey) {
1293
+ try {
1294
+ socialGraph.setRoot(pubkey);
1295
+ // Update followsSet for WebRTC peer classification
1296
+ const follows = socialGraph.getFollowedByUser(pubkey);
1297
+ followsSet = new Set(follows);
1298
+ notifySocialGraphVersionUpdate();
1299
+ respond({ type: 'void', id });
1300
+ }
1301
+ catch (err) {
1302
+ respond({ type: 'void', id, error: getErrorMessage(err) });
1303
+ }
1304
+ }
1305
+ function handleSocialGraphEvents(id, events) {
1306
+ try {
1307
+ let updated = false;
1308
+ for (const event of events) {
1309
+ socialGraph.handleEvent(event);
1310
+ updated = true;
1311
+ }
1312
+ if (updated) {
1313
+ notifySocialGraphVersionUpdate();
1314
+ }
1315
+ respond({ type: 'void', id });
1316
+ }
1317
+ catch (err) {
1318
+ respond({ type: 'void', id, error: getErrorMessage(err) });
1319
+ }
1320
+ }
1321
+ function handleGetFollowDistance(id, pubkey) {
1322
+ try {
1323
+ const distance = socialGraph.getFollowDistance(pubkey);
1324
+ respond({ type: 'followDistance', id, distance });
1325
+ }
1326
+ catch (err) {
1327
+ respond({ type: 'followDistance', id, distance: 1000, error: getErrorMessage(err) });
1328
+ }
1329
+ }
1330
+ function handleIsFollowing(id, follower, followed) {
1331
+ try {
1332
+ const result = socialGraph.isFollowing(follower, followed);
1333
+ respond({ type: 'isFollowingResult', id, result });
1334
+ }
1335
+ catch (err) {
1336
+ respond({ type: 'isFollowingResult', id, result: false, error: getErrorMessage(err) });
1337
+ }
1338
+ }
1339
+ function handleGetFollowsList(id, pubkey) {
1340
+ try {
1341
+ const follows = socialGraph.getFollowedByUser(pubkey);
1342
+ respond({ type: 'pubkeyList', id, pubkeys: Array.from(follows) });
1343
+ }
1344
+ catch (err) {
1345
+ respond({ type: 'pubkeyList', id, pubkeys: [], error: getErrorMessage(err) });
1346
+ }
1347
+ }
1348
+ function handleGetFollowers(id, pubkey) {
1349
+ try {
1350
+ const followers = socialGraph.getFollowersByUser(pubkey);
1351
+ respond({ type: 'pubkeyList', id, pubkeys: Array.from(followers) });
1352
+ }
1353
+ catch (err) {
1354
+ respond({ type: 'pubkeyList', id, pubkeys: [], error: getErrorMessage(err) });
1355
+ }
1356
+ }
1357
+ function handleGetFollowedByFriends(id, pubkey) {
1358
+ try {
1359
+ const friendsFollowing = socialGraph.getFollowedByFriends(pubkey);
1360
+ respond({ type: 'pubkeyList', id, pubkeys: Array.from(friendsFollowing) });
1361
+ }
1362
+ catch (err) {
1363
+ respond({ type: 'pubkeyList', id, pubkeys: [], error: getErrorMessage(err) });
1364
+ }
1365
+ }
1366
+ function handleGetSocialGraphSize(id) {
1367
+ try {
1368
+ const size = socialGraph.size();
1369
+ respond({ type: 'socialGraphSize', id, size });
1370
+ }
1371
+ catch (err) {
1372
+ respond({ type: 'socialGraphSize', id, size: 0, error: getErrorMessage(err) });
1373
+ }
1374
+ }
1375
+ /**
1376
+ * Fetch a user's follow list when visiting their profile.
1377
+ * Only fetches if we don't already have their follow list.
1378
+ */
1379
+ function handleFetchUserFollows(id, pubkey) {
1380
+ try {
1381
+ if (!pubkey || pubkey.length !== 64) {
1382
+ respond({ type: 'ack', id });
1383
+ return;
1384
+ }
1385
+ // Check if we already have their follow list
1386
+ const existingCreatedAt = socialGraph.getFollowListCreatedAt(pubkey);
1387
+ if (existingCreatedAt !== undefined) {
1388
+ respond({ type: 'ack', id });
1389
+ return;
1390
+ }
1391
+ // Subscribe to their kind:3 event
1392
+ if (!subscribedPubkeys.has(pubkey)) {
1393
+ subscribedPubkeys.add(pubkey);
1394
+ ndkSubscribe(`socialgraph-profile-${pubkey.slice(0, 8)}`, [{
1395
+ kinds: [KIND_CONTACTS],
1396
+ authors: [pubkey],
1397
+ }]);
1398
+ }
1399
+ respond({ type: 'ack', id });
1400
+ }
1401
+ catch (err) {
1402
+ respond({ type: 'ack', id, error: getErrorMessage(err) });
1403
+ }
1404
+ }
1405
+ // Track which pubkeys we've fetched followers for
1406
+ const fetchedFollowersPubkeys = new Set();
1407
+ /**
1408
+ * Fetch users who follow a given pubkey (for profile views)
1409
+ * Subscribes to recent kind:3 events with #p tag mentioning this user
1410
+ */
1411
+ function handleFetchUserFollowers(id, pubkey) {
1412
+ try {
1413
+ if (!pubkey || pubkey.length !== 64) {
1414
+ respond({ type: 'ack', id });
1415
+ return;
1416
+ }
1417
+ // Only fetch once per pubkey per session
1418
+ if (fetchedFollowersPubkeys.has(pubkey)) {
1419
+ respond({ type: 'ack', id });
1420
+ return;
1421
+ }
1422
+ // Check if we already have some followers data (>10 means we likely have enough)
1423
+ const existingFollowers = socialGraph.followerCount(pubkey);
1424
+ if (existingFollowers > 10) {
1425
+ respond({ type: 'ack', id });
1426
+ return;
1427
+ }
1428
+ fetchedFollowersPubkeys.add(pubkey);
1429
+ // Subscribe to recent kind:3 events that mention this user
1430
+ // Limit to 100 to avoid overwhelming the connection
1431
+ ndkSubscribe(`socialgraph-followers-${pubkey.slice(0, 8)}`, [{
1432
+ kinds: [KIND_CONTACTS],
1433
+ '#p': [pubkey],
1434
+ limit: 100,
1435
+ }]);
1436
+ respond({ type: 'ack', id });
1437
+ }
1438
+ catch (err) {
1439
+ respond({ type: 'ack', id, error: getErrorMessage(err) });
1440
+ }
1441
+ }
1442
+ // ============================================================================
1443
+ // SocialGraph Subscription
1444
+ // ============================================================================
1445
+ // Track latest event per pubkey to avoid processing old events (for social graph)
1446
+ // Limited to 1000 entries to prevent memory leak from encountering many unique pubkeys
1447
+ const socialGraphLatestByPubkey = new LRUCache(1000);
1448
+ // Track which pubkeys we've already subscribed to for follow lists
1449
+ const subscribedPubkeys = new Set();
1450
+ // Maximum total users in social graph before stopping crawl
1451
+ const MAX_SOCIAL_GRAPH_SIZE = 10000;
1452
+ /**
1453
+ * Handle incoming SocialGraph event (kind:3)
1454
+ */
1455
+ function handleSocialGraphEvent(event) {
1456
+ const rootPubkey = socialGraph.getRoot();
1457
+ const prevTime = socialGraphLatestByPubkey.get(event.pubkey) || 0;
1458
+ if (event.created_at > prevTime) {
1459
+ socialGraphLatestByPubkey.set(event.pubkey, event.created_at);
1460
+ // allowUnknownAuthors=true lets us track followers of any user, not just those connected to root
1461
+ socialGraph.handleEvent(event, true);
1462
+ // If this is the root user's contact list, update followsSet for WebRTC
1463
+ // and subscribe to kind:3 from their follows
1464
+ if (event.pubkey === rootPubkey) {
1465
+ try {
1466
+ const follows = socialGraph.getFollowedByUser(rootPubkey);
1467
+ followsSet = new Set(follows);
1468
+ console.log('[Worker] Follows updated:', followsSet.size, 'pubkeys');
1469
+ // Subscribe to kind:3 from root's follows (depth 1)
1470
+ subscribeToFollowsContactLists(Array.from(follows), 1);
1471
+ // If user has few follows, also use bootstrap user's follows
1472
+ if (follows.size < 5 && rootPubkey !== DEFAULT_BOOTSTRAP_PUBKEY) {
1473
+ const bootstrapFollows = socialGraph.getFollowedByUser(DEFAULT_BOOTSTRAP_PUBKEY);
1474
+ if (bootstrapFollows.size > 0) {
1475
+ subscribeToFollowsContactLists(Array.from(bootstrapFollows), 1);
1476
+ }
1477
+ }
1478
+ }
1479
+ catch (err) {
1480
+ console.warn('[Worker] Error getting follows for root:', err);
1481
+ }
1482
+ // Broadcast hello so peers can re-classify with updated follows
1483
+ webrtc?.broadcastHello();
1484
+ }
1485
+ else {
1486
+ // For non-root users at depth 1, subscribe to their follows (depth 2)
1487
+ // But only if graph isn't already at max size
1488
+ if (socialGraph.size() < MAX_SOCIAL_GRAPH_SIZE) {
1489
+ try {
1490
+ const distance = socialGraph.getFollowDistance(event.pubkey);
1491
+ if (distance === 1) {
1492
+ const theirFollows = socialGraph.getFollowedByUser(event.pubkey);
1493
+ subscribeToFollowsContactLists(Array.from(theirFollows), 2);
1494
+ }
1495
+ }
1496
+ catch (err) {
1497
+ console.warn('[Worker] Error getting follows for user:', event.pubkey.slice(0, 16), err);
1498
+ }
1499
+ }
1500
+ }
1501
+ notifySocialGraphVersionUpdate();
1502
+ }
1503
+ }
1504
+ /**
1505
+ * Subscribe to kind:3 contact lists from a set of pubkeys
1506
+ * Only subscribes for users whose follow list we don't already have (getFollowedByUser returns empty)
1507
+ */
1508
+ function subscribeToFollowsContactLists(pubkeys, depth) {
1509
+ // Don't subscribe while still loading from IndexedDB
1510
+ if (socialGraphLoading) {
1511
+ return;
1512
+ }
1513
+ // Stop crawling if graph is already large enough
1514
+ const currentSize = socialGraph.size();
1515
+ if (currentSize >= MAX_SOCIAL_GRAPH_SIZE) {
1516
+ return;
1517
+ }
1518
+ const remainingCapacity = MAX_SOCIAL_GRAPH_SIZE - currentSize;
1519
+ const newPubkeys = [];
1520
+ for (const pk of pubkeys) {
1521
+ if (subscribedPubkeys.has(pk))
1522
+ continue;
1523
+ if (newPubkeys.length >= remainingCapacity)
1524
+ break;
1525
+ // Only fetch if we don't have their follow list (like iris-client does)
1526
+ try {
1527
+ const existingFollows = socialGraph.getFollowedByUser(pk);
1528
+ if (existingFollows.size > 0) {
1529
+ subscribedPubkeys.add(pk); // Mark as "done" so we don't check again
1530
+ continue;
1531
+ }
1532
+ }
1533
+ catch {
1534
+ // Invalid pubkey in graph, skip
1535
+ continue;
1536
+ }
1537
+ subscribedPubkeys.add(pk);
1538
+ newPubkeys.push(pk);
1539
+ }
1540
+ if (newPubkeys.length > 0) {
1541
+ console.log(`[Worker] Subscribing to kind:3 from ${newPubkeys.length} missing pubkeys at depth ${depth} (graph size: ${currentSize})`);
1542
+ // Subscribe in batches to avoid overwhelming relays
1543
+ const batchSize = 50;
1544
+ for (let i = 0; i < newPubkeys.length; i += batchSize) {
1545
+ const batch = newPubkeys.slice(i, i + batchSize);
1546
+ ndkSubscribe(`socialgraph-depth${depth}-${i}`, [{
1547
+ kinds: [KIND_CONTACTS],
1548
+ authors: batch,
1549
+ }]);
1550
+ }
1551
+ }
1552
+ }
1553
+ /**
1554
+ * Subscribe to kind:3 contact list events for social graph
1555
+ */
1556
+ function setupSocialGraphSubscription(rootPubkey) {
1557
+ if (!rootPubkey || rootPubkey.length !== 64) {
1558
+ console.warn('[Worker] Invalid pubkey for social graph subscription:', rootPubkey);
1559
+ return;
1560
+ }
1561
+ // Clear previous subscriptions tracking when root changes
1562
+ subscribedPubkeys.clear();
1563
+ subscribedPubkeys.add(rootPubkey);
1564
+ // NOTE: Don't call setOnEvent here - use the unified handler set up in handleInit
1565
+ // Check if we already have the root user's follows
1566
+ const rootFollows = socialGraph.getFollowedByUser(rootPubkey);
1567
+ const hasRootFollows = rootFollows.size > 0;
1568
+ // Subscribe to root user's contact list if we don't have it
1569
+ if (!hasRootFollows) {
1570
+ const authors = [rootPubkey];
1571
+ if (rootPubkey !== DEFAULT_BOOTSTRAP_PUBKEY) {
1572
+ authors.push(DEFAULT_BOOTSTRAP_PUBKEY);
1573
+ subscribedPubkeys.add(DEFAULT_BOOTSTRAP_PUBKEY);
1574
+ }
1575
+ ndkSubscribe('socialgraph-contacts', [{
1576
+ kinds: [KIND_CONTACTS],
1577
+ authors,
1578
+ }]);
1579
+ }
1580
+ console.log('[Worker] Subscribed to kind:3 events for social graph');
1581
+ }
1582
+ //# sourceMappingURL=worker.js.map