@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,497 @@
1
+ import type { CID, HashTree } from '@hashtree/core';
2
+ import { parseHashtreeRootEvent, type NostrEvent } from '@hashtree/nostr';
3
+ import { nip19 } from 'nostr-tools';
4
+
5
+ export const DEFAULT_ROOT_RESOLVE_TIMEOUT_MS = 15_000;
6
+ export const DEFAULT_ROOT_RESOLVE_SETTLE_MS = 500;
7
+
8
+ const MAX_TREE_ROOT_EVENTS = 8;
9
+ const DEFAULT_ROOT_RESOLVE_RELAYS = [
10
+ 'wss://relay.damus.io',
11
+ 'wss://relay.primal.net',
12
+ 'wss://relay.nostr.band',
13
+ 'wss://relay.snort.social',
14
+ ];
15
+
16
+ function withUniqueRelays(relays?: string[]): string[] {
17
+ const seen = new Set<string>();
18
+ const result: string[] = [];
19
+
20
+ for (const relay of [...(relays ?? []), ...DEFAULT_ROOT_RESOLVE_RELAYS]) {
21
+ const normalized = relay.trim();
22
+ if (!normalized || seen.has(normalized)) continue;
23
+ seen.add(normalized);
24
+ result.push(normalized);
25
+ }
26
+
27
+ return result;
28
+ }
29
+
30
+ function safeDecodePathSegment(segment: string): string {
31
+ try {
32
+ return decodeURIComponent(segment);
33
+ } catch {
34
+ return segment;
35
+ }
36
+ }
37
+
38
+ function splitPathSegments(path?: string): string[] {
39
+ return path
40
+ ?.split('/')
41
+ .filter(Boolean)
42
+ .map(safeDecodePathSegment) ?? [];
43
+ }
44
+
45
+ function compareReplaceableEvents(left: NostrEvent, right: NostrEvent): number {
46
+ const createdAtDiff = (right.created_at ?? 0) - (left.created_at ?? 0);
47
+ if (createdAtDiff !== 0) {
48
+ return createdAtDiff;
49
+ }
50
+
51
+ const leftId = left.id ?? '';
52
+ const rightId = right.id ?? '';
53
+ return rightId.localeCompare(leftId);
54
+ }
55
+
56
+ type ParsedRootPath = {
57
+ exactTreeName: string;
58
+ treeName: string;
59
+ subPath: string[];
60
+ watchTreeNames: string[];
61
+ };
62
+
63
+ type RootRecord = {
64
+ event: NostrEvent;
65
+ cid: CID;
66
+ };
67
+
68
+ export interface RootWatchHandle {
69
+ initialCid: CID | null;
70
+ close(): Promise<void>;
71
+ }
72
+
73
+ function decodeNpub(npub: string): string | null {
74
+ try {
75
+ const decoded = nip19.decode(npub);
76
+ if (decoded.type !== 'npub' || typeof decoded.data !== 'string') {
77
+ return null;
78
+ }
79
+ return decoded.data;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ function parseRootLookupPath(path?: string): ParsedRootPath {
86
+ const pathSegments = splitPathSegments(path);
87
+ const exactTreeName = pathSegments.join('/') || 'public';
88
+ const treeName = pathSegments[0] || 'public';
89
+ const subPath = pathSegments.slice(1);
90
+ const watchTreeNames = Array.from(new Set([exactTreeName, treeName]));
91
+
92
+ return {
93
+ exactTreeName,
94
+ treeName,
95
+ subPath,
96
+ watchTreeNames,
97
+ };
98
+ }
99
+
100
+ function cidKey(cid: CID | null): string {
101
+ if (!cid) return '';
102
+ const keyHex = cid.key ? Array.from(cid.key).map((byte) => byte.toString(16).padStart(2, '0')).join('') : '';
103
+ const hashHex = Array.from(cid.hash).map((byte) => byte.toString(16).padStart(2, '0')).join('');
104
+ return keyHex ? `${hashHex}:${keyHex}` : hashHex;
105
+ }
106
+
107
+ function updateLatestRecord(current: RootRecord | null, event: NostrEvent, cid: CID): RootRecord | null {
108
+ if (current && compareReplaceableEvents(event, current.event) >= 0) {
109
+ return null;
110
+ }
111
+
112
+ return { event, cid };
113
+ }
114
+
115
+ function createSubscriptionId(): string {
116
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
117
+ return crypto.randomUUID();
118
+ }
119
+ return `htree-root-${Math.random().toString(16).slice(2)}`;
120
+ }
121
+
122
+ function parseRelayMessage(data: unknown): unknown[] | null {
123
+ if (typeof data !== 'string') {
124
+ return null;
125
+ }
126
+
127
+ try {
128
+ const parsed = JSON.parse(data);
129
+ return Array.isArray(parsed) ? parsed : null;
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
135
+ function openRelaySubscriptions(
136
+ relays: string[],
137
+ filter: Record<string, unknown>,
138
+ handlers: {
139
+ onEvent?: (event: NostrEvent, relay: string) => void;
140
+ onEose?: (relay: string) => void;
141
+ onError?: (relay: string) => void;
142
+ },
143
+ ): { close(): Promise<void> } {
144
+ const subId = createSubscriptionId();
145
+ const sockets: WebSocket[] = [];
146
+ let closed = false;
147
+
148
+ for (const relay of relays) {
149
+ let socket: WebSocket | null = null;
150
+ try {
151
+ socket = new WebSocket(relay);
152
+ } catch {
153
+ handlers.onError?.(relay);
154
+ continue;
155
+ }
156
+
157
+ sockets.push(socket);
158
+
159
+ socket.onopen = () => {
160
+ if (closed) {
161
+ try {
162
+ socket?.close();
163
+ } catch {
164
+ // Ignore close errors.
165
+ }
166
+ return;
167
+ }
168
+
169
+ try {
170
+ socket.send(JSON.stringify(['REQ', subId, filter]));
171
+ } catch {
172
+ handlers.onError?.(relay);
173
+ }
174
+ };
175
+
176
+ socket.onerror = () => {
177
+ handlers.onError?.(relay);
178
+ };
179
+
180
+ socket.onmessage = (event) => {
181
+ const message = parseRelayMessage(event.data);
182
+ if (!message || message[1] !== subId) {
183
+ return;
184
+ }
185
+
186
+ if (message[0] === 'EVENT' && message[2] && typeof message[2] === 'object') {
187
+ handlers.onEvent?.(message[2] as NostrEvent, relay);
188
+ return;
189
+ }
190
+
191
+ if (message[0] === 'EOSE' || message[0] === 'CLOSED') {
192
+ handlers.onEose?.(relay);
193
+ }
194
+ };
195
+ }
196
+
197
+ return {
198
+ async close() {
199
+ if (closed) {
200
+ return;
201
+ }
202
+ closed = true;
203
+
204
+ for (const socket of sockets) {
205
+ try {
206
+ socket.send(JSON.stringify(['CLOSE', subId]));
207
+ } catch {
208
+ // Ignore close frame errors.
209
+ }
210
+ try {
211
+ socket.close();
212
+ } catch {
213
+ // Ignore socket close errors.
214
+ }
215
+ }
216
+ },
217
+ };
218
+ }
219
+
220
+ async function resolvePreferredCid(
221
+ tree: Pick<HashTree, 'resolvePath'> | null,
222
+ exactRecord: RootRecord | null,
223
+ treeRecord: RootRecord | null,
224
+ subPath: string[],
225
+ ): Promise<CID | null> {
226
+ if (exactRecord) {
227
+ return exactRecord.cid;
228
+ }
229
+
230
+ if (!treeRecord) {
231
+ return null;
232
+ }
233
+
234
+ if (subPath.length === 0) {
235
+ return treeRecord.cid;
236
+ }
237
+
238
+ if (!tree) {
239
+ throw new Error('Tree not initialized');
240
+ }
241
+
242
+ return (await tree.resolvePath(treeRecord.cid, subPath))?.cid ?? null;
243
+ }
244
+
245
+ async function queryLatestTreeRoot(
246
+ relays: string[],
247
+ npub: string,
248
+ treeName: string,
249
+ timeoutMs: number,
250
+ settleMs: number,
251
+ ): Promise<RootRecord | null> {
252
+ const pubkey = decodeNpub(npub);
253
+ if (!pubkey) {
254
+ return null;
255
+ }
256
+
257
+ return await new Promise<RootRecord | null>((resolve) => {
258
+ let closed = false;
259
+ let latestRecord: RootRecord | null = null;
260
+ let settleTimer: ReturnType<typeof setTimeout> | null = null;
261
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
262
+
263
+ const subscription = openRelaySubscriptions(relays, {
264
+ kinds: [30078],
265
+ authors: [pubkey],
266
+ '#d': [treeName],
267
+ limit: MAX_TREE_ROOT_EVENTS,
268
+ }, {
269
+ onEvent(event) {
270
+ const parsed = parseHashtreeRootEvent(event as Parameters<typeof parseHashtreeRootEvent>[0]);
271
+ if (!parsed || parsed.treeName !== treeName) {
272
+ return;
273
+ }
274
+
275
+ const nextRecord = updateLatestRecord(latestRecord, event, parsed.rootCid);
276
+ if (!nextRecord) {
277
+ return;
278
+ }
279
+
280
+ latestRecord = nextRecord;
281
+ if (settleTimer) {
282
+ clearTimeout(settleTimer);
283
+ }
284
+ settleTimer = setTimeout(() => {
285
+ finish(latestRecord);
286
+ }, settleMs);
287
+ },
288
+ onError() {
289
+ // Ignore individual relay failures and let timeout decide.
290
+ },
291
+ onEose() {
292
+ // Slower relays may still provide a newer replaceable event.
293
+ },
294
+ });
295
+
296
+ const finish = (record: RootRecord | null): void => {
297
+ if (closed) {
298
+ return;
299
+ }
300
+ closed = true;
301
+ if (settleTimer) {
302
+ clearTimeout(settleTimer);
303
+ }
304
+ if (timeoutId) {
305
+ clearTimeout(timeoutId);
306
+ }
307
+ void subscription.close().finally(() => {
308
+ resolve(record);
309
+ });
310
+ };
311
+
312
+ timeoutId = setTimeout(() => {
313
+ finish(latestRecord);
314
+ }, timeoutMs);
315
+ });
316
+ }
317
+
318
+ export async function watchRootPathFromRelays(
319
+ tree: Pick<HashTree, 'resolvePath'> | null,
320
+ relays: string[] | undefined,
321
+ npub: string,
322
+ path: string | undefined,
323
+ onUpdate: (cid: CID | null) => void | Promise<void>,
324
+ timeoutMs: number = DEFAULT_ROOT_RESOLVE_TIMEOUT_MS,
325
+ settleMs: number = DEFAULT_ROOT_RESOLVE_SETTLE_MS,
326
+ ): Promise<RootWatchHandle> {
327
+ const relayList = withUniqueRelays(relays);
328
+ const pubkey = decodeNpub(npub);
329
+ if (!pubkey) {
330
+ return {
331
+ initialCid: null,
332
+ async close() {
333
+ // no-op
334
+ },
335
+ };
336
+ }
337
+
338
+ const { exactTreeName, treeName, subPath, watchTreeNames } = parseRootLookupPath(path);
339
+ let exactRecord: RootRecord | null = null;
340
+ let treeRecord: RootRecord | null = null;
341
+ let subscription: { close(): Promise<void> } | null = null;
342
+ let settleTimer: ReturnType<typeof setTimeout> | null = null;
343
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
344
+ let resolveTicket = 0;
345
+ let currentCidKey: string | null = null;
346
+ let initialResolved = false;
347
+ let closed = false;
348
+
349
+ const close = async (): Promise<void> => {
350
+ if (closed) {
351
+ return;
352
+ }
353
+ closed = true;
354
+
355
+ if (settleTimer) {
356
+ clearTimeout(settleTimer);
357
+ }
358
+ if (timeoutId) {
359
+ clearTimeout(timeoutId);
360
+ }
361
+
362
+ await Promise.resolve(subscription?.close()).catch(() => undefined);
363
+ };
364
+
365
+ const emitCurrent = async (mode: 'initial' | 'update'): Promise<CID | null> => {
366
+ const ticket = ++resolveTicket;
367
+ const cid = await resolvePreferredCid(tree, exactRecord, treeRecord, subPath);
368
+ if (closed || ticket !== resolveTicket) {
369
+ return cid;
370
+ }
371
+
372
+ const nextKey = cidKey(cid);
373
+ if (mode === 'initial') {
374
+ if (settleTimer) {
375
+ clearTimeout(settleTimer);
376
+ settleTimer = null;
377
+ }
378
+ if (timeoutId) {
379
+ clearTimeout(timeoutId);
380
+ timeoutId = null;
381
+ }
382
+ initialResolved = true;
383
+ currentCidKey = nextKey;
384
+ return cid;
385
+ }
386
+
387
+ if (currentCidKey === nextKey) {
388
+ return cid;
389
+ }
390
+
391
+ currentCidKey = nextKey;
392
+ await onUpdate(cid);
393
+ return cid;
394
+ };
395
+
396
+ const settleInitial = (): void => {
397
+ if (settleTimer) {
398
+ clearTimeout(settleTimer);
399
+ }
400
+ settleTimer = setTimeout(() => {
401
+ void emitCurrent('initial').then((cid) => {
402
+ if (!closed) {
403
+ void onUpdate(cid);
404
+ }
405
+ });
406
+ }, settleMs);
407
+ };
408
+
409
+ timeoutId = setTimeout(() => {
410
+ void emitCurrent('initial').then((cid) => {
411
+ if (!closed) {
412
+ void onUpdate(cid);
413
+ }
414
+ });
415
+ }, timeoutMs);
416
+
417
+ subscription = openRelaySubscriptions(relayList, {
418
+ kinds: [30078],
419
+ authors: [pubkey],
420
+ '#d': watchTreeNames,
421
+ limit: Math.max(MAX_TREE_ROOT_EVENTS, watchTreeNames.length * MAX_TREE_ROOT_EVENTS),
422
+ }, {
423
+ onEvent(event) {
424
+ const parsed = parseHashtreeRootEvent(event as Parameters<typeof parseHashtreeRootEvent>[0]);
425
+ if (!parsed) {
426
+ return;
427
+ }
428
+
429
+ let updated = false;
430
+ if (parsed.treeName === exactTreeName) {
431
+ const nextRecord = updateLatestRecord(exactRecord, event, parsed.rootCid);
432
+ if (nextRecord) {
433
+ exactRecord = nextRecord;
434
+ updated = true;
435
+ }
436
+ }
437
+
438
+ if (parsed.treeName === treeName) {
439
+ const nextRecord = updateLatestRecord(treeRecord, event, parsed.rootCid);
440
+ if (nextRecord) {
441
+ treeRecord = nextRecord;
442
+ updated = true;
443
+ }
444
+ }
445
+
446
+ if (!updated) {
447
+ return;
448
+ }
449
+
450
+ if (!initialResolved) {
451
+ settleInitial();
452
+ return;
453
+ }
454
+
455
+ void emitCurrent('update');
456
+ },
457
+ onEose() {
458
+ // Ignore faster relay EOSE notifications. The live watch keeps listening.
459
+ },
460
+ onError() {
461
+ // Ignore relay close notifications. Other relays may still be active.
462
+ },
463
+ });
464
+
465
+ return {
466
+ initialCid: null,
467
+ close,
468
+ };
469
+ }
470
+
471
+ export async function resolveRootPathFromRelays(
472
+ tree: Pick<HashTree, 'resolvePath'> | null,
473
+ relays: string[] | undefined,
474
+ npub: string,
475
+ path?: string,
476
+ timeoutMs: number = DEFAULT_ROOT_RESOLVE_TIMEOUT_MS,
477
+ settleMs: number = DEFAULT_ROOT_RESOLVE_SETTLE_MS,
478
+ ): Promise<CID | null> {
479
+ const relayList = withUniqueRelays(relays);
480
+ const { exactTreeName, treeName, subPath } = parseRootLookupPath(path);
481
+
482
+ const exactRoot = await queryLatestTreeRoot(relayList, npub, exactTreeName, timeoutMs, settleMs);
483
+ if (exactRoot) {
484
+ return exactRoot.cid;
485
+ }
486
+
487
+ if (subPath.length === 0) {
488
+ return null;
489
+ }
490
+
491
+ const root = await queryLatestTreeRoot(relayList, npub, treeName, timeoutMs, settleMs);
492
+ if (!root) {
493
+ return null;
494
+ }
495
+
496
+ return resolvePreferredCid(tree, null, root, subPath);
497
+ }
@@ -0,0 +1,137 @@
1
+ export interface HtreeClientIdStorageLike {
2
+ getItem(key: string): string | null;
3
+ setItem(key: string, value: string): void;
4
+ }
5
+
6
+ export interface ResolveHtreeClientIdOptions {
7
+ storageKey?: string;
8
+ prefix?: string;
9
+ storage?: HtreeClientIdStorageLike | null;
10
+ uuidFactory?: () => string;
11
+ }
12
+
13
+ export interface AppendHtreeQueryParamOptions {
14
+ baseOrigin?: string | null;
15
+ }
16
+
17
+ const DEFAULT_STORAGE_KEY = 'htree.mediaClientId';
18
+ const DEFAULT_PREFIX = 'htc';
19
+ const cachedClientIds = new Map<string, string>();
20
+
21
+ function getDefaultStorage(): HtreeClientIdStorageLike | null {
22
+ try {
23
+ if (typeof sessionStorage !== 'undefined') {
24
+ return sessionStorage;
25
+ }
26
+ } catch {
27
+ return null;
28
+ }
29
+
30
+ try {
31
+ if (typeof window !== 'undefined' && window.sessionStorage) {
32
+ return window.sessionStorage;
33
+ }
34
+ } catch {
35
+ return null;
36
+ }
37
+
38
+ return null;
39
+ }
40
+
41
+ function hasClientRuntime(options: ResolveHtreeClientIdOptions): boolean {
42
+ return typeof window !== 'undefined'
43
+ || typeof options.storage !== 'undefined'
44
+ || typeof options.uuidFactory === 'function';
45
+ }
46
+
47
+ function getBaseOrigin(baseOrigin?: string | null): string {
48
+ if (typeof baseOrigin === 'string' && baseOrigin.trim()) {
49
+ return baseOrigin.trim().replace(/\/+$/, '');
50
+ }
51
+ if (typeof window !== 'undefined' && typeof window.location?.origin === 'string' && window.location.origin) {
52
+ return window.location.origin;
53
+ }
54
+ return 'https://example.invalid';
55
+ }
56
+
57
+ export function createHtreeClientId(
58
+ prefix = DEFAULT_PREFIX,
59
+ uuidFactory?: () => string,
60
+ ): string {
61
+ if (typeof uuidFactory === 'function') {
62
+ return uuidFactory();
63
+ }
64
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
65
+ return crypto.randomUUID();
66
+ }
67
+ return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
68
+ }
69
+
70
+ export function getOrCreateHtreeClientId(
71
+ options: ResolveHtreeClientIdOptions = {},
72
+ ): string | null {
73
+ const storageKey = options.storageKey?.trim() || DEFAULT_STORAGE_KEY;
74
+ const cached = cachedClientIds.get(storageKey);
75
+ if (cached) {
76
+ return cached;
77
+ }
78
+
79
+ const storage = typeof options.storage === 'undefined' ? getDefaultStorage() : options.storage;
80
+ try {
81
+ const existing = storage?.getItem(storageKey);
82
+ if (existing) {
83
+ cachedClientIds.set(storageKey, existing);
84
+ return existing;
85
+ }
86
+ } catch {
87
+ // Ignore storage access failures and fall back to an in-memory id.
88
+ }
89
+
90
+ if (!hasClientRuntime(options)) {
91
+ return null;
92
+ }
93
+
94
+ const nextId = createHtreeClientId(options.prefix ?? DEFAULT_PREFIX, options.uuidFactory);
95
+ try {
96
+ storage?.setItem(storageKey, nextId);
97
+ } catch {
98
+ // Ignore storage write failures.
99
+ }
100
+ cachedClientIds.set(storageKey, nextId);
101
+ return nextId;
102
+ }
103
+
104
+ export function appendHtreeQueryParam(
105
+ url: string,
106
+ key: string,
107
+ value: string | null | undefined,
108
+ options: AppendHtreeQueryParamOptions = {},
109
+ ): string {
110
+ const trimmedValue = `${value ?? ''}`.trim();
111
+ if (!trimmedValue) {
112
+ return url;
113
+ }
114
+
115
+ try {
116
+ const baseOrigin = getBaseOrigin(options.baseOrigin);
117
+ const parsed = new URL(url, baseOrigin);
118
+ parsed.searchParams.set(key, trimmedValue);
119
+
120
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(url) && parsed.origin === baseOrigin) {
121
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
122
+ }
123
+
124
+ return parsed.toString();
125
+ } catch {
126
+ const separator = url.includes('?') ? '&' : '?';
127
+ return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(trimmedValue)}`;
128
+ }
129
+ }
130
+
131
+ export function appendHtreeClientId(
132
+ url: string,
133
+ clientId: string | null | undefined,
134
+ options: AppendHtreeQueryParamOptions = {},
135
+ ): string {
136
+ return appendHtreeQueryParam(url, 'htree_c', clientId, options);
137
+ }