@abraca/dabra 1.0.14 → 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 +176 -67
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +176 -67
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +34 -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 +41 -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;
|
|
@@ -1410,6 +1423,13 @@ declare class FileBlobStore extends EventEmitter {
|
|
|
1410
1423
|
}>>;
|
|
1411
1424
|
/** Revoke all object URLs and clear the entire blob cache from IDB. */
|
|
1412
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;
|
|
1413
1433
|
/** Revoke the object URL and remove the blob from cache. */
|
|
1414
1434
|
evictBlob(docId: string, uploadId: string): Promise<void>;
|
|
1415
1435
|
/**
|
|
@@ -1426,6 +1446,7 @@ declare class FileBlobStore extends EventEmitter {
|
|
|
1426
1446
|
* Entries that fail are marked with status "error" and left in the queue.
|
|
1427
1447
|
*/
|
|
1428
1448
|
flushQueue(): Promise<void>;
|
|
1449
|
+
private _doFlush;
|
|
1429
1450
|
private _updateQueueEntry;
|
|
1430
1451
|
destroy(): void;
|
|
1431
1452
|
}
|
|
@@ -1593,7 +1614,14 @@ declare class BackgroundSyncManager extends EventEmitter {
|
|
|
1593
1614
|
private readonly semaphore;
|
|
1594
1615
|
private readonly syncStates;
|
|
1595
1616
|
private _destroyed;
|
|
1617
|
+
private _initPromise;
|
|
1596
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;
|
|
1597
1625
|
/** Sync all documents in the root tree. */
|
|
1598
1626
|
syncAll(): Promise<void>;
|
|
1599
1627
|
/** Sync a single document by ID. */
|
|
@@ -1720,7 +1748,7 @@ declare class DataChannelRouter extends EventEmitter {
|
|
|
1720
1748
|
* Send data on a named channel, encrypting if E2EE is active.
|
|
1721
1749
|
* Falls back to plaintext if no encryptor is set or for exempt channels.
|
|
1722
1750
|
*/
|
|
1723
|
-
send(name: string, data: Uint8Array): Promise<
|
|
1751
|
+
send(name: string, data: Uint8Array): Promise<boolean>;
|
|
1724
1752
|
private registerChannel;
|
|
1725
1753
|
close(): void;
|
|
1726
1754
|
destroy(): void;
|
|
@@ -2008,7 +2036,9 @@ declare class SignalingSocket extends EventEmitter {
|
|
|
2008
2036
|
isConnected: boolean;
|
|
2009
2037
|
constructor(configuration: SignalingSocketConfiguration);
|
|
2010
2038
|
private getToken;
|
|
2039
|
+
private _connectPromise;
|
|
2011
2040
|
connect(): Promise<void>;
|
|
2041
|
+
private _doConnect;
|
|
2012
2042
|
private createConnection;
|
|
2013
2043
|
private handleMessage;
|
|
2014
2044
|
private sendRaw;
|
|
@@ -2059,6 +2089,7 @@ declare class YjsDataChannel {
|
|
|
2059
2089
|
private readonly document;
|
|
2060
2090
|
private readonly awareness;
|
|
2061
2091
|
private readonly router;
|
|
2092
|
+
isSynced: boolean;
|
|
2062
2093
|
private docUpdateHandler;
|
|
2063
2094
|
private awarenessUpdateHandler;
|
|
2064
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
|
|
|
@@ -272,6 +272,21 @@ export class FileBlobStore extends EventEmitter {
|
|
|
272
272
|
});
|
|
273
273
|
}
|
|
274
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
|
+
|
|
275
290
|
/** Revoke the object URL and remove the blob from cache. */
|
|
276
291
|
async evictBlob(docId: string, uploadId: string): Promise<void> {
|
|
277
292
|
const key = this.blobKey(docId, uploadId);
|
|
@@ -344,29 +359,33 @@ export class FileBlobStore extends EventEmitter {
|
|
|
344
359
|
* Entries that fail are marked with status "error" and left in the queue.
|
|
345
360
|
*/
|
|
346
361
|
async flushQueue(): Promise<void> {
|
|
347
|
-
if (this.
|
|
348
|
-
this.
|
|
349
|
-
|
|
362
|
+
if (this._flushPromise || !this.client) return;
|
|
363
|
+
this._flushPromise = this._doFlush();
|
|
350
364
|
try {
|
|
351
|
-
|
|
352
|
-
const pending = all.filter((e) => e.status === "pending");
|
|
353
|
-
|
|
354
|
-
for (const entry of pending) {
|
|
355
|
-
await this._updateQueueEntry(entry.id, { status: "uploading" });
|
|
356
|
-
this.emit("upload:started", { ...entry, status: "uploading" });
|
|
357
|
-
|
|
358
|
-
try {
|
|
359
|
-
await this.client.upload(entry.docId, entry.file, entry.filename);
|
|
360
|
-
await this._updateQueueEntry(entry.id, { status: "done" });
|
|
361
|
-
this.emit("upload:done", { ...entry, status: "done" });
|
|
362
|
-
} catch (err) {
|
|
363
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
364
|
-
await this._updateQueueEntry(entry.id, { status: "error", error: message });
|
|
365
|
-
this.emit("upload:error", { ...entry, status: "error", error: message });
|
|
366
|
-
}
|
|
367
|
-
}
|
|
365
|
+
await this._flushPromise;
|
|
368
366
|
} finally {
|
|
369
|
-
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
|
+
}
|
|
370
389
|
}
|
|
371
390
|
}
|
|
372
391
|
|
package/src/SearchIndex.ts
CHANGED
|
@@ -200,6 +200,8 @@ export class SearchIndex {
|
|
|
200
200
|
const queryTrigrams = [...extractTrigrams(query)];
|
|
201
201
|
if (queryTrigrams.length === 0) return [];
|
|
202
202
|
|
|
203
|
+
const maxScoreEntries = limit * 10;
|
|
204
|
+
|
|
203
205
|
return new Promise<SearchResult[]>((resolve, reject) => {
|
|
204
206
|
const tx = db.transaction("postings", "readonly");
|
|
205
207
|
const postings = tx.objectStore("postings");
|
|
@@ -212,6 +214,7 @@ export class SearchIndex {
|
|
|
212
214
|
const docIds: string[] = req.result ?? [];
|
|
213
215
|
for (const docId of docIds) {
|
|
214
216
|
scores.set(docId, (scores.get(docId) ?? 0) + 1);
|
|
217
|
+
if (scores.size >= maxScoreEntries) break;
|
|
215
218
|
}
|
|
216
219
|
remaining--;
|
|
217
220
|
if (remaining === 0) {
|
|
@@ -94,9 +94,9 @@ export class DataChannelRouter extends EventEmitter {
|
|
|
94
94
|
* Send data on a named channel, encrypting if E2EE is active.
|
|
95
95
|
* Falls back to plaintext if no encryptor is set or for exempt channels.
|
|
96
96
|
*/
|
|
97
|
-
async send(name: string, data: Uint8Array): Promise<
|
|
97
|
+
async send(name: string, data: Uint8Array): Promise<boolean> {
|
|
98
98
|
const channel = this.channels.get(name);
|
|
99
|
-
if (!channel || channel.readyState !== "open") return;
|
|
99
|
+
if (!channel || channel.readyState !== "open") return false;
|
|
100
100
|
|
|
101
101
|
if (this.encryptor?.isEstablished && !this.plaintextChannels.has(name)) {
|
|
102
102
|
const encrypted = await this.encryptor.encrypt(data);
|
|
@@ -104,6 +104,7 @@ export class DataChannelRouter extends EventEmitter {
|
|
|
104
104
|
} else {
|
|
105
105
|
channel.send(data);
|
|
106
106
|
}
|
|
107
|
+
return true;
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
private registerChannel(channel: RTCDataChannel): void {
|