@hashtree/worker 0.2.0 → 0.2.2
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.
- package/package.json +7 -3
- package/src/app-runtime.ts +393 -0
- package/src/capabilities/blossomBandwidthTracker.ts +74 -0
- package/src/capabilities/blossomTransport.ts +179 -0
- package/src/capabilities/connectivity.ts +54 -0
- package/src/capabilities/idbStorage.ts +94 -0
- package/src/capabilities/meshRouterStore.ts +426 -0
- package/src/capabilities/rootResolver.ts +497 -0
- package/src/client-id.ts +137 -0
- package/src/client.ts +501 -0
- package/src/entry.ts +3 -0
- package/src/htree-path.ts +53 -0
- package/src/htree-url.ts +156 -0
- package/src/index.ts +76 -0
- package/src/mediaStreaming.ts +64 -0
- package/src/p2p/boundedQueue.ts +168 -0
- package/src/p2p/errorMessage.ts +6 -0
- package/src/p2p/index.ts +48 -0
- package/src/p2p/lruCache.ts +78 -0
- package/src/p2p/meshQueryRouter.ts +361 -0
- package/src/p2p/protocol.ts +11 -0
- package/src/p2p/queryForwardingMachine.ts +197 -0
- package/src/p2p/signaling.ts +284 -0
- package/src/p2p/uploadRateLimiter.ts +85 -0
- package/src/p2p/webrtcController.ts +1168 -0
- package/src/p2p/webrtcProxy.ts +519 -0
- package/src/privacyGuards.ts +31 -0
- package/src/protocol.ts +124 -0
- package/src/relay/identity.ts +86 -0
- package/src/relay/mediaHandler.ts +1633 -0
- package/src/relay/ndk.ts +590 -0
- package/src/relay/nostr-wasm.ts +249 -0
- package/src/relay/nostr.ts +249 -0
- package/src/relay/protocol.ts +361 -0
- package/src/relay/publicAssetUrl.ts +25 -0
- package/src/relay/rootPathResolver.ts +50 -0
- package/src/relay/shims.d.ts +17 -0
- package/src/relay/signing.ts +332 -0
- package/src/relay/treeRootCache.ts +354 -0
- package/src/relay/treeRootSubscription.ts +577 -0
- package/src/relay/utils/constants.ts +139 -0
- package/src/relay/utils/errorMessage.ts +7 -0
- package/src/relay/utils/lruCache.ts +79 -0
- package/src/relay/webrtc.ts +5 -0
- package/src/relay/webrtcSignaling.ts +108 -0
- package/src/relay/worker.ts +1787 -0
- package/src/relay-client.ts +265 -0
- package/src/relay-entry.ts +1 -0
- package/src/runtime-network.ts +134 -0
- package/src/runtime.ts +153 -0
- package/src/transferableBytes.ts +5 -0
- package/src/tree-root.ts +851 -0
- package/src/types.ts +8 -0
- package/src/worker.ts +975 -0
package/src/tree-root.ts
ADDED
|
@@ -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 };
|