@abraca/dabra 0.1.7 → 0.3.0
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 +109 -23
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +109 -23
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +67 -7
- package/package.json +1 -1
- package/src/AbracadabraProvider.ts +118 -26
- package/src/HocuspocusProvider.ts +9 -0
- package/src/HocuspocusProviderWebsocket.ts +9 -1
- package/src/OfflineStore.ts +52 -8
|
@@ -1381,10 +1381,15 @@ var HocuspocusProviderWebsocket = class extends EventEmitter {
|
|
|
1381
1381
|
if (this.connectionAttempt) this.rejectConnectionAttempt();
|
|
1382
1382
|
this.status = WebSocketStatus.Disconnected;
|
|
1383
1383
|
this.emit("status", { status: WebSocketStatus.Disconnected });
|
|
1384
|
+
const isRateLimited = event?.code === 4429;
|
|
1384
1385
|
this.emit("disconnect", { event });
|
|
1385
|
-
if (
|
|
1386
|
-
|
|
1387
|
-
|
|
1386
|
+
if (isRateLimited) this.emit("rateLimited");
|
|
1387
|
+
if (!this.cancelWebsocketRetry && this.shouldConnect) {
|
|
1388
|
+
const delay = isRateLimited ? 6e4 : this.configuration.delay;
|
|
1389
|
+
setTimeout(() => {
|
|
1390
|
+
this.connect();
|
|
1391
|
+
}, delay);
|
|
1392
|
+
}
|
|
1388
1393
|
}
|
|
1389
1394
|
destroy() {
|
|
1390
1395
|
this.emit("destroy");
|
|
@@ -1707,6 +1712,7 @@ var HocuspocusProvider = class extends EventEmitter {
|
|
|
1707
1712
|
forceSyncInterval: false,
|
|
1708
1713
|
onAuthenticated: () => null,
|
|
1709
1714
|
onAuthenticationFailed: () => null,
|
|
1715
|
+
onRateLimited: () => null,
|
|
1710
1716
|
onOpen: () => null,
|
|
1711
1717
|
onConnect: () => null,
|
|
1712
1718
|
onMessage: () => null,
|
|
@@ -1738,6 +1744,7 @@ var HocuspocusProvider = class extends EventEmitter {
|
|
|
1738
1744
|
this.forwardClose = (e) => this.emit("close", e);
|
|
1739
1745
|
this.forwardDisconnect = (e) => this.emit("disconnect", e);
|
|
1740
1746
|
this.forwardDestroy = () => this.emit("destroy");
|
|
1747
|
+
this.forwardRateLimited = () => this.emit("rateLimited");
|
|
1741
1748
|
this.setConfiguration(configuration);
|
|
1742
1749
|
this.configuration.document = configuration.document ? configuration.document : new yjs.Doc();
|
|
1743
1750
|
this.configuration.awareness = configuration.awareness !== void 0 ? configuration.awareness : new Awareness(this.document);
|
|
@@ -1752,6 +1759,7 @@ var HocuspocusProvider = class extends EventEmitter {
|
|
|
1752
1759
|
this.on("unsyncedChanges", this.configuration.onUnsyncedChanges);
|
|
1753
1760
|
this.on("authenticated", this.configuration.onAuthenticated);
|
|
1754
1761
|
this.on("authenticationFailed", this.configuration.onAuthenticationFailed);
|
|
1762
|
+
this.on("rateLimited", this.configuration.onRateLimited);
|
|
1755
1763
|
this.awareness?.on("update", () => {
|
|
1756
1764
|
this.emit("awarenessUpdate", { states: (0, _abraca_dabra_common.awarenessStatesToArray)(this.awareness.getStates()) });
|
|
1757
1765
|
});
|
|
@@ -1944,6 +1952,7 @@ var HocuspocusProvider = class extends EventEmitter {
|
|
|
1944
1952
|
this.configuration.websocketProvider.off("disconnect", this.forwardDisconnect);
|
|
1945
1953
|
this.configuration.websocketProvider.off("destroy", this.configuration.onDestroy);
|
|
1946
1954
|
this.configuration.websocketProvider.off("destroy", this.forwardDestroy);
|
|
1955
|
+
this.configuration.websocketProvider.off("rateLimited", this.forwardRateLimited);
|
|
1947
1956
|
this.configuration.websocketProvider.detach(this);
|
|
1948
1957
|
this._isAttached = false;
|
|
1949
1958
|
}
|
|
@@ -1961,6 +1970,7 @@ var HocuspocusProvider = class extends EventEmitter {
|
|
|
1961
1970
|
this.configuration.websocketProvider.on("disconnect", this.forwardDisconnect);
|
|
1962
1971
|
this.configuration.websocketProvider.on("destroy", this.configuration.onDestroy);
|
|
1963
1972
|
this.configuration.websocketProvider.on("destroy", this.forwardDestroy);
|
|
1973
|
+
this.configuration.websocketProvider.on("rateLimited", this.forwardRateLimited);
|
|
1964
1974
|
this.configuration.websocketProvider.attach(this);
|
|
1965
1975
|
this._isAttached = true;
|
|
1966
1976
|
}
|
|
@@ -1981,18 +1991,19 @@ var HocuspocusProvider = class extends EventEmitter {
|
|
|
1981
1991
|
|
|
1982
1992
|
//#endregion
|
|
1983
1993
|
//#region packages/provider/src/OfflineStore.ts
|
|
1984
|
-
const DB_VERSION =
|
|
1994
|
+
const DB_VERSION = 2;
|
|
1985
1995
|
function idbAvailable() {
|
|
1986
1996
|
return typeof globalThis !== "undefined" && "indexedDB" in globalThis;
|
|
1987
1997
|
}
|
|
1988
|
-
function openDb$1(
|
|
1998
|
+
function openDb$1(storeKey) {
|
|
1989
1999
|
return new Promise((resolve, reject) => {
|
|
1990
|
-
const req = globalThis.indexedDB.open(`abracadabra:${
|
|
2000
|
+
const req = globalThis.indexedDB.open(`abracadabra:${storeKey}`, DB_VERSION);
|
|
1991
2001
|
req.onupgradeneeded = (event) => {
|
|
1992
2002
|
const db = event.target.result;
|
|
1993
2003
|
if (!db.objectStoreNames.contains("updates")) db.createObjectStore("updates", { autoIncrement: true });
|
|
1994
2004
|
if (!db.objectStoreNames.contains("meta")) db.createObjectStore("meta");
|
|
1995
2005
|
if (!db.objectStoreNames.contains("subdoc_queue")) db.createObjectStore("subdoc_queue", { keyPath: "childId" });
|
|
2006
|
+
if (!db.objectStoreNames.contains("doc_state")) db.createObjectStore("doc_state");
|
|
1996
2007
|
};
|
|
1997
2008
|
req.onsuccess = () => resolve(req.result);
|
|
1998
2009
|
req.onerror = () => reject(req.error);
|
|
@@ -2005,13 +2016,19 @@ function txPromise(store, request) {
|
|
|
2005
2016
|
});
|
|
2006
2017
|
}
|
|
2007
2018
|
var OfflineStore = class {
|
|
2008
|
-
|
|
2019
|
+
/**
|
|
2020
|
+
* @param docId The document UUID.
|
|
2021
|
+
* @param serverOrigin Hostname of the server (e.g. "abra.cou.sh").
|
|
2022
|
+
* When provided the IndexedDB database is namespaced
|
|
2023
|
+
* per-server, preventing cross-server data contamination.
|
|
2024
|
+
*/
|
|
2025
|
+
constructor(docId, serverOrigin) {
|
|
2009
2026
|
this.db = null;
|
|
2010
|
-
this.
|
|
2027
|
+
this.storeKey = serverOrigin ? `${serverOrigin}/${docId}` : docId;
|
|
2011
2028
|
}
|
|
2012
2029
|
async getDb() {
|
|
2013
2030
|
if (!idbAvailable()) return null;
|
|
2014
|
-
if (!this.db) this.db = await openDb$1(this.
|
|
2031
|
+
if (!this.db) this.db = await openDb$1(this.storeKey).catch(() => null);
|
|
2015
2032
|
return this.db;
|
|
2016
2033
|
}
|
|
2017
2034
|
async persistUpdate(update) {
|
|
@@ -2035,6 +2052,25 @@ var OfflineStore = class {
|
|
|
2035
2052
|
const tx = db.transaction("updates", "readwrite");
|
|
2036
2053
|
await txPromise(tx.objectStore("updates"), tx.objectStore("updates").clear());
|
|
2037
2054
|
}
|
|
2055
|
+
/**
|
|
2056
|
+
* Persist a full Y.js state snapshot (Y.encodeStateAsUpdate output).
|
|
2057
|
+
* Replaces any previously stored snapshot.
|
|
2058
|
+
*/
|
|
2059
|
+
async saveDocSnapshot(snapshot) {
|
|
2060
|
+
const db = await this.getDb();
|
|
2061
|
+
if (!db) return;
|
|
2062
|
+
const tx = db.transaction("doc_state", "readwrite");
|
|
2063
|
+
await txPromise(tx.objectStore("doc_state"), tx.objectStore("doc_state").put(snapshot, "snapshot"));
|
|
2064
|
+
}
|
|
2065
|
+
/**
|
|
2066
|
+
* Retrieve the stored full document snapshot, or null if none exists.
|
|
2067
|
+
*/
|
|
2068
|
+
async getDocSnapshot() {
|
|
2069
|
+
const db = await this.getDb();
|
|
2070
|
+
if (!db) return null;
|
|
2071
|
+
const tx = db.transaction("doc_state", "readonly");
|
|
2072
|
+
return await txPromise(tx.objectStore("doc_state"), tx.objectStore("doc_state").get("snapshot")) ?? null;
|
|
2073
|
+
}
|
|
2038
2074
|
async getStateVector() {
|
|
2039
2075
|
const db = await this.getDb();
|
|
2040
2076
|
if (!db) return null;
|
|
@@ -2128,10 +2164,16 @@ function isValidDocId(id) {
|
|
|
2128
2164
|
* own AbracadabraProvider instances sharing the same WebSocket connection.
|
|
2129
2165
|
*
|
|
2130
2166
|
* 2. Offline-first – persists CRDT updates to IndexedDB so they survive
|
|
2131
|
-
* page reloads and network outages. On
|
|
2132
|
-
*
|
|
2167
|
+
* page reloads and network outages. On startup, the last saved document
|
|
2168
|
+
* snapshot is applied immediately so the UI is usable without a server
|
|
2169
|
+
* connection. On reconnect, pending updates are flushed and a fresh
|
|
2170
|
+
* snapshot is saved.
|
|
2171
|
+
*
|
|
2172
|
+
* 3. Server-scoped storage – the IndexedDB database is keyed by server
|
|
2173
|
+
* hostname + docId, preventing cross-server data contamination when the
|
|
2174
|
+
* same docId is used on multiple servers.
|
|
2133
2175
|
*
|
|
2134
|
-
*
|
|
2176
|
+
* 4. Permission snapshotting – stores the resolved role locally so the UI
|
|
2135
2177
|
* can gate write operations without a network round-trip. Role is
|
|
2136
2178
|
* refreshed from the server on every reconnect.
|
|
2137
2179
|
*/
|
|
@@ -2151,12 +2193,41 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
|
|
|
2151
2193
|
this._client = client;
|
|
2152
2194
|
this.abracadabraConfig = configuration;
|
|
2153
2195
|
this.subdocLoading = configuration.subdocLoading ?? "lazy";
|
|
2154
|
-
|
|
2196
|
+
const serverOrigin = AbracadabraProvider.deriveServerOrigin(configuration, client);
|
|
2197
|
+
this.offlineStore = configuration.disableOfflineStore ? null : new OfflineStore(configuration.name, serverOrigin);
|
|
2155
2198
|
this.on("subdocRegistered", configuration.onSubdocRegistered ?? (() => null));
|
|
2156
2199
|
this.on("subdocLoaded", configuration.onSubdocLoaded ?? (() => null));
|
|
2157
2200
|
this.document.on("subdocs", this.boundHandleYSubdocsChange);
|
|
2158
2201
|
this.on("synced", () => this.flushPendingUpdates());
|
|
2159
2202
|
this.restorePermissionSnapshot();
|
|
2203
|
+
this.ready = this._initFromOfflineStore();
|
|
2204
|
+
}
|
|
2205
|
+
/**
|
|
2206
|
+
* Extract the server hostname from the provider configuration.
|
|
2207
|
+
* Used to namespace the IndexedDB key so docs from different servers
|
|
2208
|
+
* never share the same database.
|
|
2209
|
+
*/
|
|
2210
|
+
static deriveServerOrigin(config, client) {
|
|
2211
|
+
try {
|
|
2212
|
+
const url = config.url ?? config.websocketProvider?.url ?? client?.wsUrl;
|
|
2213
|
+
if (url) return new URL(url).hostname;
|
|
2214
|
+
} catch {}
|
|
2215
|
+
}
|
|
2216
|
+
/**
|
|
2217
|
+
* Load the stored document snapshot (and any pending local edits) from
|
|
2218
|
+
* IndexedDB and apply them to the Y.Doc so the UI can render without a
|
|
2219
|
+
* server connection.
|
|
2220
|
+
*
|
|
2221
|
+
* Uses `this.offlineStore` as the Y.js update origin so that
|
|
2222
|
+
* `documentUpdateHandler` ignores these replayed updates and does not
|
|
2223
|
+
* attempt to re-persist or re-send them.
|
|
2224
|
+
*/
|
|
2225
|
+
async _initFromOfflineStore() {
|
|
2226
|
+
if (!this.offlineStore) return;
|
|
2227
|
+
const snapshot = await this.offlineStore.getDocSnapshot().catch(() => null);
|
|
2228
|
+
if (snapshot) yjs.applyUpdate(this.document, snapshot, this.offlineStore);
|
|
2229
|
+
const pending = await this.offlineStore.getPendingUpdates().catch(() => []);
|
|
2230
|
+
for (const update of pending) yjs.applyUpdate(this.document, update, this.offlineStore);
|
|
2160
2231
|
}
|
|
2161
2232
|
authenticatedHandler(scope) {
|
|
2162
2233
|
super.authenticatedHandler(scope);
|
|
@@ -2283,9 +2354,9 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
|
|
|
2283
2354
|
* child document id. Each child opens its own WebSocket connection because
|
|
2284
2355
|
* the server is document-scoped (one WebSocket ↔ one document).
|
|
2285
2356
|
*/
|
|
2286
|
-
|
|
2287
|
-
if (!isValidDocId(childId))
|
|
2288
|
-
if (this.childProviders.has(childId)) return this.childProviders.get(childId);
|
|
2357
|
+
loadChild(childId) {
|
|
2358
|
+
if (!isValidDocId(childId)) return Promise.reject(/* @__PURE__ */ new Error(`loadChild: "${childId}" is not a valid document ID (must be a UUID). If this node was created with an older version of the app, delete it and recreate it.`));
|
|
2359
|
+
if (this.childProviders.has(childId)) return Promise.resolve(this.childProviders.get(childId));
|
|
2289
2360
|
if (this.pendingLoads.has(childId)) return this.pendingLoads.get(childId);
|
|
2290
2361
|
const load = this._doLoadChild(childId);
|
|
2291
2362
|
this.pendingLoads.set(childId, load);
|
|
@@ -2294,7 +2365,7 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
|
|
|
2294
2365
|
}
|
|
2295
2366
|
async _doLoadChild(childId) {
|
|
2296
2367
|
const childDoc = new yjs.Doc({ guid: childId });
|
|
2297
|
-
await new Promise((resolve) => {
|
|
2368
|
+
if (this.isConnected) await new Promise((resolve) => {
|
|
2298
2369
|
const onRegistered = ({ childId: cid }) => {
|
|
2299
2370
|
if (cid === childId) {
|
|
2300
2371
|
this.off("subdocRegistered", onRegistered);
|
|
@@ -2305,6 +2376,7 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
|
|
|
2305
2376
|
this.registerSubdoc(childDoc);
|
|
2306
2377
|
setTimeout(resolve, 3e3);
|
|
2307
2378
|
});
|
|
2379
|
+
else this.registerSubdoc(childDoc);
|
|
2308
2380
|
const childProvider = new AbracadabraProvider({
|
|
2309
2381
|
name: childId,
|
|
2310
2382
|
document: childDoc,
|
|
@@ -2337,31 +2409,45 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
|
|
|
2337
2409
|
/**
|
|
2338
2410
|
* Override to persist every local update to IndexedDB before sending it
|
|
2339
2411
|
* over the wire, ensuring no work is lost during connection outages.
|
|
2412
|
+
*
|
|
2413
|
+
* Updates applied by the server (origin === this) and updates replayed
|
|
2414
|
+
* from the offline store (origin === this.offlineStore) are both skipped
|
|
2415
|
+
* to avoid loops.
|
|
2340
2416
|
*/
|
|
2341
2417
|
documentUpdateHandler(update, origin) {
|
|
2342
2418
|
if (origin === this) return;
|
|
2419
|
+
if (this.offlineStore !== null && origin === this.offlineStore) return;
|
|
2343
2420
|
this.offlineStore?.persistUpdate(update).catch(() => null);
|
|
2344
2421
|
super.documentUpdateHandler(update, origin);
|
|
2345
2422
|
}
|
|
2346
2423
|
/**
|
|
2347
|
-
* After reconnect + sync
|
|
2348
|
-
*
|
|
2424
|
+
* After reconnect + sync:
|
|
2425
|
+
* 1. Flush any updates that were generated while offline.
|
|
2426
|
+
* 2. Flush any queued subdoc registrations.
|
|
2427
|
+
* 3. Save a fresh full document snapshot for the next offline session.
|
|
2428
|
+
*
|
|
2429
|
+
* Uses a local `store` reference captured at the start so that a concurrent
|
|
2430
|
+
* `destroy()` call setting `offlineStore = null` does not cause null-ref
|
|
2431
|
+
* errors across async await boundaries.
|
|
2349
2432
|
*/
|
|
2350
2433
|
async flushPendingUpdates() {
|
|
2351
|
-
|
|
2352
|
-
|
|
2434
|
+
const store = this.offlineStore;
|
|
2435
|
+
if (!store) return;
|
|
2436
|
+
const updates = await store.getPendingUpdates();
|
|
2353
2437
|
if (updates.length > 0) {
|
|
2354
2438
|
for (const update of updates) this.send(UpdateMessage, {
|
|
2355
2439
|
update,
|
|
2356
2440
|
documentName: this.configuration.name
|
|
2357
2441
|
});
|
|
2358
|
-
await
|
|
2442
|
+
await store.clearPendingUpdates();
|
|
2359
2443
|
}
|
|
2360
|
-
const pendingSubdocs = await
|
|
2444
|
+
const pendingSubdocs = await store.getPendingSubdocs();
|
|
2361
2445
|
for (const { childId } of pendingSubdocs) this.send(SubdocMessage, {
|
|
2362
2446
|
documentName: this.configuration.name,
|
|
2363
2447
|
childDocumentName: childId
|
|
2364
2448
|
});
|
|
2449
|
+
const snapshot = yjs.encodeStateAsUpdate(this.document);
|
|
2450
|
+
await store.saveDocSnapshot(snapshot).catch(() => null);
|
|
2365
2451
|
}
|
|
2366
2452
|
get isConnected() {
|
|
2367
2453
|
return this.configuration.websocketProvider.status === "connected";
|