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