@hashtree/worker 0.2.0 → 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 (259) hide show
  1. package/package.json +7 -3
  2. package/src/app-runtime.ts +393 -0
  3. package/src/capabilities/blossomBandwidthTracker.ts +74 -0
  4. package/src/capabilities/blossomTransport.ts +179 -0
  5. package/src/capabilities/connectivity.ts +54 -0
  6. package/src/capabilities/idbStorage.ts +94 -0
  7. package/src/capabilities/meshRouterStore.ts +426 -0
  8. package/src/capabilities/rootResolver.ts +497 -0
  9. package/src/client-id.ts +137 -0
  10. package/src/client.ts +501 -0
  11. package/{dist/entry.js → src/entry.ts} +1 -1
  12. package/src/htree-path.ts +53 -0
  13. package/src/htree-url.ts +156 -0
  14. package/src/index.ts +76 -0
  15. package/src/mediaStreaming.ts +64 -0
  16. package/src/p2p/boundedQueue.ts +168 -0
  17. package/src/p2p/errorMessage.ts +6 -0
  18. package/src/p2p/index.ts +48 -0
  19. package/src/p2p/lruCache.ts +78 -0
  20. package/src/p2p/meshQueryRouter.ts +361 -0
  21. package/src/p2p/protocol.ts +11 -0
  22. package/src/p2p/queryForwardingMachine.ts +197 -0
  23. package/src/p2p/signaling.ts +284 -0
  24. package/src/p2p/uploadRateLimiter.ts +85 -0
  25. package/src/p2p/webrtcController.ts +1168 -0
  26. package/src/p2p/webrtcProxy.ts +519 -0
  27. package/src/privacyGuards.ts +31 -0
  28. package/src/protocol.ts +124 -0
  29. package/src/relay/identity.ts +86 -0
  30. package/src/relay/mediaHandler.ts +1633 -0
  31. package/src/relay/ndk.ts +590 -0
  32. package/{dist/relay/nostr-wasm.js → src/relay/nostr-wasm.ts} +4 -1
  33. package/src/relay/nostr.ts +249 -0
  34. package/src/relay/protocol.ts +361 -0
  35. package/src/relay/publicAssetUrl.ts +25 -0
  36. package/src/relay/rootPathResolver.ts +50 -0
  37. package/src/relay/shims.d.ts +17 -0
  38. package/src/relay/signing.ts +332 -0
  39. package/src/relay/treeRootCache.ts +354 -0
  40. package/src/relay/treeRootSubscription.ts +577 -0
  41. package/src/relay/utils/constants.ts +139 -0
  42. package/src/relay/utils/errorMessage.ts +7 -0
  43. package/src/relay/utils/lruCache.ts +79 -0
  44. package/src/relay/webrtc.ts +5 -0
  45. package/src/relay/webrtcSignaling.ts +108 -0
  46. package/src/relay/worker.ts +1787 -0
  47. package/src/relay-client.ts +265 -0
  48. package/src/relay-entry.ts +1 -0
  49. package/src/runtime-network.ts +134 -0
  50. package/src/runtime.ts +153 -0
  51. package/{dist/transferableBytes.js → src/transferableBytes.ts} +2 -3
  52. package/src/tree-root.ts +851 -0
  53. package/src/types.ts +8 -0
  54. package/src/worker.ts +975 -0
  55. package/dist/app-runtime.d.ts +0 -60
  56. package/dist/app-runtime.d.ts.map +0 -1
  57. package/dist/app-runtime.js +0 -271
  58. package/dist/app-runtime.js.map +0 -1
  59. package/dist/capabilities/blossomBandwidthTracker.d.ts +0 -26
  60. package/dist/capabilities/blossomBandwidthTracker.d.ts.map +0 -1
  61. package/dist/capabilities/blossomBandwidthTracker.js +0 -53
  62. package/dist/capabilities/blossomBandwidthTracker.js.map +0 -1
  63. package/dist/capabilities/blossomTransport.d.ts +0 -22
  64. package/dist/capabilities/blossomTransport.d.ts.map +0 -1
  65. package/dist/capabilities/blossomTransport.js +0 -139
  66. package/dist/capabilities/blossomTransport.js.map +0 -1
  67. package/dist/capabilities/connectivity.d.ts +0 -3
  68. package/dist/capabilities/connectivity.d.ts.map +0 -1
  69. package/dist/capabilities/connectivity.js +0 -49
  70. package/dist/capabilities/connectivity.js.map +0 -1
  71. package/dist/capabilities/idbStorage.d.ts +0 -25
  72. package/dist/capabilities/idbStorage.d.ts.map +0 -1
  73. package/dist/capabilities/idbStorage.js +0 -73
  74. package/dist/capabilities/idbStorage.js.map +0 -1
  75. package/dist/capabilities/meshRouterStore.d.ts +0 -71
  76. package/dist/capabilities/meshRouterStore.d.ts.map +0 -1
  77. package/dist/capabilities/meshRouterStore.js +0 -316
  78. package/dist/capabilities/meshRouterStore.js.map +0 -1
  79. package/dist/capabilities/rootResolver.d.ts +0 -10
  80. package/dist/capabilities/rootResolver.d.ts.map +0 -1
  81. package/dist/capabilities/rootResolver.js +0 -392
  82. package/dist/capabilities/rootResolver.js.map +0 -1
  83. package/dist/client-id.d.ts +0 -18
  84. package/dist/client-id.d.ts.map +0 -1
  85. package/dist/client-id.js +0 -98
  86. package/dist/client-id.js.map +0 -1
  87. package/dist/client.d.ts +0 -61
  88. package/dist/client.d.ts.map +0 -1
  89. package/dist/client.js +0 -417
  90. package/dist/client.js.map +0 -1
  91. package/dist/entry.d.ts +0 -2
  92. package/dist/entry.d.ts.map +0 -1
  93. package/dist/entry.js.map +0 -1
  94. package/dist/htree-path.d.ts +0 -13
  95. package/dist/htree-path.d.ts.map +0 -1
  96. package/dist/htree-path.js +0 -38
  97. package/dist/htree-path.js.map +0 -1
  98. package/dist/htree-url.d.ts +0 -22
  99. package/dist/htree-url.d.ts.map +0 -1
  100. package/dist/htree-url.js +0 -118
  101. package/dist/htree-url.js.map +0 -1
  102. package/dist/index.d.ts +0 -17
  103. package/dist/index.d.ts.map +0 -1
  104. package/dist/index.js +0 -8
  105. package/dist/index.js.map +0 -1
  106. package/dist/mediaStreaming.d.ts +0 -7
  107. package/dist/mediaStreaming.d.ts.map +0 -1
  108. package/dist/mediaStreaming.js +0 -48
  109. package/dist/mediaStreaming.js.map +0 -1
  110. package/dist/p2p/boundedQueue.d.ts +0 -79
  111. package/dist/p2p/boundedQueue.d.ts.map +0 -1
  112. package/dist/p2p/boundedQueue.js +0 -134
  113. package/dist/p2p/boundedQueue.js.map +0 -1
  114. package/dist/p2p/errorMessage.d.ts +0 -5
  115. package/dist/p2p/errorMessage.d.ts.map +0 -1
  116. package/dist/p2p/errorMessage.js +0 -7
  117. package/dist/p2p/errorMessage.js.map +0 -1
  118. package/dist/p2p/index.d.ts +0 -8
  119. package/dist/p2p/index.d.ts.map +0 -1
  120. package/dist/p2p/index.js +0 -6
  121. package/dist/p2p/index.js.map +0 -1
  122. package/dist/p2p/lruCache.d.ts +0 -26
  123. package/dist/p2p/lruCache.d.ts.map +0 -1
  124. package/dist/p2p/lruCache.js +0 -65
  125. package/dist/p2p/lruCache.js.map +0 -1
  126. package/dist/p2p/meshQueryRouter.d.ts +0 -57
  127. package/dist/p2p/meshQueryRouter.d.ts.map +0 -1
  128. package/dist/p2p/meshQueryRouter.js +0 -264
  129. package/dist/p2p/meshQueryRouter.js.map +0 -1
  130. package/dist/p2p/protocol.d.ts +0 -10
  131. package/dist/p2p/protocol.d.ts.map +0 -1
  132. package/dist/p2p/protocol.js +0 -2
  133. package/dist/p2p/protocol.js.map +0 -1
  134. package/dist/p2p/queryForwardingMachine.d.ts +0 -46
  135. package/dist/p2p/queryForwardingMachine.d.ts.map +0 -1
  136. package/dist/p2p/queryForwardingMachine.js +0 -144
  137. package/dist/p2p/queryForwardingMachine.js.map +0 -1
  138. package/dist/p2p/signaling.d.ts +0 -63
  139. package/dist/p2p/signaling.d.ts.map +0 -1
  140. package/dist/p2p/signaling.js +0 -185
  141. package/dist/p2p/signaling.js.map +0 -1
  142. package/dist/p2p/uploadRateLimiter.d.ts +0 -21
  143. package/dist/p2p/uploadRateLimiter.d.ts.map +0 -1
  144. package/dist/p2p/uploadRateLimiter.js +0 -62
  145. package/dist/p2p/uploadRateLimiter.js.map +0 -1
  146. package/dist/p2p/webrtcController.d.ts +0 -176
  147. package/dist/p2p/webrtcController.d.ts.map +0 -1
  148. package/dist/p2p/webrtcController.js +0 -938
  149. package/dist/p2p/webrtcController.js.map +0 -1
  150. package/dist/p2p/webrtcProxy.d.ts +0 -62
  151. package/dist/p2p/webrtcProxy.d.ts.map +0 -1
  152. package/dist/p2p/webrtcProxy.js +0 -447
  153. package/dist/p2p/webrtcProxy.js.map +0 -1
  154. package/dist/privacyGuards.d.ts +0 -14
  155. package/dist/privacyGuards.d.ts.map +0 -1
  156. package/dist/privacyGuards.js +0 -27
  157. package/dist/privacyGuards.js.map +0 -1
  158. package/dist/protocol.d.ts +0 -225
  159. package/dist/protocol.d.ts.map +0 -1
  160. package/dist/protocol.js +0 -2
  161. package/dist/protocol.js.map +0 -1
  162. package/dist/relay/identity.d.ts +0 -36
  163. package/dist/relay/identity.d.ts.map +0 -1
  164. package/dist/relay/identity.js +0 -78
  165. package/dist/relay/identity.js.map +0 -1
  166. package/dist/relay/mediaHandler.d.ts +0 -64
  167. package/dist/relay/mediaHandler.d.ts.map +0 -1
  168. package/dist/relay/mediaHandler.js +0 -1285
  169. package/dist/relay/mediaHandler.js.map +0 -1
  170. package/dist/relay/ndk.d.ts +0 -96
  171. package/dist/relay/ndk.d.ts.map +0 -1
  172. package/dist/relay/ndk.js +0 -502
  173. package/dist/relay/ndk.js.map +0 -1
  174. package/dist/relay/nostr-wasm.d.ts +0 -14
  175. package/dist/relay/nostr-wasm.d.ts.map +0 -1
  176. package/dist/relay/nostr-wasm.js.map +0 -1
  177. package/dist/relay/nostr.d.ts +0 -60
  178. package/dist/relay/nostr.d.ts.map +0 -1
  179. package/dist/relay/nostr.js +0 -207
  180. package/dist/relay/nostr.js.map +0 -1
  181. package/dist/relay/protocol.d.ts +0 -592
  182. package/dist/relay/protocol.d.ts.map +0 -1
  183. package/dist/relay/protocol.js +0 -16
  184. package/dist/relay/protocol.js.map +0 -1
  185. package/dist/relay/publicAssetUrl.d.ts +0 -6
  186. package/dist/relay/publicAssetUrl.d.ts.map +0 -1
  187. package/dist/relay/publicAssetUrl.js +0 -14
  188. package/dist/relay/publicAssetUrl.js.map +0 -1
  189. package/dist/relay/rootPathResolver.d.ts +0 -9
  190. package/dist/relay/rootPathResolver.d.ts.map +0 -1
  191. package/dist/relay/rootPathResolver.js +0 -32
  192. package/dist/relay/rootPathResolver.js.map +0 -1
  193. package/dist/relay/signing.d.ts +0 -50
  194. package/dist/relay/signing.d.ts.map +0 -1
  195. package/dist/relay/signing.js +0 -299
  196. package/dist/relay/signing.js.map +0 -1
  197. package/dist/relay/treeRootCache.d.ts +0 -86
  198. package/dist/relay/treeRootCache.d.ts.map +0 -1
  199. package/dist/relay/treeRootCache.js +0 -269
  200. package/dist/relay/treeRootCache.js.map +0 -1
  201. package/dist/relay/treeRootSubscription.d.ts +0 -55
  202. package/dist/relay/treeRootSubscription.d.ts.map +0 -1
  203. package/dist/relay/treeRootSubscription.js +0 -478
  204. package/dist/relay/treeRootSubscription.js.map +0 -1
  205. package/dist/relay/utils/constants.d.ts +0 -76
  206. package/dist/relay/utils/constants.d.ts.map +0 -1
  207. package/dist/relay/utils/constants.js +0 -113
  208. package/dist/relay/utils/constants.js.map +0 -1
  209. package/dist/relay/utils/errorMessage.d.ts +0 -5
  210. package/dist/relay/utils/errorMessage.d.ts.map +0 -1
  211. package/dist/relay/utils/errorMessage.js +0 -8
  212. package/dist/relay/utils/errorMessage.js.map +0 -1
  213. package/dist/relay/utils/lruCache.d.ts +0 -26
  214. package/dist/relay/utils/lruCache.d.ts.map +0 -1
  215. package/dist/relay/utils/lruCache.js +0 -66
  216. package/dist/relay/utils/lruCache.js.map +0 -1
  217. package/dist/relay/webrtc.d.ts +0 -2
  218. package/dist/relay/webrtc.d.ts.map +0 -1
  219. package/dist/relay/webrtc.js +0 -3
  220. package/dist/relay/webrtc.js.map +0 -1
  221. package/dist/relay/webrtcSignaling.d.ts +0 -37
  222. package/dist/relay/webrtcSignaling.d.ts.map +0 -1
  223. package/dist/relay/webrtcSignaling.js +0 -86
  224. package/dist/relay/webrtcSignaling.js.map +0 -1
  225. package/dist/relay/worker.d.ts +0 -12
  226. package/dist/relay/worker.d.ts.map +0 -1
  227. package/dist/relay/worker.js +0 -1540
  228. package/dist/relay/worker.js.map +0 -1
  229. package/dist/relay-client.d.ts +0 -31
  230. package/dist/relay-client.d.ts.map +0 -1
  231. package/dist/relay-client.js +0 -197
  232. package/dist/relay-client.js.map +0 -1
  233. package/dist/relay-entry.d.ts +0 -2
  234. package/dist/relay-entry.d.ts.map +0 -1
  235. package/dist/relay-entry.js +0 -2
  236. package/dist/relay-entry.js.map +0 -1
  237. package/dist/runtime-network.d.ts +0 -23
  238. package/dist/runtime-network.d.ts.map +0 -1
  239. package/dist/runtime-network.js +0 -105
  240. package/dist/runtime-network.js.map +0 -1
  241. package/dist/runtime.d.ts +0 -24
  242. package/dist/runtime.d.ts.map +0 -1
  243. package/dist/runtime.js +0 -126
  244. package/dist/runtime.js.map +0 -1
  245. package/dist/transferableBytes.d.ts +0 -2
  246. package/dist/transferableBytes.d.ts.map +0 -1
  247. package/dist/transferableBytes.js.map +0 -1
  248. package/dist/tree-root.d.ts +0 -201
  249. package/dist/tree-root.d.ts.map +0 -1
  250. package/dist/tree-root.js +0 -632
  251. package/dist/tree-root.js.map +0 -1
  252. package/dist/types.d.ts +0 -2
  253. package/dist/types.d.ts.map +0 -1
  254. package/dist/types.js +0 -2
  255. package/dist/types.js.map +0 -1
  256. package/dist/worker.d.ts +0 -9
  257. package/dist/worker.d.ts.map +0 -1
  258. package/dist/worker.js +0 -792
  259. package/dist/worker.js.map +0 -1
