@abraca/dabra 1.0.13 → 1.0.15
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/dist/abracadabra-provider.cjs +218 -67
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +218 -67
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +45 -3
- package/package.json +1 -1
- package/src/AbracadabraBaseProvider.ts +7 -1
- package/src/AbracadabraClient.ts +52 -42
- package/src/AbracadabraProvider.ts +19 -2
- package/src/AbracadabraWS.ts +5 -1
- package/src/BackgroundSyncManager.ts +56 -4
- package/src/DocKeyManager.ts +6 -3
- package/src/EventEmitter.ts +16 -1
- package/src/FileBlobStore.ts +82 -22
- package/src/SearchIndex.ts +3 -0
- package/src/webrtc/DataChannelRouter.ts +3 -2
- package/src/webrtc/FileTransferChannel.ts +1 -0
- package/src/webrtc/ManualSignaling.ts +5 -1
- package/src/webrtc/SignalingSocket.ts +12 -0
- package/src/webrtc/YjsDataChannel.ts +1 -0
package/dist/index.d.ts
CHANGED
|
@@ -126,6 +126,7 @@ declare class EventEmitter {
|
|
|
126
126
|
[key: string]: Function[];
|
|
127
127
|
};
|
|
128
128
|
on(event: string, fn: Function): this;
|
|
129
|
+
once(event: string, fn: Function): this;
|
|
129
130
|
protected emit(event: string, ...args: any): this;
|
|
130
131
|
off(event: string, fn?: Function): this;
|
|
131
132
|
removeAllListeners(): void;
|
|
@@ -256,6 +257,8 @@ declare class AbracadabraClient {
|
|
|
256
257
|
get token(): string | null;
|
|
257
258
|
set token(value: string | null);
|
|
258
259
|
get isAuthenticated(): boolean;
|
|
260
|
+
/** Check if the current JWT token is present and not expired. */
|
|
261
|
+
isTokenValid(): boolean;
|
|
259
262
|
/** Derives ws:// or wss:// URL from the http(s) base URL. */
|
|
260
263
|
get wsUrl(): string;
|
|
261
264
|
/** Register a new user with password. */
|
|
@@ -427,6 +430,7 @@ declare class AbracadabraClient {
|
|
|
427
430
|
*/
|
|
428
431
|
getIceServers(): Promise<RTCIceServer[]>;
|
|
429
432
|
private request;
|
|
433
|
+
private requestOrNull;
|
|
430
434
|
private toError;
|
|
431
435
|
private loadPersistedToken;
|
|
432
436
|
private persistToken;
|
|
@@ -508,6 +512,7 @@ declare class CryptoIdentityKeystore {
|
|
|
508
512
|
//#region packages/provider/src/DocKeyManager.d.ts
|
|
509
513
|
declare class DocKeyManager {
|
|
510
514
|
private cache;
|
|
515
|
+
private static readonly CACHE_TTL;
|
|
511
516
|
/** Generate a new random AES-256-GCM document key. */
|
|
512
517
|
static generateDocKey(): Promise<CryptoKey>;
|
|
513
518
|
/**
|
|
@@ -655,6 +660,10 @@ declare class AbracadabraProvider extends AbracadabraBaseProvider {
|
|
|
655
660
|
* If offline the event is queued in IndexedDB and replayed on reconnect.
|
|
656
661
|
*/
|
|
657
662
|
private registerSubdoc;
|
|
663
|
+
/** Get a loaded child provider by ID, or null if not yet loaded. */
|
|
664
|
+
getChild(childId: string): AbracadabraProvider | null;
|
|
665
|
+
/** Check if a child provider is already loaded. */
|
|
666
|
+
hasChild(childId: string): boolean;
|
|
658
667
|
/**
|
|
659
668
|
* Create (or return cached) a child AbracadabraProvider for a given
|
|
660
669
|
* child document id. Each child opens its own WebSocket connection because
|
|
@@ -1239,10 +1248,14 @@ declare class AbracadabraBaseProvider extends EventEmitter {
|
|
|
1239
1248
|
isSynced: boolean;
|
|
1240
1249
|
unsyncedChanges: number;
|
|
1241
1250
|
isAuthenticated: boolean;
|
|
1251
|
+
/** Current WebSocket connection status. */
|
|
1252
|
+
get connectionStatus(): WebSocketStatus;
|
|
1242
1253
|
authorizedScope: AuthorizedScope | undefined;
|
|
1243
1254
|
manageSocket: boolean;
|
|
1244
1255
|
private _isAttached;
|
|
1245
|
-
intervals:
|
|
1256
|
+
intervals: {
|
|
1257
|
+
forceSync: ReturnType<typeof setInterval> | null;
|
|
1258
|
+
};
|
|
1246
1259
|
constructor(configuration: AbracadabraBaseProviderConfiguration);
|
|
1247
1260
|
boundDocumentUpdateHandler: (update: Uint8Array, origin: any) => void;
|
|
1248
1261
|
boundAwarenessUpdateHandler: ({
|
|
@@ -1375,7 +1388,7 @@ declare class FileBlobStore extends EventEmitter {
|
|
|
1375
1388
|
private readonly _notFound;
|
|
1376
1389
|
private static readonly NOT_FOUND_TTL;
|
|
1377
1390
|
/** Prevents concurrent flush runs. */
|
|
1378
|
-
private
|
|
1391
|
+
private _flushPromise;
|
|
1379
1392
|
private readonly _onlineHandler;
|
|
1380
1393
|
constructor(serverOrigin: string, client?: AbracadabraClient | null);
|
|
1381
1394
|
private getDb;
|
|
@@ -1399,6 +1412,24 @@ declare class FileBlobStore extends EventEmitter {
|
|
|
1399
1412
|
* Use this to re-upload a file after a page reload.
|
|
1400
1413
|
*/
|
|
1401
1414
|
getBlob(docId: string, uploadId: string): Promise<Blob | null>;
|
|
1415
|
+
/** Return metadata for all cached blobs (for storage stats). */
|
|
1416
|
+
getAllCachedEntries(): Promise<Array<{
|
|
1417
|
+
docId: string;
|
|
1418
|
+
uploadId: string;
|
|
1419
|
+
filename: string;
|
|
1420
|
+
mimeType: string;
|
|
1421
|
+
size: number;
|
|
1422
|
+
cachedAt: number;
|
|
1423
|
+
}>>;
|
|
1424
|
+
/** Revoke all object URLs and clear the entire blob cache from IDB. */
|
|
1425
|
+
clearAllBlobs(): Promise<void>;
|
|
1426
|
+
/**
|
|
1427
|
+
* Revoke the in-memory object URL without touching the IDB cache.
|
|
1428
|
+
* The next call to getBlobUrl() will re-create a fresh URL from IDB.
|
|
1429
|
+
* Use this when an <img> @error fires — the blob data is fine, only
|
|
1430
|
+
* the object URL reference is stale.
|
|
1431
|
+
*/
|
|
1432
|
+
invalidateUrl(docId: string, uploadId: string): void;
|
|
1402
1433
|
/** Revoke the object URL and remove the blob from cache. */
|
|
1403
1434
|
evictBlob(docId: string, uploadId: string): Promise<void>;
|
|
1404
1435
|
/**
|
|
@@ -1415,6 +1446,7 @@ declare class FileBlobStore extends EventEmitter {
|
|
|
1415
1446
|
* Entries that fail are marked with status "error" and left in the queue.
|
|
1416
1447
|
*/
|
|
1417
1448
|
flushQueue(): Promise<void>;
|
|
1449
|
+
private _doFlush;
|
|
1418
1450
|
private _updateQueueEntry;
|
|
1419
1451
|
destroy(): void;
|
|
1420
1452
|
}
|
|
@@ -1582,7 +1614,14 @@ declare class BackgroundSyncManager extends EventEmitter {
|
|
|
1582
1614
|
private readonly semaphore;
|
|
1583
1615
|
private readonly syncStates;
|
|
1584
1616
|
private _destroyed;
|
|
1617
|
+
private _initPromise;
|
|
1585
1618
|
constructor(rootProvider: AbracadabraProvider, client: AbracadabraClient, fileBlobStore?: FileBlobStore | null, opts?: BackgroundSyncManagerOptions);
|
|
1619
|
+
/**
|
|
1620
|
+
* Load persisted sync states from IndexedDB into the in-memory map.
|
|
1621
|
+
* Called automatically by syncAll() / syncDoc(); safe to call concurrently.
|
|
1622
|
+
*/
|
|
1623
|
+
init(): Promise<void>;
|
|
1624
|
+
private _loadPersistedStates;
|
|
1586
1625
|
/** Sync all documents in the root tree. */
|
|
1587
1626
|
syncAll(): Promise<void>;
|
|
1588
1627
|
/** Sync a single document by ID. */
|
|
@@ -1709,7 +1748,7 @@ declare class DataChannelRouter extends EventEmitter {
|
|
|
1709
1748
|
* Send data on a named channel, encrypting if E2EE is active.
|
|
1710
1749
|
* Falls back to plaintext if no encryptor is set or for exempt channels.
|
|
1711
1750
|
*/
|
|
1712
|
-
send(name: string, data: Uint8Array): Promise<
|
|
1751
|
+
send(name: string, data: Uint8Array): Promise<boolean>;
|
|
1713
1752
|
private registerChannel;
|
|
1714
1753
|
close(): void;
|
|
1715
1754
|
destroy(): void;
|
|
@@ -1997,7 +2036,9 @@ declare class SignalingSocket extends EventEmitter {
|
|
|
1997
2036
|
isConnected: boolean;
|
|
1998
2037
|
constructor(configuration: SignalingSocketConfiguration);
|
|
1999
2038
|
private getToken;
|
|
2039
|
+
private _connectPromise;
|
|
2000
2040
|
connect(): Promise<void>;
|
|
2041
|
+
private _doConnect;
|
|
2001
2042
|
private createConnection;
|
|
2002
2043
|
private handleMessage;
|
|
2003
2044
|
private sendRaw;
|
|
@@ -2048,6 +2089,7 @@ declare class YjsDataChannel {
|
|
|
2048
2089
|
private readonly document;
|
|
2049
2090
|
private readonly awareness;
|
|
2050
2091
|
private readonly router;
|
|
2092
|
+
isSynced: boolean;
|
|
2051
2093
|
private docUpdateHandler;
|
|
2052
2094
|
private awarenessUpdateHandler;
|
|
2053
2095
|
private channelOpenHandler;
|
package/package.json
CHANGED
|
@@ -29,6 +29,7 @@ import type {
|
|
|
29
29
|
onStatusParameters,
|
|
30
30
|
onSyncedParameters,
|
|
31
31
|
onUnsyncedChangesParameters,
|
|
32
|
+
WebSocketStatus,
|
|
32
33
|
} from "./types.ts";
|
|
33
34
|
|
|
34
35
|
export type AbracadabraBaseProviderConfiguration = Required<
|
|
@@ -137,6 +138,11 @@ export class AbracadabraBaseProvider extends EventEmitter {
|
|
|
137
138
|
|
|
138
139
|
isAuthenticated = false;
|
|
139
140
|
|
|
141
|
+
/** Current WebSocket connection status. */
|
|
142
|
+
get connectionStatus(): WebSocketStatus {
|
|
143
|
+
return this.configuration.websocketProvider.status;
|
|
144
|
+
}
|
|
145
|
+
|
|
140
146
|
authorizedScope: AuthorizedScope | undefined = undefined;
|
|
141
147
|
|
|
142
148
|
// @internal
|
|
@@ -144,7 +150,7 @@ export class AbracadabraBaseProvider extends EventEmitter {
|
|
|
144
150
|
|
|
145
151
|
private _isAttached = false;
|
|
146
152
|
|
|
147
|
-
intervals:
|
|
153
|
+
intervals: { forceSync: ReturnType<typeof setInterval> | null } = {
|
|
148
154
|
forceSync: null,
|
|
149
155
|
};
|
|
150
156
|
|
package/src/AbracadabraClient.ts
CHANGED
|
@@ -78,6 +78,18 @@ export class AbracadabraClient {
|
|
|
78
78
|
return this._token !== null;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/** Check if the current JWT token is present and not expired. */
|
|
82
|
+
isTokenValid(): boolean {
|
|
83
|
+
if (!this._token) return false;
|
|
84
|
+
try {
|
|
85
|
+
const [, payload] = this._token.split(".");
|
|
86
|
+
const { exp } = JSON.parse(atob(payload));
|
|
87
|
+
return typeof exp === "number" && exp * 1000 > Date.now();
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
81
93
|
/** Derives ws:// or wss:// URL from the http(s) base URL. */
|
|
82
94
|
get wsUrl(): string {
|
|
83
95
|
return this.baseUrl
|
|
@@ -202,17 +214,10 @@ export class AbracadabraClient {
|
|
|
202
214
|
|
|
203
215
|
/** Get the caller's key envelope for a document (for decrypting the DocKey). */
|
|
204
216
|
async getMyKeyEnvelope(docId: string): Promise<{ encrypted_key: string; key_epoch: number } | null> {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
);
|
|
210
|
-
} catch (e: unknown) {
|
|
211
|
-
if (typeof e === "object" && e !== null && "status" in e && (e as { status: number }).status === 404) {
|
|
212
|
-
return null;
|
|
213
|
-
}
|
|
214
|
-
throw e;
|
|
215
|
-
}
|
|
217
|
+
return this.requestOrNull<{ encrypted_key: string; key_epoch: number }>(
|
|
218
|
+
"GET",
|
|
219
|
+
`/docs/${encodeURIComponent(docId)}/key-envelope`,
|
|
220
|
+
);
|
|
216
221
|
}
|
|
217
222
|
|
|
218
223
|
/** Upload key envelopes for a document (Owner only). */
|
|
@@ -225,18 +230,11 @@ export class AbracadabraClient {
|
|
|
225
230
|
|
|
226
231
|
/** Get the X25519 public key for a user. */
|
|
227
232
|
async getUserX25519Key(userId: string): Promise<string | null> {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
return res.x25519_key;
|
|
234
|
-
} catch (e: unknown) {
|
|
235
|
-
if (typeof e === "object" && e !== null && "status" in e && (e as { status: number }).status === 404) {
|
|
236
|
-
return null;
|
|
237
|
-
}
|
|
238
|
-
throw e;
|
|
239
|
-
}
|
|
233
|
+
const res = await this.requestOrNull<{ x25519_key: string }>(
|
|
234
|
+
"GET",
|
|
235
|
+
`/users/${encodeURIComponent(userId)}/x25519-key`,
|
|
236
|
+
);
|
|
237
|
+
return res?.x25519_key ?? null;
|
|
240
238
|
}
|
|
241
239
|
|
|
242
240
|
/** List all non-revoked keys for a user (Owner/Admin or self). */
|
|
@@ -365,13 +363,16 @@ export class AbracadabraClient {
|
|
|
365
363
|
"GET",
|
|
366
364
|
`/docs/${encodeURIComponent(docId)}/effective-permissions`,
|
|
367
365
|
);
|
|
368
|
-
} catch {
|
|
369
|
-
// Fallback for older servers that don't support this endpoint
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
366
|
+
} catch (e: unknown) {
|
|
367
|
+
// Fallback for older servers that don't support this endpoint (404 only)
|
|
368
|
+
if (typeof e === "object" && e !== null && "status" in e && (e as { status: number }).status === 404) {
|
|
369
|
+
const perms = await this.listPermissions(docId);
|
|
370
|
+
return {
|
|
371
|
+
permissions: perms.map((p) => ({ ...p, source: "direct" as const })),
|
|
372
|
+
default_role: "viewer",
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
throw e;
|
|
375
376
|
}
|
|
376
377
|
}
|
|
377
378
|
|
|
@@ -508,14 +509,7 @@ export class AbracadabraClient {
|
|
|
508
509
|
|
|
509
510
|
/** Get the hub space, or null if none is configured. */
|
|
510
511
|
async getHubSpace(): Promise<SpaceMeta | null> {
|
|
511
|
-
|
|
512
|
-
return await this.request<SpaceMeta>("GET", "/spaces/hub", { auth: false });
|
|
513
|
-
} catch (e: unknown) {
|
|
514
|
-
if (typeof e === "object" && e !== null && "status" in e && (e as { status: number }).status === 404) {
|
|
515
|
-
return null;
|
|
516
|
-
}
|
|
517
|
-
throw e;
|
|
518
|
-
}
|
|
512
|
+
return this.requestOrNull<SpaceMeta>("GET", "/spaces/hub", { auth: false });
|
|
519
513
|
}
|
|
520
514
|
|
|
521
515
|
/** Create a new space (auth required). */
|
|
@@ -588,7 +582,7 @@ export class AbracadabraClient {
|
|
|
588
582
|
headers["Authorization"] = `Bearer ${this._token}`;
|
|
589
583
|
}
|
|
590
584
|
|
|
591
|
-
const init: RequestInit = { method, headers };
|
|
585
|
+
const init: RequestInit = { method, headers, signal: AbortSignal.timeout(30_000) };
|
|
592
586
|
|
|
593
587
|
if (opts?.body !== undefined) {
|
|
594
588
|
headers["Content-Type"] = "application/json";
|
|
@@ -598,7 +592,7 @@ export class AbracadabraClient {
|
|
|
598
592
|
const res = await this._fetch(`${this.baseUrl}${path}`, init);
|
|
599
593
|
|
|
600
594
|
if (!res.ok) {
|
|
601
|
-
throw await this.toError(res);
|
|
595
|
+
throw await this.toError(res, method, path);
|
|
602
596
|
}
|
|
603
597
|
|
|
604
598
|
// 204 No Content
|
|
@@ -609,7 +603,22 @@ export class AbracadabraClient {
|
|
|
609
603
|
return res.json() as Promise<T>;
|
|
610
604
|
}
|
|
611
605
|
|
|
612
|
-
private async
|
|
606
|
+
private async requestOrNull<T>(
|
|
607
|
+
method: string,
|
|
608
|
+
path: string,
|
|
609
|
+
opts?: { body?: unknown; auth?: boolean },
|
|
610
|
+
): Promise<T | null> {
|
|
611
|
+
try {
|
|
612
|
+
return await this.request<T>(method, path, opts);
|
|
613
|
+
} catch (e: unknown) {
|
|
614
|
+
if (typeof e === "object" && e !== null && "status" in e && (e as { status: number }).status === 404) {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
throw e;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private async toError(res: Response, method?: string, path?: string): Promise<Error> {
|
|
613
622
|
let message: string;
|
|
614
623
|
try {
|
|
615
624
|
const body = await res.json() as { error?: string };
|
|
@@ -617,7 +626,8 @@ export class AbracadabraClient {
|
|
|
617
626
|
} catch {
|
|
618
627
|
message = res.statusText;
|
|
619
628
|
}
|
|
620
|
-
const
|
|
629
|
+
const prefix = method && path ? `${method} ${path}: ` : "";
|
|
630
|
+
const err = new Error(`${prefix}${message} (${res.status})`);
|
|
621
631
|
(err as any).status = res.status;
|
|
622
632
|
return err;
|
|
623
633
|
}
|
|
@@ -155,7 +155,12 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
|
|
|
155
155
|
this.restorePermissionSnapshot();
|
|
156
156
|
|
|
157
157
|
// Pre-populate the Y.Doc from the local snapshot so the UI works offline.
|
|
158
|
-
this.ready =
|
|
158
|
+
this.ready = Promise.race([
|
|
159
|
+
this._initFromOfflineStore(),
|
|
160
|
+
new Promise<void>((resolve) => setTimeout(resolve, 5_000)),
|
|
161
|
+
]).catch((err) => {
|
|
162
|
+
this.emit("error", { message: `Offline store init failed: ${err?.message ?? err}` });
|
|
163
|
+
});
|
|
159
164
|
}
|
|
160
165
|
|
|
161
166
|
// ── Server origin derivation ──────────────────────────────────────────────
|
|
@@ -337,7 +342,9 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
|
|
|
337
342
|
|
|
338
343
|
// Intercept auth_challenge before subdoc handling.
|
|
339
344
|
if (msg.type === "auth_challenge" && msg.challenge && msg.expiresAt) {
|
|
340
|
-
this.handleAuthChallenge(msg.challenge, msg.expiresAt).catch(() =>
|
|
345
|
+
this.handleAuthChallenge(msg.challenge, msg.expiresAt).catch((err) => {
|
|
346
|
+
this.emit("authenticationFailed", { reason: `Auth challenge error: ${err?.message ?? err}` });
|
|
347
|
+
});
|
|
341
348
|
return;
|
|
342
349
|
}
|
|
343
350
|
|
|
@@ -407,6 +414,16 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
|
|
|
407
414
|
}
|
|
408
415
|
}
|
|
409
416
|
|
|
417
|
+
/** Get a loaded child provider by ID, or null if not yet loaded. */
|
|
418
|
+
getChild(childId: string): AbracadabraProvider | null {
|
|
419
|
+
return this.childProviders.get(childId) ?? null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Check if a child provider is already loaded. */
|
|
423
|
+
hasChild(childId: string): boolean {
|
|
424
|
+
return this.childProviders.has(childId);
|
|
425
|
+
}
|
|
426
|
+
|
|
410
427
|
/**
|
|
411
428
|
* Create (or return cached) a child AbracadabraProvider for a given
|
|
412
429
|
* child document id. Each child opens its own WebSocket connection because
|
package/src/AbracadabraWS.ts
CHANGED
|
@@ -409,7 +409,11 @@ export class AbracadabraWS extends EventEmitter {
|
|
|
409
409
|
const message = new IncomingMessage(event.data);
|
|
410
410
|
const documentName = message.peekVarString();
|
|
411
411
|
|
|
412
|
-
|
|
412
|
+
try {
|
|
413
|
+
this.configuration.providerMap.get(documentName)?.onMessage(event);
|
|
414
|
+
} catch (err) {
|
|
415
|
+
console.error(`[AbracadabraWS] Provider onMessage error for "${documentName}":`, err);
|
|
416
|
+
}
|
|
413
417
|
}
|
|
414
418
|
|
|
415
419
|
resolveConnectionAttempt() {
|
|
@@ -79,6 +79,7 @@ export class BackgroundSyncManager extends EventEmitter {
|
|
|
79
79
|
private readonly syncStates = new Map<string, DocSyncState>();
|
|
80
80
|
|
|
81
81
|
private _destroyed = false;
|
|
82
|
+
private _initPromise: Promise<void> | null = null;
|
|
82
83
|
|
|
83
84
|
constructor(
|
|
84
85
|
rootProvider: AbracadabraProvider,
|
|
@@ -108,15 +109,45 @@ export class BackgroundSyncManager extends EventEmitter {
|
|
|
108
109
|
|
|
109
110
|
// ── Public API ────────────────────────────────────────────────────────────
|
|
110
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Load persisted sync states from IndexedDB into the in-memory map.
|
|
114
|
+
* Called automatically by syncAll() / syncDoc(); safe to call concurrently.
|
|
115
|
+
*/
|
|
116
|
+
async init(): Promise<void> {
|
|
117
|
+
if (!this._initPromise) {
|
|
118
|
+
this._initPromise = this._loadPersistedStates();
|
|
119
|
+
}
|
|
120
|
+
return this._initPromise;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async _loadPersistedStates(): Promise<void> {
|
|
124
|
+
try {
|
|
125
|
+
const states = await this.persistence.getAllStates();
|
|
126
|
+
for (const state of states) {
|
|
127
|
+
this.syncStates.set(state.docId, state);
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
// IDB unavailable — proceed with empty map (full sync fallback)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
111
134
|
/** Sync all documents in the root tree. */
|
|
112
135
|
async syncAll(): Promise<void> {
|
|
113
136
|
if (this._destroyed) return;
|
|
114
137
|
|
|
138
|
+
await this.init();
|
|
139
|
+
|
|
115
140
|
const treeMap = this.rootProvider.document.getMap("doc-tree") as Y.Map<any>;
|
|
116
141
|
const entries = Array.from(treeMap.entries()) as Array<[string, any]>;
|
|
117
142
|
|
|
118
143
|
if (entries.length === 0) return;
|
|
119
144
|
|
|
145
|
+
// Build updatedAt lookup for skip-unchanged logic
|
|
146
|
+
const updatedAtMap = new Map<string, number>();
|
|
147
|
+
for (const [docId, v] of entries) {
|
|
148
|
+
updatedAtMap.set(docId, v?.updatedAt ?? v?.createdAt ?? 0);
|
|
149
|
+
}
|
|
150
|
+
|
|
120
151
|
// Pre-cache cover images immediately (fire-and-forget)
|
|
121
152
|
this._prefetchCovers(entries).catch(() => null);
|
|
122
153
|
|
|
@@ -124,11 +155,16 @@ export class BackgroundSyncManager extends EventEmitter {
|
|
|
124
155
|
const queue = this._buildQueue(entries);
|
|
125
156
|
|
|
126
157
|
// Sync all docs respecting concurrency limit
|
|
127
|
-
await Promise.all(
|
|
158
|
+
await Promise.all(
|
|
159
|
+
queue.map((docId) =>
|
|
160
|
+
this._syncWithSemaphore(docId, updatedAtMap.get(docId) ?? 0),
|
|
161
|
+
),
|
|
162
|
+
);
|
|
128
163
|
}
|
|
129
164
|
|
|
130
165
|
/** Sync a single document by ID. */
|
|
131
166
|
async syncDoc(docId: string): Promise<DocSyncState> {
|
|
167
|
+
await this.init();
|
|
132
168
|
const state = await this._doSyncDoc(docId);
|
|
133
169
|
this.syncStates.set(docId, state);
|
|
134
170
|
await this.persistence.setState(state).catch(() => null);
|
|
@@ -194,8 +230,24 @@ export class BackgroundSyncManager extends EventEmitter {
|
|
|
194
230
|
return items.map((i) => i.docId);
|
|
195
231
|
}
|
|
196
232
|
|
|
197
|
-
private async _syncWithSemaphore(
|
|
233
|
+
private async _syncWithSemaphore(
|
|
234
|
+
docId: string,
|
|
235
|
+
updatedAt: number,
|
|
236
|
+
): Promise<void> {
|
|
198
237
|
if (this._destroyed) return;
|
|
238
|
+
|
|
239
|
+
// Skip if already synced and doc hasn't changed since last sync
|
|
240
|
+
const existing = this.syncStates.get(docId);
|
|
241
|
+
if (
|
|
242
|
+
existing &&
|
|
243
|
+
existing.status === "synced" &&
|
|
244
|
+
existing.lastSynced !== null &&
|
|
245
|
+
existing.lastSynced >= updatedAt
|
|
246
|
+
) {
|
|
247
|
+
this.emit("stateChanged", { docId, state: existing });
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
199
251
|
await this.semaphore.acquire();
|
|
200
252
|
try {
|
|
201
253
|
const state = await this._doSyncDoc(docId);
|
|
@@ -218,9 +270,9 @@ export class BackgroundSyncManager extends EventEmitter {
|
|
|
218
270
|
this.syncStates.set(docId, syncing);
|
|
219
271
|
this.emit("stateChanged", { docId, state: syncing });
|
|
220
272
|
|
|
273
|
+
let isE2E = false;
|
|
221
274
|
try {
|
|
222
275
|
// Check encryption type
|
|
223
|
-
let isE2E = false;
|
|
224
276
|
try {
|
|
225
277
|
const enc = await this.client.getDocEncryption(docId);
|
|
226
278
|
isE2E = enc.mode === "e2e";
|
|
@@ -240,7 +292,7 @@ export class BackgroundSyncManager extends EventEmitter {
|
|
|
240
292
|
status: "error",
|
|
241
293
|
lastSynced: this.syncStates.get(docId)?.lastSynced ?? null,
|
|
242
294
|
error,
|
|
243
|
-
isE2E
|
|
295
|
+
isE2E,
|
|
244
296
|
};
|
|
245
297
|
}
|
|
246
298
|
}
|
package/src/DocKeyManager.ts
CHANGED
|
@@ -18,7 +18,8 @@ function fromBase64(b64: string): Uint8Array {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export class DocKeyManager {
|
|
21
|
-
private cache = new Map<string, { key: CryptoKey; epoch: number }>();
|
|
21
|
+
private cache = new Map<string, { key: CryptoKey; epoch: number; fetchedAt: number }>();
|
|
22
|
+
private static readonly CACHE_TTL = 10 * 60 * 1000; // 10 minutes
|
|
22
23
|
|
|
23
24
|
/** Generate a new random AES-256-GCM document key. */
|
|
24
25
|
static async generateDocKey(): Promise<CryptoKey> {
|
|
@@ -39,7 +40,9 @@ export class DocKeyManager {
|
|
|
39
40
|
keystore: CryptoIdentityKeystore,
|
|
40
41
|
): Promise<CryptoKey | null> {
|
|
41
42
|
const cached = this.cache.get(docId);
|
|
42
|
-
if (cached)
|
|
43
|
+
if (cached && Date.now() - cached.fetchedAt < DocKeyManager.CACHE_TTL) {
|
|
44
|
+
return cached.key;
|
|
45
|
+
}
|
|
43
46
|
|
|
44
47
|
const envelope = await client.getMyKeyEnvelope(docId);
|
|
45
48
|
if (!envelope) return null;
|
|
@@ -48,7 +51,7 @@ export class DocKeyManager {
|
|
|
48
51
|
try {
|
|
49
52
|
const wrapped = fromBase64(envelope.encrypted_key);
|
|
50
53
|
const docKey = await this._unwrapKey(wrapped, x25519PrivKey, docId);
|
|
51
|
-
this.cache.set(docId, { key: docKey, epoch: envelope.key_epoch });
|
|
54
|
+
this.cache.set(docId, { key: docKey, epoch: envelope.key_epoch, fetchedAt: Date.now() });
|
|
52
55
|
return docKey;
|
|
53
56
|
} finally {
|
|
54
57
|
x25519PrivKey.fill(0);
|
package/src/EventEmitter.ts
CHANGED
|
@@ -13,11 +13,26 @@ export default class EventEmitter {
|
|
|
13
13
|
return this;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
|
17
|
+
public once(event: string, fn: Function): this {
|
|
18
|
+
const wrapper = (...args: any[]) => {
|
|
19
|
+
this.off(event, wrapper);
|
|
20
|
+
fn.apply(this, args);
|
|
21
|
+
};
|
|
22
|
+
return this.on(event, wrapper);
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
protected emit(event: string, ...args: any): this {
|
|
17
26
|
const callbacks = this.callbacks[event];
|
|
18
27
|
|
|
19
28
|
if (callbacks) {
|
|
20
|
-
|
|
29
|
+
for (const callback of callbacks) {
|
|
30
|
+
try {
|
|
31
|
+
callback.apply(this, args);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.error(`[EventEmitter] Error in "${event}" listener:`, err);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
21
36
|
}
|
|
22
37
|
|
|
23
38
|
return this;
|
package/src/FileBlobStore.ts
CHANGED
|
@@ -75,7 +75,7 @@ export class FileBlobStore extends EventEmitter {
|
|
|
75
75
|
private static readonly NOT_FOUND_TTL = 5 * 60 * 1000;
|
|
76
76
|
|
|
77
77
|
/** Prevents concurrent flush runs. */
|
|
78
|
-
private
|
|
78
|
+
private _flushPromise: Promise<void> | null = null;
|
|
79
79
|
|
|
80
80
|
private readonly _onlineHandler: () => void;
|
|
81
81
|
|
|
@@ -231,6 +231,62 @@ export class FileBlobStore extends EventEmitter {
|
|
|
231
231
|
return entry?.blob ?? null;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
/** Return metadata for all cached blobs (for storage stats). */
|
|
235
|
+
async getAllCachedEntries(): Promise<Array<{
|
|
236
|
+
docId: string; uploadId: string; filename: string;
|
|
237
|
+
mimeType: string; size: number; cachedAt: number;
|
|
238
|
+
}>> {
|
|
239
|
+
const db = await this.getDb();
|
|
240
|
+
if (!db) return [];
|
|
241
|
+
return new Promise((resolve, reject) => {
|
|
242
|
+
const tx = db.transaction("blobs", "readonly");
|
|
243
|
+
const store = tx.objectStore("blobs");
|
|
244
|
+
const keysReq = store.getAllKeys();
|
|
245
|
+
const valuesReq = store.getAll();
|
|
246
|
+
tx.oncomplete = () => {
|
|
247
|
+
const keys = keysReq.result as string[];
|
|
248
|
+
const values = valuesReq.result as BlobCacheEntry[];
|
|
249
|
+
resolve(keys.map((key, i) => {
|
|
250
|
+
const slashIdx = key.indexOf("/");
|
|
251
|
+
const docId = key.slice(0, slashIdx);
|
|
252
|
+
const uploadId = key.slice(slashIdx + 1);
|
|
253
|
+
const e = values[i]!;
|
|
254
|
+
return { docId, uploadId, filename: e.filename, mimeType: e.mime_type, size: e.blob.size, cachedAt: e.cachedAt };
|
|
255
|
+
}));
|
|
256
|
+
};
|
|
257
|
+
tx.onerror = () => reject(tx.error);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Revoke all object URLs and clear the entire blob cache from IDB. */
|
|
262
|
+
async clearAllBlobs(): Promise<void> {
|
|
263
|
+
for (const url of this.objectUrls.values()) URL.revokeObjectURL(url);
|
|
264
|
+
this.objectUrls.clear();
|
|
265
|
+
const db = await this.getDb();
|
|
266
|
+
if (!db) return;
|
|
267
|
+
return new Promise((resolve, reject) => {
|
|
268
|
+
const tx = db.transaction("blobs", "readwrite");
|
|
269
|
+
const req = tx.objectStore("blobs").clear();
|
|
270
|
+
req.onsuccess = () => resolve();
|
|
271
|
+
req.onerror = () => reject(req.error);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Revoke the in-memory object URL without touching the IDB cache.
|
|
277
|
+
* The next call to getBlobUrl() will re-create a fresh URL from IDB.
|
|
278
|
+
* Use this when an <img> @error fires — the blob data is fine, only
|
|
279
|
+
* the object URL reference is stale.
|
|
280
|
+
*/
|
|
281
|
+
invalidateUrl(docId: string, uploadId: string): void {
|
|
282
|
+
const key = this.blobKey(docId, uploadId);
|
|
283
|
+
const url = this.objectUrls.get(key);
|
|
284
|
+
if (url) {
|
|
285
|
+
URL.revokeObjectURL(url);
|
|
286
|
+
this.objectUrls.delete(key);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
234
290
|
/** Revoke the object URL and remove the blob from cache. */
|
|
235
291
|
async evictBlob(docId: string, uploadId: string): Promise<void> {
|
|
236
292
|
const key = this.blobKey(docId, uploadId);
|
|
@@ -303,29 +359,33 @@ export class FileBlobStore extends EventEmitter {
|
|
|
303
359
|
* Entries that fail are marked with status "error" and left in the queue.
|
|
304
360
|
*/
|
|
305
361
|
async flushQueue(): Promise<void> {
|
|
306
|
-
if (this.
|
|
307
|
-
this.
|
|
308
|
-
|
|
362
|
+
if (this._flushPromise || !this.client) return;
|
|
363
|
+
this._flushPromise = this._doFlush();
|
|
309
364
|
try {
|
|
310
|
-
|
|
311
|
-
const pending = all.filter((e) => e.status === "pending");
|
|
312
|
-
|
|
313
|
-
for (const entry of pending) {
|
|
314
|
-
await this._updateQueueEntry(entry.id, { status: "uploading" });
|
|
315
|
-
this.emit("upload:started", { ...entry, status: "uploading" });
|
|
316
|
-
|
|
317
|
-
try {
|
|
318
|
-
await this.client.upload(entry.docId, entry.file, entry.filename);
|
|
319
|
-
await this._updateQueueEntry(entry.id, { status: "done" });
|
|
320
|
-
this.emit("upload:done", { ...entry, status: "done" });
|
|
321
|
-
} catch (err) {
|
|
322
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
323
|
-
await this._updateQueueEntry(entry.id, { status: "error", error: message });
|
|
324
|
-
this.emit("upload:error", { ...entry, status: "error", error: message });
|
|
325
|
-
}
|
|
326
|
-
}
|
|
365
|
+
await this._flushPromise;
|
|
327
366
|
} finally {
|
|
328
|
-
this.
|
|
367
|
+
this._flushPromise = null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private async _doFlush(): Promise<void> {
|
|
372
|
+
if (!this.client) return;
|
|
373
|
+
const all = await this.getQueue();
|
|
374
|
+
const pending = all.filter((e) => e.status === "pending");
|
|
375
|
+
|
|
376
|
+
for (const entry of pending) {
|
|
377
|
+
await this._updateQueueEntry(entry.id, { status: "uploading" });
|
|
378
|
+
this.emit("upload:started", { ...entry, status: "uploading" });
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
await this.client.upload(entry.docId, entry.file, entry.filename);
|
|
382
|
+
await this._updateQueueEntry(entry.id, { status: "done" });
|
|
383
|
+
this.emit("upload:done", { ...entry, status: "done" });
|
|
384
|
+
} catch (err) {
|
|
385
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
386
|
+
await this._updateQueueEntry(entry.id, { status: "error", error: message });
|
|
387
|
+
this.emit("upload:error", { ...entry, status: "error", error: message });
|
|
388
|
+
}
|
|
329
389
|
}
|
|
330
390
|
}
|
|
331
391
|
|