@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
@@ -0,0 +1,851 @@
1
+ /**
2
+ * TreeRootRegistry - Browser-side single source of truth for tree root data
3
+ *
4
+ * This module provides:
5
+ * - Unified record format for all root data
6
+ * - Subscription API that emits cached data immediately, then updates
7
+ * - Async resolve with timeout for waiting on first resolution
8
+ * - Local write tracking with dirty flag for publish throttling
9
+ * - Pluggable persistence (localStorage by default)
10
+ *
11
+ * This module lives under @hashtree/worker because it coordinates with worker
12
+ * updates and publish flows, but it runs on the app/main thread.
13
+ */
14
+
15
+ import type { Hash, TreeVisibility } from '@hashtree/core';
16
+ import { fromHex, toHex } from '@hashtree/core';
17
+
18
+ /**
19
+ * Source of the tree root update
20
+ */
21
+ export type TreeRootSource = 'local-write' | 'nostr' | 'prefetch' | 'worker';
22
+
23
+ /**
24
+ * Core record format - single source of truth for all root data
25
+ */
26
+ export interface TreeRootRecord {
27
+ hash: Hash;
28
+ key?: Hash;
29
+ visibility: TreeVisibility;
30
+ labels?: string[];
31
+ updatedAt: number; // Unix seconds (event created_at or local timestamp)
32
+ source: TreeRootSource;
33
+ dirty: boolean; // Local writes pending publish
34
+
35
+ // Visibility-specific fields
36
+ encryptedKey?: string; // For link-visible: XOR(contentKey, linkKey)
37
+ keyId?: string; // For link-visible: derived from linkKey
38
+ selfEncryptedKey?: string; // For private: NIP-44 encrypted content key
39
+ selfEncryptedLinkKey?: string; // For link-visible: NIP-44 encrypted link key
40
+ }
41
+
42
+ /**
43
+ * Serialized format for localStorage persistence
44
+ */
45
+ interface PersistedRecord {
46
+ hash: string; // hex
47
+ key?: string; // hex
48
+ visibility: TreeVisibility;
49
+ labels?: string[];
50
+ updatedAt: number;
51
+ source: TreeRootSource;
52
+ dirty: boolean;
53
+ encryptedKey?: string;
54
+ keyId?: string;
55
+ selfEncryptedKey?: string;
56
+ selfEncryptedLinkKey?: string;
57
+ }
58
+
59
+ /**
60
+ * Listener callback type
61
+ */
62
+ type Listener = (record: TreeRootRecord | null) => void;
63
+
64
+ /**
65
+ * Persistence interface - allows swapping localStorage for IndexedDB/etc
66
+ */
67
+ export interface RegistryPersistence {
68
+ save(key: string, record: TreeRootRecord): void;
69
+ load(key: string): TreeRootRecord | null;
70
+ delete(key: string): void;
71
+ loadAll(): Map<string, TreeRootRecord>;
72
+ }
73
+
74
+ const STORAGE_KEY = 'hashtree:localRootCache';
75
+
76
+ /**
77
+ * Default localStorage persistence
78
+ */
79
+ class LocalStoragePersistence implements RegistryPersistence {
80
+ private cache: Map<string, TreeRootRecord> | null = null;
81
+
82
+ private serializeRecord(record: TreeRootRecord): PersistedRecord {
83
+ return {
84
+ hash: toHex(record.hash),
85
+ key: record.key ? toHex(record.key) : undefined,
86
+ visibility: record.visibility,
87
+ labels: record.labels,
88
+ updatedAt: record.updatedAt,
89
+ source: record.source,
90
+ dirty: record.dirty,
91
+ encryptedKey: record.encryptedKey,
92
+ keyId: record.keyId,
93
+ selfEncryptedKey: record.selfEncryptedKey,
94
+ selfEncryptedLinkKey: record.selfEncryptedLinkKey,
95
+ };
96
+ }
97
+
98
+ private deserializeRecord(data: PersistedRecord): TreeRootRecord | null {
99
+ try {
100
+ return {
101
+ hash: fromHex(data.hash),
102
+ key: data.key ? fromHex(data.key) : undefined,
103
+ visibility: data.visibility,
104
+ labels: uniqueLabels(data.labels),
105
+ updatedAt: data.updatedAt,
106
+ source: data.source,
107
+ dirty: data.dirty,
108
+ encryptedKey: data.encryptedKey,
109
+ keyId: data.keyId,
110
+ selfEncryptedKey: data.selfEncryptedKey,
111
+ selfEncryptedLinkKey: data.selfEncryptedLinkKey,
112
+ };
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ save(key: string, record: TreeRootRecord): void {
119
+ if (typeof window === 'undefined') return;
120
+
121
+ // Update in-memory cache
122
+ if (!this.cache) {
123
+ this.cache = this.loadAll();
124
+ }
125
+ this.cache.set(key, record);
126
+
127
+ // Persist to localStorage
128
+ try {
129
+ const data: Record<string, PersistedRecord> = {};
130
+ for (const [k, r] of this.cache.entries()) {
131
+ data[k] = this.serializeRecord(r);
132
+ }
133
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
134
+ } catch {
135
+ // Ignore persistence errors (storage may be full/unavailable)
136
+ }
137
+ }
138
+
139
+ load(key: string): TreeRootRecord | null {
140
+ if (!this.cache) {
141
+ this.cache = this.loadAll();
142
+ }
143
+ return this.cache.get(key) ?? null;
144
+ }
145
+
146
+ delete(key: string): void {
147
+ if (typeof window === 'undefined') return;
148
+
149
+ if (!this.cache) {
150
+ this.cache = this.loadAll();
151
+ }
152
+ this.cache.delete(key);
153
+
154
+ try {
155
+ const data: Record<string, PersistedRecord> = {};
156
+ for (const [k, r] of this.cache.entries()) {
157
+ data[k] = this.serializeRecord(r);
158
+ }
159
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
160
+ } catch {
161
+ // Ignore persistence errors
162
+ }
163
+ }
164
+
165
+ loadAll(): Map<string, TreeRootRecord> {
166
+ const result = new Map<string, TreeRootRecord>();
167
+ if (typeof window === 'undefined') return result;
168
+
169
+ try {
170
+ const raw = window.localStorage.getItem(STORAGE_KEY);
171
+ if (!raw) return result;
172
+
173
+ const data = JSON.parse(raw) as Record<string, PersistedRecord>;
174
+ for (const [key, persisted] of Object.entries(data)) {
175
+ const record = this.deserializeRecord(persisted);
176
+ if (record) {
177
+ result.set(key, record);
178
+ }
179
+ }
180
+ } catch {
181
+ // Ignore parse errors
182
+ }
183
+
184
+ this.cache = result;
185
+ return result;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * TreeRootRegistry - singleton class for managing tree root data
191
+ */
192
+ class TreeRootRegistryImpl {
193
+ private records = new Map<string, TreeRootRecord>();
194
+ private listeners = new Map<string, Set<Listener>>();
195
+ private globalListeners = new Set<(key: string, record: TreeRootRecord | null) => void>();
196
+ private persistence: RegistryPersistence;
197
+
198
+ // Publish throttling
199
+ private publishTimers = new Map<string, ReturnType<typeof setTimeout>>();
200
+ private publishFn: ((npub: string, treeName: string, record: TreeRootRecord) => Promise<boolean>) | null = null;
201
+ private publishDelay = 1000;
202
+ private retryDelay = 5000;
203
+
204
+ constructor(persistence?: RegistryPersistence) {
205
+ this.persistence = persistence ?? new LocalStoragePersistence();
206
+ this.hydrate();
207
+ }
208
+
209
+ /**
210
+ * Hydrate from persistence on startup
211
+ */
212
+ private hydrate(): void {
213
+ const persisted = this.persistence.loadAll();
214
+ for (const [key, record] of persisted.entries()) {
215
+ this.records.set(key, record);
216
+
217
+ // Schedule publish for dirty entries
218
+ if (record.dirty) {
219
+ const [npub, ...treeNameParts] = key.split('/');
220
+ const treeName = treeNameParts.join('/');
221
+ if (npub && treeName) {
222
+ this.schedulePublish(npub, treeName);
223
+ }
224
+ }
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Set the publish function (called with throttling for dirty records)
230
+ */
231
+ setPublishFn(fn: (npub: string, treeName: string, record: TreeRootRecord) => Promise<boolean>): void {
232
+ this.publishFn = fn;
233
+ }
234
+
235
+ /**
236
+ * Build cache key from npub and treeName
237
+ */
238
+ private makeKey(npub: string, treeName: string): string {
239
+ return `${npub}/${treeName}`;
240
+ }
241
+
242
+ /**
243
+ * Notify listeners of a record change
244
+ */
245
+ private notify(key: string, record: TreeRootRecord | null): void {
246
+ const keyListeners = this.listeners.get(key);
247
+ if (keyListeners) {
248
+ for (const listener of keyListeners) {
249
+ try {
250
+ listener(record);
251
+ } catch (e) {
252
+ console.error('[TreeRootRegistry] Listener error:', e);
253
+ }
254
+ }
255
+ }
256
+
257
+ // Notify global listeners
258
+ for (const listener of this.globalListeners) {
259
+ try {
260
+ listener(key, record);
261
+ } catch (e) {
262
+ console.error('[TreeRootRegistry] Global listener error:', e);
263
+ }
264
+ }
265
+ }
266
+
267
+ private shouldAcceptUpdate(
268
+ existing: TreeRootRecord | undefined,
269
+ hash: Hash,
270
+ key: Hash | undefined,
271
+ updatedAt: number
272
+ ): boolean {
273
+ if (!existing) return true;
274
+ if (existing.dirty) return false;
275
+ if (existing.updatedAt > updatedAt) return false;
276
+ if (existing.updatedAt === updatedAt) {
277
+ if (toHex(existing.hash) === toHex(hash)) {
278
+ if (!key) return false;
279
+ if (existing.key && toHex(existing.key) === toHex(key)) return false;
280
+ }
281
+ }
282
+ return true;
283
+ }
284
+
285
+ private mergeSameHashMetadata(
286
+ existing: TreeRootRecord | undefined,
287
+ hash: Hash,
288
+ options?: {
289
+ key?: Hash;
290
+ visibility?: TreeVisibility;
291
+ labels?: string[];
292
+ encryptedKey?: string;
293
+ keyId?: string;
294
+ selfEncryptedKey?: string;
295
+ selfEncryptedLinkKey?: string;
296
+ }
297
+ ): boolean {
298
+ if (!existing) return false;
299
+ if (existing.dirty) return false;
300
+ if (toHex(existing.hash) !== toHex(hash)) return false;
301
+
302
+ let changed = false;
303
+
304
+ if (!existing.key && options?.key) {
305
+ existing.key = options.key;
306
+ changed = true;
307
+ }
308
+
309
+ if (existing.visibility === 'public' && options?.visibility && options.visibility !== 'public') {
310
+ existing.visibility = options.visibility;
311
+ changed = true;
312
+ }
313
+
314
+ const mergedLabels = mergeLabels(options?.labels, existing.labels);
315
+ if (mergedLabels && JSON.stringify(mergedLabels) !== JSON.stringify(existing.labels)) {
316
+ existing.labels = mergedLabels;
317
+ changed = true;
318
+ }
319
+
320
+ if (!existing.encryptedKey && options?.encryptedKey) {
321
+ existing.encryptedKey = options.encryptedKey;
322
+ changed = true;
323
+ }
324
+
325
+ if (!existing.keyId && options?.keyId) {
326
+ existing.keyId = options.keyId;
327
+ changed = true;
328
+ }
329
+
330
+ if (!existing.selfEncryptedKey && options?.selfEncryptedKey) {
331
+ existing.selfEncryptedKey = options.selfEncryptedKey;
332
+ changed = true;
333
+ }
334
+
335
+ if (!existing.selfEncryptedLinkKey && options?.selfEncryptedLinkKey) {
336
+ existing.selfEncryptedLinkKey = options.selfEncryptedLinkKey;
337
+ changed = true;
338
+ }
339
+
340
+ return changed;
341
+ }
342
+
343
+ /**
344
+ * Sync lookup - returns cached record or null (no side effects)
345
+ */
346
+ get(npub: string, treeName: string): TreeRootRecord | null {
347
+ return this.records.get(this.makeKey(npub, treeName)) ?? null;
348
+ }
349
+
350
+ /**
351
+ * Get by key directly
352
+ */
353
+ getByKey(key: string): TreeRootRecord | null {
354
+ return this.records.get(key) ?? null;
355
+ }
356
+
357
+ /**
358
+ * Async resolve - returns current record if cached, otherwise waits for first resolve
359
+ */
360
+ async resolve(
361
+ npub: string,
362
+ treeName: string,
363
+ options?: { timeoutMs?: number }
364
+ ): Promise<TreeRootRecord | null> {
365
+ const key = this.makeKey(npub, treeName);
366
+ const existing = this.records.get(key);
367
+ if (existing) {
368
+ return existing;
369
+ }
370
+
371
+ const timeoutMs = options?.timeoutMs ?? 10000;
372
+
373
+ return new Promise((resolve) => {
374
+ let resolved = false;
375
+ let unsubscribe: (() => void) | null = null;
376
+
377
+ const timeout = setTimeout(() => {
378
+ if (!resolved) {
379
+ resolved = true;
380
+ unsubscribe?.();
381
+ resolve(null);
382
+ }
383
+ }, timeoutMs);
384
+
385
+ unsubscribe = this.subscribe(npub, treeName, (record) => {
386
+ if (!resolved && record) {
387
+ resolved = true;
388
+ clearTimeout(timeout);
389
+ unsubscribe?.();
390
+ resolve(record);
391
+ }
392
+ });
393
+ });
394
+ }
395
+
396
+ /**
397
+ * Subscribe to updates for a specific tree
398
+ * Emits current snapshot immediately if available, then future updates
399
+ */
400
+ subscribe(npub: string, treeName: string, callback: Listener): () => void {
401
+ const key = this.makeKey(npub, treeName);
402
+
403
+ let keyListeners = this.listeners.get(key);
404
+ if (!keyListeners) {
405
+ keyListeners = new Set();
406
+ this.listeners.set(key, keyListeners);
407
+ }
408
+ keyListeners.add(callback);
409
+
410
+ // Emit current snapshot if available
411
+ const existing = this.records.get(key);
412
+ if (existing) {
413
+ // Use queueMicrotask to ensure callback is async
414
+ queueMicrotask(() => callback(existing));
415
+ }
416
+
417
+ return () => {
418
+ const listeners = this.listeners.get(key);
419
+ if (listeners) {
420
+ listeners.delete(callback);
421
+ if (listeners.size === 0) {
422
+ this.listeners.delete(key);
423
+ }
424
+ }
425
+ };
426
+ }
427
+
428
+ /**
429
+ * Subscribe to all registry updates (for bridges like Tauri/worker)
430
+ */
431
+ subscribeAll(callback: (key: string, record: TreeRootRecord | null) => void): () => void {
432
+ this.globalListeners.add(callback);
433
+ return () => {
434
+ this.globalListeners.delete(callback);
435
+ };
436
+ }
437
+
438
+ /**
439
+ * Set record from local write - marks dirty and schedules publish
440
+ */
441
+ setLocal(
442
+ npub: string,
443
+ treeName: string,
444
+ hash: Hash,
445
+ options?: {
446
+ key?: Hash;
447
+ visibility?: TreeVisibility;
448
+ labels?: string[];
449
+ encryptedKey?: string;
450
+ keyId?: string;
451
+ selfEncryptedKey?: string;
452
+ selfEncryptedLinkKey?: string;
453
+ }
454
+ ): void {
455
+ const cacheKey = this.makeKey(npub, treeName);
456
+ const existing = this.records.get(cacheKey);
457
+
458
+ // Preserve existing visibility if not provided
459
+ const visibility = options?.visibility ?? existing?.visibility ?? 'public';
460
+
461
+ const record: TreeRootRecord = {
462
+ hash,
463
+ key: options?.key,
464
+ visibility,
465
+ labels: uniqueLabels(options?.labels) ?? existing?.labels,
466
+ updatedAt: Math.floor(Date.now() / 1000),
467
+ source: 'local-write',
468
+ dirty: true,
469
+ encryptedKey: options?.encryptedKey ?? existing?.encryptedKey,
470
+ keyId: options?.keyId ?? existing?.keyId,
471
+ selfEncryptedKey: options?.selfEncryptedKey ?? existing?.selfEncryptedKey,
472
+ selfEncryptedLinkKey: options?.selfEncryptedLinkKey ?? existing?.selfEncryptedLinkKey,
473
+ };
474
+
475
+ this.records.set(cacheKey, record);
476
+ this.persistence.save(cacheKey, record);
477
+ this.notify(cacheKey, record);
478
+ this.schedulePublish(npub, treeName);
479
+ }
480
+
481
+ /**
482
+ * Set record from resolver (Nostr event) - only updates if newer
483
+ */
484
+ setFromResolver(
485
+ npub: string,
486
+ treeName: string,
487
+ hash: Hash,
488
+ updatedAt: number,
489
+ options?: {
490
+ key?: Hash;
491
+ visibility?: TreeVisibility;
492
+ labels?: string[];
493
+ encryptedKey?: string;
494
+ keyId?: string;
495
+ selfEncryptedKey?: string;
496
+ selfEncryptedLinkKey?: string;
497
+ }
498
+ ): boolean {
499
+ const cacheKey = this.makeKey(npub, treeName);
500
+ const existing = this.records.get(cacheKey);
501
+ const sameHash = !!existing && toHex(existing.hash) === toHex(hash);
502
+
503
+ // Only update if newer (based on updatedAt timestamp), or same timestamp with new hash/key
504
+ if (!this.shouldAcceptUpdate(existing ?? undefined, hash, options?.key, updatedAt)) {
505
+ if (this.mergeSameHashMetadata(existing ?? undefined, hash, options)) {
506
+ this.persistence.save(cacheKey, existing!);
507
+ this.notify(cacheKey, existing!);
508
+ return true;
509
+ }
510
+ return false;
511
+ }
512
+
513
+ const record: TreeRootRecord = {
514
+ hash,
515
+ // Preserve known key when newer resolver updates omit it for the same hash.
516
+ key: options?.key ?? (sameHash ? existing?.key : undefined),
517
+ visibility: options?.visibility ?? 'public',
518
+ labels: uniqueLabels(options?.labels) ?? existing?.labels,
519
+ updatedAt,
520
+ source: 'nostr',
521
+ dirty: false,
522
+ encryptedKey: options?.encryptedKey ?? (sameHash ? existing?.encryptedKey : undefined),
523
+ keyId: options?.keyId ?? (sameHash ? existing?.keyId : undefined),
524
+ selfEncryptedKey: options?.selfEncryptedKey ?? (sameHash ? existing?.selfEncryptedKey : undefined),
525
+ selfEncryptedLinkKey: options?.selfEncryptedLinkKey ?? (sameHash ? existing?.selfEncryptedLinkKey : undefined),
526
+ };
527
+
528
+ this.records.set(cacheKey, record);
529
+ this.persistence.save(cacheKey, record);
530
+ this.notify(cacheKey, record);
531
+ return true;
532
+ }
533
+
534
+ /**
535
+ * Merge a decrypted key into an existing record without changing updatedAt/source.
536
+ * Returns true if the record was updated.
537
+ */
538
+ mergeKey(
539
+ npub: string,
540
+ treeName: string,
541
+ hash: Hash,
542
+ key: Hash
543
+ ): boolean {
544
+ const cacheKey = this.makeKey(npub, treeName);
545
+ const existing = this.records.get(cacheKey);
546
+ if (!existing) return false;
547
+
548
+ if (toHex(existing.hash) !== toHex(hash)) return false;
549
+ if (existing.key) return false;
550
+
551
+ existing.key = key;
552
+ this.persistence.save(cacheKey, existing);
553
+ this.notify(cacheKey, existing);
554
+ return true;
555
+ }
556
+
557
+ /**
558
+ * Set record from worker (Nostr subscription routed through worker)
559
+ * Similar to setFromResolver but source is 'worker'
560
+ */
561
+ setFromWorker(
562
+ npub: string,
563
+ treeName: string,
564
+ hash: Hash,
565
+ updatedAt: number,
566
+ options?: {
567
+ key?: Hash;
568
+ visibility?: TreeVisibility;
569
+ labels?: string[];
570
+ encryptedKey?: string;
571
+ keyId?: string;
572
+ selfEncryptedKey?: string;
573
+ selfEncryptedLinkKey?: string;
574
+ }
575
+ ): boolean {
576
+ const cacheKey = this.makeKey(npub, treeName);
577
+ const existing = this.records.get(cacheKey);
578
+ const sameHash = !!existing && toHex(existing.hash) === toHex(hash);
579
+
580
+ // Only update if newer (based on updatedAt timestamp), or same timestamp with new hash/key
581
+ if (!this.shouldAcceptUpdate(existing ?? undefined, hash, options?.key, updatedAt)) {
582
+ if (this.mergeSameHashMetadata(existing ?? undefined, hash, options)) {
583
+ this.persistence.save(cacheKey, existing!);
584
+ this.notify(cacheKey, existing!);
585
+ return true;
586
+ }
587
+ return false;
588
+ }
589
+
590
+ const record: TreeRootRecord = {
591
+ hash,
592
+ // Preserve known key when worker updates omit it for the same hash.
593
+ key: options?.key ?? (sameHash ? existing?.key : undefined),
594
+ visibility: options?.visibility ?? 'public',
595
+ labels: uniqueLabels(options?.labels) ?? existing?.labels,
596
+ updatedAt,
597
+ source: 'worker',
598
+ dirty: false,
599
+ encryptedKey: options?.encryptedKey ?? (sameHash ? existing?.encryptedKey : undefined),
600
+ keyId: options?.keyId ?? (sameHash ? existing?.keyId : undefined),
601
+ selfEncryptedKey: options?.selfEncryptedKey ?? (sameHash ? existing?.selfEncryptedKey : undefined),
602
+ selfEncryptedLinkKey: options?.selfEncryptedLinkKey ?? (sameHash ? existing?.selfEncryptedLinkKey : undefined),
603
+ };
604
+
605
+ this.records.set(cacheKey, record);
606
+ this.persistence.save(cacheKey, record);
607
+ this.notify(cacheKey, record);
608
+ return true;
609
+ }
610
+
611
+ /**
612
+ * Set record from external source (Tauri, worker, prefetch)
613
+ */
614
+ setFromExternal(
615
+ npub: string,
616
+ treeName: string,
617
+ hash: Hash,
618
+ source: TreeRootSource,
619
+ options?: {
620
+ key?: Hash;
621
+ visibility?: TreeVisibility;
622
+ labels?: string[];
623
+ updatedAt?: number;
624
+ encryptedKey?: string;
625
+ keyId?: string;
626
+ selfEncryptedKey?: string;
627
+ selfEncryptedLinkKey?: string;
628
+ }
629
+ ): void {
630
+ const cacheKey = this.makeKey(npub, treeName);
631
+ const existing = this.records.get(cacheKey);
632
+ const sameHash = !!existing && toHex(existing.hash) === toHex(hash);
633
+
634
+ // Don't overwrite dirty local writes
635
+ if (existing?.dirty) {
636
+ return;
637
+ }
638
+
639
+ const updatedAt = options?.updatedAt ?? Math.floor(Date.now() / 1000);
640
+
641
+ // Only update if newer (based on updatedAt timestamp), or same timestamp with new hash/key
642
+ if (!this.shouldAcceptUpdate(existing ?? undefined, hash, options?.key, updatedAt)) {
643
+ if (this.mergeSameHashMetadata(existing ?? undefined, hash, options)) {
644
+ this.persistence.save(cacheKey, existing!);
645
+ this.notify(cacheKey, existing!);
646
+ }
647
+ return;
648
+ }
649
+
650
+ const record: TreeRootRecord = {
651
+ hash,
652
+ key: options?.key ?? (sameHash ? existing?.key : undefined),
653
+ visibility: options?.visibility ?? existing?.visibility ?? 'public',
654
+ labels: uniqueLabels(options?.labels) ?? existing?.labels,
655
+ updatedAt,
656
+ source,
657
+ dirty: false,
658
+ encryptedKey: options?.encryptedKey ?? (sameHash ? existing?.encryptedKey : undefined),
659
+ keyId: options?.keyId ?? (sameHash ? existing?.keyId : undefined),
660
+ selfEncryptedKey: options?.selfEncryptedKey ?? (sameHash ? existing?.selfEncryptedKey : undefined),
661
+ selfEncryptedLinkKey: options?.selfEncryptedLinkKey ?? (sameHash ? existing?.selfEncryptedLinkKey : undefined),
662
+ };
663
+
664
+ this.records.set(cacheKey, record);
665
+ this.persistence.save(cacheKey, record);
666
+ this.notify(cacheKey, record);
667
+ }
668
+
669
+ /**
670
+ * Delete a record
671
+ */
672
+ delete(npub: string, treeName: string): void {
673
+ const key = this.makeKey(npub, treeName);
674
+
675
+ // Cancel any pending publish
676
+ const timer = this.publishTimers.get(key);
677
+ if (timer) {
678
+ clearTimeout(timer);
679
+ this.publishTimers.delete(key);
680
+ }
681
+
682
+ this.records.delete(key);
683
+ this.persistence.delete(key);
684
+ this.notify(key, null);
685
+ }
686
+
687
+ /**
688
+ * Schedule a throttled publish
689
+ */
690
+ private schedulePublish(npub: string, treeName: string, delay: number = this.publishDelay): void {
691
+ const key = this.makeKey(npub, treeName);
692
+
693
+ // Clear existing timer
694
+ const existingTimer = this.publishTimers.get(key);
695
+ if (existingTimer) {
696
+ clearTimeout(existingTimer);
697
+ }
698
+
699
+ // Schedule new publish
700
+ const timer = setTimeout(() => {
701
+ this.publishTimers.delete(key);
702
+ this.doPublish(npub, treeName);
703
+ }, delay);
704
+
705
+ this.publishTimers.set(key, timer);
706
+ }
707
+
708
+ /**
709
+ * Execute the publish
710
+ */
711
+ private async doPublish(npub: string, treeName: string): Promise<void> {
712
+ const key = this.makeKey(npub, treeName);
713
+ const record = this.records.get(key);
714
+
715
+ if (!record || !record.dirty || !this.publishFn) {
716
+ return;
717
+ }
718
+
719
+ try {
720
+ const success = await this.publishFn(npub, treeName, record);
721
+
722
+ if (success) {
723
+ // Mark as clean (published)
724
+ // Re-check record in case it changed during async publish
725
+ const currentRecord = this.records.get(key);
726
+ if (currentRecord && toHex(currentRecord.hash) === toHex(record.hash)) {
727
+ currentRecord.dirty = false;
728
+ this.persistence.save(key, currentRecord);
729
+ }
730
+ } else if (!this.publishTimers.has(key)) {
731
+ this.schedulePublish(npub, treeName, this.retryDelay);
732
+ }
733
+ } catch (e) {
734
+ console.error('[TreeRootRegistry] Publish failed:', e);
735
+ if (!this.publishTimers.has(key)) {
736
+ this.schedulePublish(npub, treeName, this.retryDelay);
737
+ }
738
+ }
739
+ }
740
+
741
+ /**
742
+ * Force immediate publish of all dirty records
743
+ */
744
+ async flushPendingPublishes(): Promise<void> {
745
+ if (!this.publishFn) {
746
+ console.warn('[TreeRootRegistry] flushPendingPublishes: publishFn not set');
747
+ return;
748
+ }
749
+
750
+ const promises: Promise<void>[] = [];
751
+
752
+ for (const [key, timer] of this.publishTimers) {
753
+ clearTimeout(timer);
754
+ this.publishTimers.delete(key);
755
+
756
+ const [npub, ...treeNameParts] = key.split('/');
757
+ const treeName = treeNameParts.join('/');
758
+ if (npub && treeName) {
759
+ promises.push(this.doPublish(npub, treeName));
760
+ }
761
+ }
762
+
763
+ await Promise.all(promises);
764
+ }
765
+
766
+ /**
767
+ * Cancel pending publish (call before delete to prevent "undelete")
768
+ */
769
+ cancelPendingPublish(npub: string, treeName: string): void {
770
+ const key = this.makeKey(npub, treeName);
771
+ const timer = this.publishTimers.get(key);
772
+ if (timer) {
773
+ clearTimeout(timer);
774
+ this.publishTimers.delete(key);
775
+ }
776
+ }
777
+
778
+ /**
779
+ * Get all records (for debugging/migration)
780
+ */
781
+ getAllRecords(): Map<string, TreeRootRecord> {
782
+ return new Map(this.records);
783
+ }
784
+
785
+ /**
786
+ * Check if a record exists
787
+ */
788
+ has(npub: string, treeName: string): boolean {
789
+ return this.records.has(this.makeKey(npub, treeName));
790
+ }
791
+
792
+ /**
793
+ * Get visibility for a tree
794
+ */
795
+ getVisibility(npub: string, treeName: string): TreeVisibility | undefined {
796
+ return this.records.get(this.makeKey(npub, treeName))?.visibility;
797
+ }
798
+
799
+ /**
800
+ * Get labels for a tree
801
+ */
802
+ getLabels(npub: string, treeName: string): string[] | undefined {
803
+ return this.records.get(this.makeKey(npub, treeName))?.labels;
804
+ }
805
+ }
806
+
807
+ // Singleton instance - use window to survive HMR
808
+ declare global {
809
+ interface Window {
810
+ __treeRootRegistry?: TreeRootRegistryImpl;
811
+ }
812
+ }
813
+
814
+ function getRegistry(): TreeRootRegistryImpl {
815
+ if (typeof window !== 'undefined' && window.__treeRootRegistry) {
816
+ return window.__treeRootRegistry;
817
+ }
818
+
819
+ const registry = new TreeRootRegistryImpl();
820
+
821
+ if (typeof window !== 'undefined') {
822
+ window.__treeRootRegistry = registry;
823
+ }
824
+
825
+ return registry;
826
+ }
827
+
828
+ function uniqueLabels(labels: string[] | undefined): string[] | undefined {
829
+ if (!labels?.length) return undefined;
830
+
831
+ const deduped: string[] = [];
832
+ const seen = new Set<string>();
833
+ for (const label of labels) {
834
+ if (!label || seen.has(label)) continue;
835
+ seen.add(label);
836
+ deduped.push(label);
837
+ }
838
+ return deduped.length > 0 ? deduped : undefined;
839
+ }
840
+
841
+ function mergeLabels(primary: string[] | undefined, fallback: string[] | undefined): string[] | undefined {
842
+ if (!primary?.length) return uniqueLabels(fallback);
843
+ if (!fallback?.length) return uniqueLabels(primary);
844
+ return uniqueLabels([...primary, ...fallback]);
845
+ }
846
+
847
+ // Export singleton instance
848
+ export const treeRootRegistry = getRegistry();
849
+
850
+ // Export types for consumers
851
+ export type { TreeRootRecord as TreeRootEntry };