package/src/worker.ts ADDED
@@ -0,0 +1,975 @@
1
+ /// <reference lib="webworker" />
2
+
3
+ import {
4
+ HashTree,
5
+ decryptChk,
6
+ fromHex,
7
+ nhashDecode,
8
+ nhashEncode,
9
+ toHex,
10
+ tryDecodeTreeNode,
11
+ type CID,
12
+ type Hash,
13
+ type Store,
14
+ } from '@hashtree/core';
15
+ import type {
16
+ BlossomBandwidthState,
17
+ BlobSource,
18
+ WorkerDiagnosticEvent,
19
+ WorkerDiagnosticLevel,
20
+ UploadProgressState,
21
+ UploadServerStatus,
22
+ WorkerRequest,
23
+ WorkerResponse,
24
+ WorkerConfig,
25
+ } from './protocol.js';
26
+ import { IdbBlobStorage } from './capabilities/idbStorage.js';
27
+ import { BlossomTransport, DEFAULT_BLOSSOM_SERVERS } from './capabilities/blossomTransport.js';
28
+ import { probeConnectivity } from './capabilities/connectivity.js';
29
+ import { MeshRouterStore } from './capabilities/meshRouterStore.js';
30
+ import { resolveRootPathFromRelays, watchRootPathFromRelays } from './capabilities/rootResolver.js';
31
+ import { assertEncryptedUploadCid, markEncryptedHashes, shouldServeHashToPeer } from './privacyGuards.js';
32
+ import { streamFileRangeChunks } from './mediaStreaming.js';
33
+ import { cloneTransferableBytes } from './transferableBytes.js';
34
+
35
+ const DEFAULT_STORE_NAME = 'hashtree-worker';
36
+ const DEFAULT_STORAGE_MAX_BYTES = 1024 * 1024 * 1024;
37
+ const DEFAULT_CONNECTIVITY_PROBE_INTERVAL_MS = 20_000;
38
+ const P2P_FETCH_TIMEOUT_MS = 2_000;
39
+
40
+ export interface HashtreeWorkerMessageEndpoint {
41
+ postMessage(message: WorkerResponse): void;
42
+ addEventListener(type: 'message', listener: EventListenerOrEventListenerObject): void;
43
+ removeEventListener(type: 'message', listener: EventListenerOrEventListenerObject): void;
44
+ start?: () => void;
45
+ }
46
+
47
+ let endpoint: HashtreeWorkerMessageEndpoint | null = null;
48
+ let endpointListener: EventListener | null = null;
49
+
50
+ let storage: IdbBlobStorage | null = null;
51
+ let blossom: BlossomTransport | null = null;
52
+ let meshStore: MeshRouterStore | null = null;
53
+ let tree: HashTree | null = null;
54
+ let nostrRelays: string[] = [];
55
+ let probeInterval: ReturnType<typeof setInterval> | null = null;
56
+ let probeIntervalMs = DEFAULT_CONNECTIVITY_PROBE_INTERVAL_MS;
57
+ let p2pFetchCounter = 0;
58
+ let rootWatchCounter = 0;
59
+ let diagnosticsEnabled = false;
60
+ let diagnosticsMirrorToConsole = false;
61
+ const pendingP2PFetches = new Map<
62
+ string,
63
+ { resolve: (data: Uint8Array | null) => void; timeoutId: ReturnType<typeof setTimeout> }
64
+ >();
65
+ const peerShareableEncryptedHashes = new Set<string>();
66
+ const activeRootWatches = new Map<string, { close: () => Promise<void> }>();
67
+ let putBlobStreamCounter = 0;
68
+ const activePutBlobStreams = new Map<string, {
69
+ upload: boolean;
70
+ writer: {
71
+ append(data: Uint8Array): Promise<void>;
72
+ finalize(): Promise<{ hash: Hash; size: number; key?: Uint8Array }>;
73
+ };
74
+ }>();
75
+
76
+ interface MediaFileRequest {
77
+ type: 'hashtree-file';
78
+ requestId: string;
79
+ nhash: string;
80
+ path: string;
81
+ start: number;
82
+ end?: number;
83
+ mimeType?: string;
84
+ download?: boolean;
85
+ head?: boolean;
86
+ }
87
+
88
+ interface MediaHeadersResponse {
89
+ type: 'headers';
90
+ requestId: string;
91
+ status: number;
92
+ totalSize: number;
93
+ headers: Record<string, string>;
94
+ }
95
+
96
+ interface MediaChunkResponse {
97
+ type: 'chunk';
98
+ requestId: string;
99
+ data: Uint8Array;
100
+ }
101
+
102
+ interface MediaDoneResponse {
103
+ type: 'done';
104
+ requestId: string;
105
+ }
106
+
107
+ interface MediaErrorResponse {
108
+ type: 'error';
109
+ requestId: string;
110
+ message: string;
111
+ }
112
+
113
+ const MEDIA_CHUNK_SIZE = 64 * 1024;
114
+
115
+ function getErrorMessage(err: unknown): string {
116
+ return err instanceof Error ? err.message : String(err);
117
+ }
118
+
119
+ const EMPTY_BLOSSOM_BANDWIDTH: BlossomBandwidthState = {
120
+ totalBytesSent: 0,
121
+ totalBytesReceived: 0,
122
+ updatedAt: 0,
123
+ servers: [],
124
+ };
125
+
126
+ let blossomBandwidth: BlossomBandwidthState = { ...EMPTY_BLOSSOM_BANDWIDTH };
127
+
128
+ function respond(message: WorkerResponse): void {
129
+ endpoint?.postMessage(message);
130
+ }
131
+
132
+ function emitDiagnostic(
133
+ level: WorkerDiagnosticLevel,
134
+ scope: string,
135
+ code: string,
136
+ message: string,
137
+ data?: WorkerDiagnosticEvent['data'],
138
+ ): void {
139
+ if (!diagnosticsEnabled && !diagnosticsMirrorToConsole) {
140
+ return;
141
+ }
142
+
143
+ const event: WorkerDiagnosticEvent = {
144
+ scope,
145
+ code,
146
+ level,
147
+ message,
148
+ timestamp: Date.now(),
149
+ data,
150
+ };
151
+
152
+ if (diagnosticsEnabled) {
153
+ respond({ type: 'diagnostic', event });
154
+ }
155
+
156
+ if (diagnosticsMirrorToConsole) {
157
+ const prefix = `[HashtreeWorker:${scope}:${code}] ${message}`;
158
+ if (level === 'error') {
159
+ console.error(prefix, data ?? {});
160
+ return;
161
+ }
162
+ if (level === 'warn') {
163
+ console.warn(prefix, data ?? {});
164
+ return;
165
+ }
166
+ console.log(prefix, data ?? {});
167
+ }
168
+ }
169
+
170
+ function publishBlossomBandwidth(stats: BlossomBandwidthState): void {
171
+ blossomBandwidth = {
172
+ totalBytesSent: stats.totalBytesSent,
173
+ totalBytesReceived: stats.totalBytesReceived,
174
+ updatedAt: stats.updatedAt,
175
+ servers: stats.servers.map(server => ({
176
+ url: server.url,
177
+ bytesSent: server.bytesSent,
178
+ bytesReceived: server.bytesReceived,
179
+ })),
180
+ };
181
+
182
+ respond({
183
+ type: 'blossomBandwidth',
184
+ stats: blossomBandwidth,
185
+ });
186
+ }
187
+
188
+ function resetState(): void {
189
+ if (probeInterval) {
190
+ clearInterval(probeInterval);
191
+ probeInterval = null;
192
+ }
193
+ for (const watch of activeRootWatches.values()) {
194
+ void Promise.resolve(watch.close()).catch(() => undefined);
195
+ }
196
+ activeRootWatches.clear();
197
+ storage?.close();
198
+ storage = null;
199
+ blossom = null;
200
+ meshStore = null;
201
+ tree = null;
202
+ for (const pending of pendingP2PFetches.values()) {
203
+ clearTimeout(pending.timeoutId);
204
+ }
205
+ pendingP2PFetches.clear();
206
+ peerShareableEncryptedHashes.clear();
207
+ activePutBlobStreams.clear();
208
+ blossomBandwidth = { ...EMPTY_BLOSSOM_BANDWIDTH };
209
+ nostrRelays = [];
210
+ diagnosticsEnabled = false;
211
+ diagnosticsMirrorToConsole = false;
212
+ }
213
+
214
+ async function markEncryptedTreeHashesAsPeerShareable(id: CID): Promise<void> {
215
+ if (!tree) return;
216
+ const hashes: string[] = [];
217
+ for await (const block of tree.walkBlocks(id)) {
218
+ hashes.push(toHex(block.hash));
219
+ }
220
+ markEncryptedHashes(hashes, peerShareableEncryptedHashes);
221
+ }
222
+
223
+ async function emitConnectivityUpdate(): Promise<void> {
224
+ if (!blossom) return;
225
+ const state = await probeConnectivity(blossom.getServers());
226
+ respond({ type: 'connectivityUpdate', state });
227
+ }
228
+
229
+ function startConnectivityProbeLoop(): void {
230
+ if (probeInterval) {
231
+ clearInterval(probeInterval);
232
+ probeInterval = null;
233
+ }
234
+ probeInterval = setInterval(() => {
235
+ void emitConnectivityUpdate();
236
+ }, probeIntervalMs);
237
+ }
238
+
239
+ function nextP2PFetchRequestId(): string {
240
+ p2pFetchCounter += 1;
241
+ return `p2p_${Date.now()}_${p2pFetchCounter}`;
242
+ }
243
+
244
+ function nextRootWatchId(): string {
245
+ rootWatchCounter += 1;
246
+ return `root_${Date.now()}_${rootWatchCounter}`;
247
+ }
248
+
249
+ async function requestP2PBlob(hashHex: string): Promise<Uint8Array | null> {
250
+ const requestId = nextP2PFetchRequestId();
251
+ const data = await new Promise<Uint8Array | null>((resolve) => {
252
+ const timeoutId = setTimeout(() => {
253
+ pendingP2PFetches.delete(requestId);
254
+ resolve(null);
255
+ }, P2P_FETCH_TIMEOUT_MS);
256
+ pendingP2PFetches.set(requestId, { resolve, timeoutId });
257
+ respond({ type: 'p2pFetch', requestId, hashHex });
258
+ });
259
+
260
+ return data;
261
+ }
262
+
263
+ function resolveP2PFetch(requestId: string, data?: Uint8Array, error?: string): void {
264
+ const pending = pendingP2PFetches.get(requestId);
265
+ if (!pending) return;
266
+ clearTimeout(pending.timeoutId);
267
+ pendingP2PFetches.delete(requestId);
268
+
269
+ if (error || !data) {
270
+ pending.resolve(null);
271
+ return;
272
+ }
273
+
274
+ pending.resolve(data);
275
+ }
276
+
277
+ async function loadBlobData(hashHex: string): Promise<{ data: Uint8Array; source: BlobSource } | null> {
278
+ if (!meshStore) return null;
279
+ const result = await meshStore.getDetailed(fromHex(hashHex) as Hash);
280
+ if (!result) return null;
281
+
282
+ const source: BlobSource = result.sourceId === 'idb'
283
+ ? 'idb'
284
+ : result.sourceId === 'blossom'
285
+ ? 'blossom'
286
+ : 'p2p';
287
+ return { data: result.data, source };
288
+ }
289
+
290
+ function createStorageStore(): Store {
291
+ return {
292
+ put: async (hash: Hash, data: Uint8Array): Promise<boolean> => {
293
+ if (!storage) throw new Error('Worker storage not initialized');
294
+ await storage.putByHashTrusted(toHex(hash), data);
295
+ return true;
296
+ },
297
+ get: async (hash: Hash): Promise<Uint8Array | null> => {
298
+ if (!storage) {
299
+ return null;
300
+ }
301
+ return storage.get(toHex(hash));
302
+ },
303
+ has: async (hash: Hash): Promise<boolean> => {
304
+ if (!storage) return false;
305
+ return storage.has(toHex(hash));
306
+ },
307
+ delete: async (hash: Hash): Promise<boolean> => {
308
+ if (!storage) return false;
309
+ return storage.delete(toHex(hash));
310
+ },
311
+ };
312
+ }
313
+
314
+ function createMeshStore(): MeshRouterStore {
315
+ return new MeshRouterStore({
316
+ primary: createStorageStore(),
317
+ primarySourceId: 'idb',
318
+ requestTimeoutMs: 5_500,
319
+ sources: [
320
+ {
321
+ id: 'p2p',
322
+ get: async (hash) => requestP2PBlob(toHex(hash)),
323
+ },
324
+ {
325
+ id: 'blossom',
326
+ isAvailable: () => !!blossom && blossom.getServers().some((server) => server.read !== false),
327
+ get: async (hash) => blossom ? blossom.fetch(toHex(hash)) : null,
328
+ },
329
+ ],
330
+ });
331
+ }
332
+
333
+ async function getPlaintextFileSize(fileCid: CID): Promise<number | null> {
334
+ if (!tree) return null;
335
+
336
+ if (!fileCid.key) {
337
+ return tree.getSize(fileCid.hash);
338
+ }
339
+
340
+ const loaded = await loadBlobData(toHex(fileCid.hash));
341
+ if (!loaded) return null;
342
+
343
+ const decryptedRoot = await decryptChk(loaded.data, fileCid.key);
344
+ const rootNode = tryDecodeTreeNode(decryptedRoot);
345
+ if (!rootNode) {
346
+ return decryptedRoot.byteLength;
347
+ }
348
+
349
+ const summedSize = rootNode.links.reduce((sum, link) => sum + (link.size ?? 0), 0);
350
+ if (summedSize > 0) {
351
+ return summedSize;
352
+ }
353
+
354
+ const fullData = await tree.readFile(fileCid);
355
+ return fullData?.byteLength ?? 0;
356
+ }
357
+
358
+ function decodeDownloadName(path: string): string {
359
+ try {
360
+ return decodeURIComponent(path.split('/').pop() || 'file');
361
+ } catch {
362
+ return path.split('/').pop() || 'file';
363
+ }
364
+ }
365
+
366
+ function postMediaError(port: MessagePort, requestId: string, message: string): void {
367
+ emitDiagnostic('warn', 'media', 'media-request-error', message, { requestId });
368
+ const response: MediaErrorResponse = { type: 'error', requestId, message };
369
+ port.postMessage(response);
370
+ }
371
+
372
+ async function handleMediaFileRequest(port: MessagePort, request: MediaFileRequest): Promise<void> {
373
+ if (!tree) {
374
+ emitDiagnostic('error', 'media', 'worker-not-initialized', 'Worker not initialized for media request', {
375
+ requestId: request.requestId,
376
+ });
377
+ postMediaError(port, request.requestId, 'Worker not initialized');
378
+ return;
379
+ }
380
+
381
+ let rootCid: CID;
382
+ try {
383
+ rootCid = nhashDecode(request.nhash);
384
+ } catch {
385
+ emitDiagnostic('warn', 'media', 'invalid-nhash', 'Invalid nhash for media request', {
386
+ requestId: request.requestId,
387
+ });
388
+ postMediaError(port, request.requestId, 'Invalid nhash');
389
+ return;
390
+ }
391
+
392
+ emitDiagnostic('debug', 'media', 'request-start', 'Handling media request', {
393
+ requestId: request.requestId,
394
+ start: request.start,
395
+ end: typeof request.end === 'number' ? request.end : null,
396
+ head: request.head === true,
397
+ });
398
+
399
+ let cid = rootCid;
400
+ const requestedPath = request.path.trim().replace(/^\/+/, '');
401
+ if (requestedPath) {
402
+ const resolved = await tree.resolvePath(rootCid, requestedPath);
403
+ if (resolved) {
404
+ cid = resolved.cid;
405
+ } else if (await tree.isDirectory(rootCid)) {
406
+ emitDiagnostic('warn', 'media', 'file-not-found', 'Media file path not found', {
407
+ requestId: request.requestId,
408
+ });
409
+ postMediaError(port, request.requestId, 'File not found');
410
+ return;
411
+ }
412
+ }
413
+
414
+ const totalSize = await getPlaintextFileSize(cid);
415
+ if (totalSize === null) {
416
+ emitDiagnostic('warn', 'media', 'size-not-found', 'Media file size unavailable', {
417
+ requestId: request.requestId,
418
+ });
419
+ postMediaError(port, request.requestId, 'File not found');
420
+ return;
421
+ }
422
+
423
+ if (totalSize === 0) {
424
+ const headersMessage: MediaHeadersResponse = {
425
+ type: 'headers',
426
+ requestId: request.requestId,
427
+ status: 200,
428
+ totalSize,
429
+ headers: {
430
+ 'content-type': request.mimeType || 'application/octet-stream',
431
+ 'accept-ranges': 'bytes',
432
+ 'content-length': '0',
433
+ },
434
+ };
435
+ port.postMessage(headersMessage);
436
+ const doneMessage: MediaDoneResponse = { type: 'done', requestId: request.requestId };
437
+ port.postMessage(doneMessage);
438
+ return;
439
+ }
440
+
441
+ const start = Number.isFinite(request.start) ? Math.max(0, Math.floor(request.start)) : 0;
442
+ if (start >= totalSize) {
443
+ const headers: MediaHeadersResponse = {
444
+ type: 'headers',
445
+ requestId: request.requestId,
446
+ status: 416,
447
+ totalSize,
448
+ headers: {
449
+ 'content-type': request.mimeType || 'application/octet-stream',
450
+ 'content-range': `bytes */${totalSize}`,
451
+ },
452
+ };
453
+ port.postMessage(headers);
454
+ const done: MediaDoneResponse = { type: 'done', requestId: request.requestId };
455
+ port.postMessage(done);
456
+ return;
457
+ }
458
+
459
+ const requestedEnd = Number.isFinite(request.end) && typeof request.end === 'number'
460
+ ? Math.floor(request.end)
461
+ : totalSize - 1;
462
+ const end = Math.min(totalSize - 1, Math.max(start, requestedEnd));
463
+ const isPartial = start !== 0 || end !== totalSize - 1;
464
+
465
+ const expectedLength = end - start + 1;
466
+
467
+ const responseHeaders: Record<string, string> = {
468
+ 'content-type': request.mimeType || 'application/octet-stream',
469
+ 'accept-ranges': 'bytes',
470
+ 'content-length': String(expectedLength),
471
+ };
472
+ if (isPartial) {
473
+ responseHeaders['content-range'] = `bytes ${start}-${end}/${totalSize}`;
474
+ }
475
+ if (request.download) {
476
+ const fileName = decodeDownloadName(request.path).replace(/["\\]/g, '_');
477
+ responseHeaders['content-disposition'] = `attachment; filename="${fileName}"`;
478
+ }
479
+
480
+ const headersMessage: MediaHeadersResponse = {
481
+ type: 'headers',
482
+ requestId: request.requestId,
483
+ status: isPartial ? 206 : 200,
484
+ totalSize,
485
+ headers: responseHeaders,
486
+ };
487
+ port.postMessage(headersMessage);
488
+
489
+ if (!request.head) {
490
+ for await (const chunk of streamFileRangeChunks(tree, cid, start, end, MEDIA_CHUNK_SIZE)) {
491
+ const transferableChunk = cloneTransferableBytes(chunk);
492
+ const chunkMessage: MediaChunkResponse = {
493
+ type: 'chunk',
494
+ requestId: request.requestId,
495
+ data: transferableChunk,
496
+ };
497
+ port.postMessage(chunkMessage, [transferableChunk.buffer]);
498
+ }
499
+ }
500
+
501
+ emitDiagnostic('debug', 'media', 'request-complete', 'Completed media request', {
502
+ requestId: request.requestId,
503
+ totalSize,
504
+ status: isPartial ? 206 : 200,
505
+ });
506
+ const doneMessage: MediaDoneResponse = { type: 'done', requestId: request.requestId };
507
+ port.postMessage(doneMessage);
508
+ }
509
+
510
+ function registerMediaPort(port: MessagePort): void {
511
+ emitDiagnostic('info', 'media', 'port-registered', 'Registered media MessagePort');
512
+ port.onmessage = (event: MessageEvent<unknown>) => {
513
+ const data = event.data as Partial<MediaFileRequest> | null;
514
+ if (!data || data.type !== 'hashtree-file' || typeof data.requestId !== 'string') {
515
+ return;
516
+ }
517
+ if (typeof data.nhash !== 'string' || typeof data.path !== 'string') {
518
+ emitDiagnostic('warn', 'media', 'invalid-request', 'Received invalid media request payload', {
519
+ requestId: data.requestId,
520
+ });
521
+ postMediaError(port, data.requestId, 'Invalid media request');
522
+ return;
523
+ }
524
+ const request: MediaFileRequest = {
525
+ type: 'hashtree-file',
526
+ requestId: data.requestId,
527
+ nhash: data.nhash,
528
+ path: data.path,
529
+ start: typeof data.start === 'number' ? data.start : 0,
530
+ end: typeof data.end === 'number' ? data.end : undefined,
531
+ mimeType: typeof data.mimeType === 'string' ? data.mimeType : undefined,
532
+ download: !!data.download,
533
+ head: !!data.head,
534
+ };
535
+ void handleMediaFileRequest(port, request).catch((err) => {
536
+ postMediaError(port, request.requestId, getErrorMessage(err));
537
+ });
538
+ };
539
+ }
540
+
541
+ function init(config: WorkerConfig): void {
542
+ resetState();
543
+ const storeName = config.storeName || DEFAULT_STORE_NAME;
544
+ const maxBytes = config.storageMaxBytes || DEFAULT_STORAGE_MAX_BYTES;
545
+ probeIntervalMs = config.connectivityProbeIntervalMs || DEFAULT_CONNECTIVITY_PROBE_INTERVAL_MS;
546
+ nostrRelays = config.relays ?? [];
547
+ diagnosticsEnabled = config.diagnosticsEnabled === true;
548
+ diagnosticsMirrorToConsole = config.diagnosticsMirrorToConsole === true;
549
+
550
+ storage = new IdbBlobStorage(storeName, maxBytes);
551
+ blossom = new BlossomTransport(
552
+ config.blossomServers || DEFAULT_BLOSSOM_SERVERS,
553
+ (stats) => {
554
+ publishBlossomBandwidth(stats);
555
+ }
556
+ );
557
+ meshStore = createMeshStore();
558
+ tree = new HashTree({ store: meshStore });
559
+ publishBlossomBandwidth(blossom.getBandwidthStats());
560
+ emitDiagnostic('info', 'worker', 'initialized', 'Hashtree worker initialized', {
561
+ storeName,
562
+ relayCount: nostrRelays.length,
563
+ diagnosticsMirrorToConsole,
564
+ });
565
+
566
+ startConnectivityProbeLoop();
567
+ void emitConnectivityUpdate();
568
+ }
569
+
570
+ function nextPutBlobStreamId(): string {
571
+ putBlobStreamCounter += 1;
572
+ return `pbs_${Date.now()}_${putBlobStreamCounter}`;
573
+ }
574
+
575
+ function startBlossomUploadProgress(hashHex: string, nhash: string, fileCid: CID): void {
576
+ if (!blossom || !tree) return;
577
+ const writeServers = blossom.getWriteServers();
578
+ if (writeServers.length === 0) return;
579
+ const chunkProgressEmitIntervalMs = 100;
580
+
581
+ const progress: UploadProgressState = {
582
+ hashHex,
583
+ nhash,
584
+ totalServers: writeServers.length,
585
+ processedServers: 0,
586
+ uploadedServers: 0,
587
+ skippedServers: 0,
588
+ failedServers: 0,
589
+ totalChunks: 0,
590
+ processedChunks: 0,
591
+ progressRatio: 0,
592
+ complete: false,
593
+ };
594
+
595
+ const serverStats = new Map<string, UploadServerStatus>();
596
+ for (const server of writeServers) {
597
+ serverStats.set(server.url, { url: server.url, uploaded: 0, skipped: 0, failed: 0 });
598
+ }
599
+
600
+ let lastChunkProgressEmit = 0;
601
+
602
+ const syncServerStatuses = (): void => {
603
+ progress.serverStatuses = Array.from(serverStats.values())
604
+ .map((status) => ({ ...status }))
605
+ .sort((a, b) => a.url.localeCompare(b.url));
606
+ };
607
+
608
+ const emitProgress = (): void => {
609
+ syncServerStatuses();
610
+ respond({ type: 'uploadProgress', progress: { ...progress } });
611
+ };
612
+
613
+ emitProgress();
614
+
615
+ const onUploadProgress = (serverUrl: string, status: 'uploaded' | 'skipped' | 'failed'): void => {
616
+ const stats = serverStats.get(serverUrl);
617
+ if (!stats) return;
618
+ stats[status]++;
619
+ };
620
+
621
+ void (async () => {
622
+ const uploadStore = blossom.createUploadStore(onUploadProgress);
623
+ const result = await tree.push(fileCid, uploadStore, {
624
+ onProgress: (current, total) => {
625
+ if (total <= 0 || progress.complete) return;
626
+ const fraction = current / total;
627
+ progress.totalChunks = total;
628
+ progress.processedChunks = current;
629
+ progress.progressRatio = Math.max(0, Math.min(1, fraction));
630
+
631
+ const processedEstimate = Math.min(
632
+ progress.totalServers,
633
+ Math.max(0, Math.floor(fraction * progress.totalServers))
634
+ );
635
+ const serverEstimateChanged = processedEstimate !== progress.processedServers;
636
+ if (serverEstimateChanged) {
637
+ progress.processedServers = processedEstimate;
638
+ }
639
+
640
+ const now = Date.now();
641
+ const shouldEmitChunkProgress = now - lastChunkProgressEmit >= chunkProgressEmitIntervalMs || current >= total;
642
+ if (serverEstimateChanged || shouldEmitChunkProgress) {
643
+ lastChunkProgressEmit = now;
644
+ emitProgress();
645
+ }
646
+ },
647
+ });
648
+
649
+ let uploadedServers = 0;
650
+ let skippedServers = 0;
651
+ let failedServers = 0;
652
+ for (const [, stats] of serverStats) {
653
+ if (stats.failed > 0) {
654
+ failedServers++;
655
+ } else if (stats.uploaded > 0) {
656
+ uploadedServers++;
657
+ } else {
658
+ skippedServers++;
659
+ }
660
+ }
661
+
662
+ progress.uploadedServers = uploadedServers;
663
+ progress.skippedServers = skippedServers;
664
+ progress.failedServers = failedServers;
665
+ progress.processedServers = progress.totalServers;
666
+ if (typeof progress.totalChunks === 'number' && progress.totalChunks > 0) {
667
+ progress.processedChunks = progress.totalChunks;
668
+ }
669
+ progress.progressRatio = 1;
670
+ progress.complete = true;
671
+ if (result.failed > 0 && result.errors.length > 0) {
672
+ progress.error = result.errors[0].error.message;
673
+ }
674
+ emitProgress();
675
+ })().catch((err) => {
676
+ if (progress.complete) return;
677
+ progress.failedServers = progress.totalServers;
678
+ progress.processedServers = progress.totalServers;
679
+ if (typeof progress.totalChunks === 'number' && progress.totalChunks > 0) {
680
+ progress.processedChunks = progress.totalChunks;
681
+ }
682
+ progress.progressRatio = 1;
683
+ progress.complete = true;
684
+ progress.error = getErrorMessage(err);
685
+ emitProgress();
686
+ });
687
+ }
688
+
689
+ function respondBlobStored(id: string, fileCid: CID, upload: boolean): void {
690
+ const hashHex = toHex(fileCid.hash);
691
+ const nhash = nhashEncode(fileCid);
692
+
693
+ if (upload) {
694
+ startBlossomUploadProgress(hashHex, nhash, fileCid);
695
+ }
696
+
697
+ respond({
698
+ type: 'blobStored',
699
+ id,
700
+ hashHex,
701
+ nhash,
702
+ });
703
+ }
704
+
705
+ async function handleRequest(req: WorkerRequest): Promise<void> {
706
+ switch (req.type) {
707
+ case 'init': {
708
+ init(req.config);
709
+ respond({ type: 'ready', id: req.id });
710
+ return;
711
+ }
712
+
713
+ case 'close': {
714
+ resetState();
715
+ respond({ type: 'void', id: req.id });
716
+ return;
717
+ }
718
+
719
+ case 'putBlob': {
720
+ if (!storage || !blossom || !tree) {
721
+ respond({ type: 'error', id: req.id, error: 'Worker not initialized' });
722
+ return;
723
+ }
724
+
725
+ let fileCid: CID;
726
+ if (req.upload === false) {
727
+ const hash = await tree.putBlob(req.data);
728
+ fileCid = { hash };
729
+ } else {
730
+ const fileResult = await tree.putFile(req.data);
731
+ fileCid = fileResult.cid;
732
+ assertEncryptedUploadCid(fileCid);
733
+ await markEncryptedTreeHashesAsPeerShareable(fileCid);
734
+ }
735
+
736
+ respondBlobStored(req.id, fileCid, req.upload !== false);
737
+ return;
738
+ }
739
+
740
+ case 'beginPutBlobStream': {
741
+ if (!tree) {
742
+ respond({ type: 'error', id: req.id, error: 'Worker not initialized' });
743
+ return;
744
+ }
745
+ const upload = req.upload !== false;
746
+ const streamId = nextPutBlobStreamId();
747
+ const writer = tree.createStream({ unencrypted: !upload });
748
+ activePutBlobStreams.set(streamId, { upload, writer });
749
+ respond({ type: 'blobStreamStarted', id: req.id, streamId });
750
+ return;
751
+ }
752
+
753
+ case 'appendPutBlobStream': {
754
+ const stream = activePutBlobStreams.get(req.streamId);
755
+ if (!stream) {
756
+ respond({ type: 'void', id: req.id, error: 'Upload stream not found' });
757
+ return;
758
+ }
759
+ await stream.writer.append(req.chunk);
760
+ respond({ type: 'void', id: req.id });
761
+ return;
762
+ }
763
+
764
+ case 'finishPutBlobStream': {
765
+ const stream = activePutBlobStreams.get(req.streamId);
766
+ if (!stream) {
767
+ respond({ type: 'error', id: req.id, error: 'Upload stream not found' });
768
+ return;
769
+ }
770
+ activePutBlobStreams.delete(req.streamId);
771
+
772
+ const finalized = await stream.writer.finalize();
773
+ const fileCid: CID = finalized.key
774
+ ? { hash: finalized.hash, key: finalized.key }
775
+ : { hash: finalized.hash };
776
+
777
+ if (stream.upload) {
778
+ assertEncryptedUploadCid(fileCid);
779
+ await markEncryptedTreeHashesAsPeerShareable(fileCid);
780
+ }
781
+
782
+ respondBlobStored(req.id, fileCid, stream.upload);
783
+ return;
784
+ }
785
+
786
+ case 'cancelPutBlobStream': {
787
+ activePutBlobStreams.delete(req.streamId);
788
+ respond({ type: 'void', id: req.id });
789
+ return;
790
+ }
791
+
792
+ case 'p2pFetchResult': {
793
+ resolveP2PFetch(req.requestId, req.data, req.error);
794
+ return;
795
+ }
796
+
797
+ case 'getBlob': {
798
+ if (!storage) {
799
+ respond({ type: 'blob', id: req.id, error: 'Worker not initialized' });
800
+ return;
801
+ }
802
+ if (req.forPeer && !shouldServeHashToPeer(req.hashHex, peerShareableEncryptedHashes)) {
803
+ respond({ type: 'blob', id: req.id, error: 'Refusing to serve non-encrypted or untrusted blob to peer' });
804
+ return;
805
+ }
806
+ const loaded = await loadBlobData(req.hashHex);
807
+ if (!loaded) {
808
+ respond({ type: 'blob', id: req.id, error: 'Blob not found' });
809
+ return;
810
+ }
811
+ respond({ type: 'blob', id: req.id, data: loaded.data, source: loaded.source });
812
+ return;
813
+ }
814
+
815
+ case 'registerMediaPort': {
816
+ if (!storage) {
817
+ respond({ type: 'void', id: req.id, error: 'Worker not initialized' });
818
+ return;
819
+ }
820
+ registerMediaPort(req.port);
821
+ respond({ type: 'void', id: req.id });
822
+ return;
823
+ }
824
+
825
+ case 'setBlossomServers': {
826
+ if (!blossom) {
827
+ respond({ type: 'void', id: req.id, error: 'Worker not initialized' });
828
+ return;
829
+ }
830
+ blossom.setServers(req.servers);
831
+ respond({ type: 'void', id: req.id });
832
+ void emitConnectivityUpdate();
833
+ return;
834
+ }
835
+
836
+ case 'setStorageMaxBytes': {
837
+ if (!storage) {
838
+ respond({ type: 'void', id: req.id, error: 'Worker not initialized' });
839
+ return;
840
+ }
841
+ storage.setMaxBytes(req.maxBytes);
842
+ respond({ type: 'void', id: req.id });
843
+ return;
844
+ }
845
+
846
+ case 'getStorageStats': {
847
+ if (!storage) {
848
+ respond({
849
+ type: 'storageStats',
850
+ id: req.id,
851
+ items: 0,
852
+ bytes: 0,
853
+ maxBytes: 0,
854
+ error: 'Worker not initialized',
855
+ });
856
+ return;
857
+ }
858
+ const stats = await storage.getStats();
859
+ respond({ type: 'storageStats', id: req.id, ...stats });
860
+ return;
861
+ }
862
+
863
+ case 'probeConnectivity': {
864
+ if (!blossom) {
865
+ respond({ type: 'connectivity', id: req.id, error: 'Worker not initialized' });
866
+ return;
867
+ }
868
+ const state = await probeConnectivity(blossom.getServers());
869
+ respond({ type: 'connectivity', id: req.id, state });
870
+ return;
871
+ }
872
+
873
+ case 'resolveRoot': {
874
+ if (!tree) {
875
+ respond({ type: 'cid', id: req.id, error: 'Worker not initialized' });
876
+ return;
877
+ }
878
+
879
+ try {
880
+ const cid = await resolveRootPathFromRelays(
881
+ tree,
882
+ nostrRelays,
883
+ req.npub,
884
+ req.path,
885
+ req.timeoutMs,
886
+ req.settleMs,
887
+ );
888
+ respond({ type: 'cid', id: req.id, cid: cid ?? undefined });
889
+ } catch (err) {
890
+ respond({ type: 'cid', id: req.id, error: getErrorMessage(err) });
891
+ }
892
+ return;
893
+ }
894
+
895
+ case 'watchRoot': {
896
+ if (!tree) {
897
+ respond({ type: 'rootWatchStarted', id: req.id, watchId: '', error: 'Worker not initialized' });
898
+ return;
899
+ }
900
+
901
+ const watchId = nextRootWatchId();
902
+ try {
903
+ const watch = await watchRootPathFromRelays(
904
+ tree,
905
+ nostrRelays,
906
+ req.npub,
907
+ req.path,
908
+ (cid) => {
909
+ respond({ type: 'rootUpdate', watchId, cid: cid ?? undefined });
910
+ },
911
+ req.timeoutMs,
912
+ req.settleMs,
913
+ );
914
+ activeRootWatches.set(watchId, { close: watch.close });
915
+ respond({
916
+ type: 'rootWatchStarted',
917
+ id: req.id,
918
+ watchId,
919
+ ...(watch.initialCid ? { cid: watch.initialCid } : {}),
920
+ });
921
+ } catch (err) {
922
+ respond({ type: 'rootWatchStarted', id: req.id, watchId: '', error: getErrorMessage(err) });
923
+ }
924
+ return;
925
+ }
926
+
927
+ case 'unwatchRoot': {
928
+ const watch = activeRootWatches.get(req.watchId);
929
+ activeRootWatches.delete(req.watchId);
930
+ if (watch) {
931
+ await Promise.resolve(watch.close()).catch(() => undefined);
932
+ }
933
+ respond({ type: 'void', id: req.id });
934
+ return;
935
+ }
936
+ }
937
+ }
938
+
939
+ function isWorkerRequestMessage(value: unknown): value is WorkerRequest {
940
+ return Boolean(
941
+ value
942
+ && typeof value === 'object'
943
+ && typeof (value as { type?: unknown }).type === 'string'
944
+ );
945
+ }
946
+
947
+ export function attachHashtreeWorker(
948
+ target: HashtreeWorkerMessageEndpoint = self as unknown as DedicatedWorkerGlobalScope,
949
+ ): () => void {
950
+ if (endpoint && endpointListener) {
951
+ endpoint.removeEventListener('message', endpointListener);
952
+ }
953
+
954
+ endpoint = target;
955
+ endpointListener = ((event: Event) => {
956
+ const req = (event as MessageEvent<unknown>).data;
957
+ if (!isWorkerRequestMessage(req)) {
958
+ return;
959
+ }
960
+ void handleRequest(req).catch((err) => {
961
+ respond({ type: 'error', id: req.id, error: getErrorMessage(err) });
962
+ });
963
+ }) as EventListener;
964
+
965
+ endpoint.addEventListener('message', endpointListener);
966
+ endpoint.start?.();
967
+
968
+ return () => {
969
+ target.removeEventListener('message', endpointListener as EventListener);
970
+ if (endpoint === target) {
971
+ endpoint = null;
972
+ endpointListener = null;
973
+ }
974
+ };
975
+ }