@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
|
@@ -1351,10 +1351,15 @@ var HocuspocusProviderWebsocket = class extends EventEmitter {
|
|
|
1351
1351
|
if (this.connectionAttempt) this.rejectConnectionAttempt();
|
|
1352
1352
|
this.status = WebSocketStatus.Disconnected;
|
|
1353
1353
|
this.emit("status", { status: WebSocketStatus.Disconnected });
|
|
1354
|
+
const isRateLimited = event?.code === 4429;
|
|
1354
1355
|
this.emit("disconnect", { event });
|
|
1355
|
-
if (
|
|
1356
|
-
|
|
1357
|
-
|
|
1356
|
+
if (isRateLimited) this.emit("rateLimited");
|
|
1357
|
+
if (!this.cancelWebsocketRetry && this.shouldConnect) {
|
|
1358
|
+
const delay = isRateLimited ? 6e4 : this.configuration.delay;
|
|
1359
|
+
setTimeout(() => {
|
|
1360
|
+
this.connect();
|
|
1361
|
+
}, delay);
|
|
1362
|
+
}
|
|
1358
1363
|
}
|
|
1359
1364
|
destroy() {
|
|
1360
1365
|
this.emit("destroy");
|
|
@@ -1677,6 +1682,7 @@ var HocuspocusProvider = class extends EventEmitter {
|
|
|
1677
1682
|
forceSyncInterval: false,
|
|
1678
1683
|
onAuthenticated: () => null,
|
|
1679
1684
|
onAuthenticationFailed: () => null,
|
|
1685
|
+
onRateLimited: () => null,
|
|
1680
1686
|
onOpen: () => null,
|
|
1681
1687
|
onConnect: () => null,
|
|
1682
1688
|
onMessage: () => null,
|
|
@@ -1708,6 +1714,7 @@ var HocuspocusProvider = class extends EventEmitter {
|
|
|
1708
1714
|
this.forwardClose = (e) => this.emit("close", e);
|
|
1709
1715
|
this.forwardDisconnect = (e) => this.emit("disconnect", e);
|
|
1710
1716
|
this.forwardDestroy = () => this.emit("destroy");
|
|
1717
|
+
this.forwardRateLimited = () => this.emit("rateLimited");
|
|
1711
1718
|
this.setConfiguration(configuration);
|
|
1712
1719
|
this.configuration.document = configuration.document ? configuration.document : new Y.Doc();
|
|
1713
1720
|
this.configuration.awareness = configuration.awareness !== void 0 ? configuration.awareness : new Awareness(this.document);
|
|
@@ -1722,6 +1729,7 @@ var HocuspocusProvider = class extends EventEmitter {
|
|
|
1722
1729
|
this.on("unsyncedChanges", this.configuration.onUnsyncedChanges);
|
|
1723
1730
|
this.on("authenticated", this.configuration.onAuthenticated);
|
|
1724
1731
|
this.on("authenticationFailed", this.configuration.onAuthenticationFailed);
|
|
1732
|
+
this.on("rateLimited", this.configuration.onRateLimited);
|
|
1725
1733
|
this.awareness?.on("update", () => {
|
|
1726
1734
|
this.emit("awarenessUpdate", { states: awarenessStatesToArray(this.awareness.getStates()) });
|
|
1727
1735
|
});
|
|
@@ -1914,6 +1922,7 @@ var HocuspocusProvider = class extends EventEmitter {
|
|
|
1914
1922
|
this.configuration.websocketProvider.off("disconnect", this.forwardDisconnect);
|
|
1915
1923
|
this.configuration.websocketProvider.off("destroy", this.configuration.onDestroy);
|
|
1916
1924
|
this.configuration.websocketProvider.off("destroy", this.forwardDestroy);
|
|
1925
|
+
this.configuration.websocketProvider.off("rateLimited", this.forwardRateLimited);
|
|
1917
1926
|
this.configuration.websocketProvider.detach(this);
|
|
1918
1927
|
this._isAttached = false;
|
|
1919
1928
|
}
|
|
@@ -1931,6 +1940,7 @@ var HocuspocusProvider = class extends EventEmitter {
|
|
|
1931
1940
|
this.configuration.websocketProvider.on("disconnect", this.forwardDisconnect);
|
|
1932
1941
|
this.configuration.websocketProvider.on("destroy", this.configuration.onDestroy);
|
|
1933
1942
|
this.configuration.websocketProvider.on("destroy", this.forwardDestroy);
|
|
1943
|
+
this.configuration.websocketProvider.on("rateLimited", this.forwardRateLimited);
|
|
1934
1944
|
this.configuration.websocketProvider.attach(this);
|
|
1935
1945
|
this._isAttached = true;
|
|
1936
1946
|
}
|
|
@@ -1951,18 +1961,19 @@ var HocuspocusProvider = class extends EventEmitter {
|
|
|
1951
1961
|
|
|
1952
1962
|
//#endregion
|
|
1953
1963
|
//#region packages/provider/src/OfflineStore.ts
|
|
1954
|
-
const DB_VERSION =
|
|
1964
|
+
const DB_VERSION = 2;
|
|
1955
1965
|
function idbAvailable() {
|
|
1956
1966
|
return typeof globalThis !== "undefined" && "indexedDB" in globalThis;
|
|
1957
1967
|
}
|
|
1958
|
-
function openDb$1(
|
|
1968
|
+
function openDb$1(storeKey) {
|
|
1959
1969
|
return new Promise((resolve, reject) => {
|
|
1960
|
-
const req = globalThis.indexedDB.open(`abracadabra:${
|
|
1970
|
+
const req = globalThis.indexedDB.open(`abracadabra:${storeKey}`, DB_VERSION);
|
|
1961
1971
|
req.onupgradeneeded = (event) => {
|
|
1962
1972
|
const db = event.target.result;
|
|
1963
1973
|
if (!db.objectStoreNames.contains("updates")) db.createObjectStore("updates", { autoIncrement: true });
|
|
1964
1974
|
if (!db.objectStoreNames.contains("meta")) db.createObjectStore("meta");
|
|
1965
1975
|
if (!db.objectStoreNames.contains("subdoc_queue")) db.createObjectStore("subdoc_queue", { keyPath: "childId" });
|
|
1976
|
+
if (!db.objectStoreNames.contains("doc_state")) db.createObjectStore("doc_state");
|
|
1966
1977
|
};
|
|
1967
1978
|
req.onsuccess = () => resolve(req.result);
|
|
1968
1979
|
req.onerror = () => reject(req.error);
|
|
@@ -1975,13 +1986,19 @@ function txPromise(store, request) {
|
|
|
1975
1986
|
});
|
|
1976
1987
|
}
|
|
1977
1988
|
var OfflineStore = class {
|
|
1978
|
-
|
|
1989
|
+
/**
|
|
1990
|
+
* @param docId The document UUID.
|
|
1991
|
+
* @param serverOrigin Hostname of the server (e.g. "abra.cou.sh").
|
|
1992
|
+
* When provided the IndexedDB database is namespaced
|
|
1993
|
+
* per-server, preventing cross-server data contamination.
|
|
1994
|
+
*/
|
|
1995
|
+
constructor(docId, serverOrigin) {
|
|
1979
1996
|
this.db = null;
|
|
1980
|
-
this.
|
|
1997
|
+
this.storeKey = serverOrigin ? `${serverOrigin}/${docId}` : docId;
|
|
1981
1998
|
}
|
|
1982
1999
|
async getDb() {
|
|
1983
2000
|
if (!idbAvailable()) return null;
|
|
1984
|
-
if (!this.db) this.db = await openDb$1(this.
|
|
2001
|
+
if (!this.db) this.db = await openDb$1(this.storeKey).catch(() => null);
|
|
1985
2002
|
return this.db;
|
|
1986
2003
|
}
|
|
1987
2004
|
async persistUpdate(update) {
|
|
@@ -2005,6 +2022,25 @@ var OfflineStore = class {
|
|
|
2005
2022
|
const tx = db.transaction("updates", "readwrite");
|
|
2006
2023
|
await txPromise(tx.objectStore("updates"), tx.objectStore("updates").clear());
|
|
2007
2024
|
}
|
|
2025
|
+
/**
|
|
2026
|
+
* Persist a full Y.js state snapshot (Y.encodeStateAsUpdate output).
|
|
2027
|
+
* Replaces any previously stored snapshot.
|
|
2028
|
+
*/
|
|
2029
|
+
async saveDocSnapshot(snapshot) {
|
|
2030
|
+
const db = await this.getDb();
|
|
2031
|
+
if (!db) return;
|
|
2032
|
+
const tx = db.transaction("doc_state", "readwrite");
|
|
2033
|
+
await txPromise(tx.objectStore("doc_state"), tx.objectStore("doc_state").put(snapshot, "snapshot"));
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* Retrieve the stored full document snapshot, or null if none exists.
|
|
2037
|
+
*/
|
|
2038
|
+
async getDocSnapshot() {
|
|
2039
|
+
const db = await this.getDb();
|
|
2040
|
+
if (!db) return null;
|
|
2041
|
+
const tx = db.transaction("doc_state", "readonly");
|
|
2042
|
+
return await txPromise(tx.objectStore("doc_state"), tx.objectStore("doc_state").get("snapshot")) ?? null;
|
|
2043
|
+
}
|
|
2008
2044
|
async getStateVector() {
|
|
2009
2045
|
const db = await this.getDb();
|
|
2010
2046
|
if (!db) return null;
|
|
@@ -2098,10 +2134,16 @@ function isValidDocId(id) {
|
|
|
2098
2134
|
* own AbracadabraProvider instances sharing the same WebSocket connection.
|
|
2099
2135
|
*
|
|
2100
2136
|
* 2. Offline-first – persists CRDT updates to IndexedDB so they survive
|
|
2101
|
-
* page reloads and network outages. On
|
|
2102
|
-
*
|
|
2137
|
+
* page reloads and network outages. On startup, the last saved document
|
|
2138
|
+
* snapshot is applied immediately so the UI is usable without a server
|
|
2139
|
+
* connection. On reconnect, pending updates are flushed and a fresh
|
|
2140
|
+
* snapshot is saved.
|
|
2141
|
+
*
|
|
2142
|
+
* 3. Server-scoped storage – the IndexedDB database is keyed by server
|
|
2143
|
+
* hostname + docId, preventing cross-server data contamination when the
|
|
2144
|
+
* same docId is used on multiple servers.
|
|
2103
2145
|
*
|
|
2104
|
-
*
|
|
2146
|
+
* 4. Permission snapshotting – stores the resolved role locally so the UI
|
|
2105
2147
|
* can gate write operations without a network round-trip. Role is
|
|
2106
2148
|
* refreshed from the server on every reconnect.
|
|
2107
2149
|
*/
|
|
@@ -2121,12 +2163,41 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
|
|
|
2121
2163
|
this._client = client;
|
|
2122
2164
|
this.abracadabraConfig = configuration;
|
|
2123
2165
|
this.subdocLoading = configuration.subdocLoading ?? "lazy";
|
|
2124
|
-
|
|
2166
|
+
const serverOrigin = AbracadabraProvider.deriveServerOrigin(configuration, client);
|
|
2167
|
+
this.offlineStore = configuration.disableOfflineStore ? null : new OfflineStore(configuration.name, serverOrigin);
|
|
2125
2168
|
this.on("subdocRegistered", configuration.onSubdocRegistered ?? (() => null));
|
|
2126
2169
|
this.on("subdocLoaded", configuration.onSubdocLoaded ?? (() => null));
|
|
2127
2170
|
this.document.on("subdocs", this.boundHandleYSubdocsChange);
|
|
2128
2171
|
this.on("synced", () => this.flushPendingUpdates());
|
|
2129
2172
|
this.restorePermissionSnapshot();
|
|
2173
|
+
this.ready = this._initFromOfflineStore();
|
|
2174
|
+
}
|
|
2175
|
+
/**
|
|
2176
|
+
* Extract the server hostname from the provider configuration.
|
|
2177
|
+
* Used to namespace the IndexedDB key so docs from different servers
|
|
2178
|
+
* never share the same database.
|
|
2179
|
+
*/
|
|
2180
|
+
static deriveServerOrigin(config, client) {
|
|
2181
|
+
try {
|
|
2182
|
+
const url = config.url ?? config.websocketProvider?.url ?? client?.wsUrl;
|
|
2183
|
+
if (url) return new URL(url).hostname;
|
|
2184
|
+
} catch {}
|
|
2185
|
+
}
|
|
2186
|
+
/**
|
|
2187
|
+
* Load the stored document snapshot (and any pending local edits) from
|
|
2188
|
+
* IndexedDB and apply them to the Y.Doc so the UI can render without a
|
|
2189
|
+
* server connection.
|
|
2190
|
+
*
|
|
2191
|
+
* Uses `this.offlineStore` as the Y.js update origin so that
|
|
2192
|
+
* `documentUpdateHandler` ignores these replayed updates and does not
|
|
2193
|
+
* attempt to re-persist or re-send them.
|
|
2194
|
+
*/
|
|
2195
|
+
async _initFromOfflineStore() {
|
|
2196
|
+
if (!this.offlineStore) return;
|
|
2197
|
+
const snapshot = await this.offlineStore.getDocSnapshot().catch(() => null);
|
|
2198
|
+
if (snapshot) Y.applyUpdate(this.document, snapshot, this.offlineStore);
|
|
2199
|
+
const pending = await this.offlineStore.getPendingUpdates().catch(() => []);
|
|
2200
|
+
for (const update of pending) Y.applyUpdate(this.document, update, this.offlineStore);
|
|
2130
2201
|
}
|
|
2131
2202
|
authenticatedHandler(scope) {
|
|
2132
2203
|
super.authenticatedHandler(scope);
|
|
@@ -2253,9 +2324,9 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
|
|
|
2253
2324
|
* child document id. Each child opens its own WebSocket connection because
|
|
2254
2325
|
* the server is document-scoped (one WebSocket ↔ one document).
|
|
2255
2326
|
*/
|
|
2256
|
-
|
|
2257
|
-
if (!isValidDocId(childId))
|
|
2258
|
-
if (this.childProviders.has(childId)) return this.childProviders.get(childId);
|
|
2327
|
+
loadChild(childId) {
|
|
2328
|
+
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.`));
|
|
2329
|
+
if (this.childProviders.has(childId)) return Promise.resolve(this.childProviders.get(childId));
|
|
2259
2330
|
if (this.pendingLoads.has(childId)) return this.pendingLoads.get(childId);
|
|
2260
2331
|
const load = this._doLoadChild(childId);
|
|
2261
2332
|
this.pendingLoads.set(childId, load);
|
|
@@ -2264,7 +2335,7 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
|
|
|
2264
2335
|
}
|
|
2265
2336
|
async _doLoadChild(childId) {
|
|
2266
2337
|
const childDoc = new Y.Doc({ guid: childId });
|
|
2267
|
-
await new Promise((resolve) => {
|
|
2338
|
+
if (this.isConnected) await new Promise((resolve) => {
|
|
2268
2339
|
const onRegistered = ({ childId: cid }) => {
|
|
2269
2340
|
if (cid === childId) {
|
|
2270
2341
|
this.off("subdocRegistered", onRegistered);
|
|
@@ -2275,6 +2346,7 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
|
|
|
2275
2346
|
this.registerSubdoc(childDoc);
|
|
2276
2347
|
setTimeout(resolve, 3e3);
|
|
2277
2348
|
});
|
|
2349
|
+
else this.registerSubdoc(childDoc);
|
|
2278
2350
|
const childProvider = new AbracadabraProvider({
|
|
2279
2351
|
name: childId,
|
|
2280
2352
|
document: childDoc,
|
|
@@ -2307,31 +2379,45 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
|
|
|
2307
2379
|
/**
|
|
2308
2380
|
* Override to persist every local update to IndexedDB before sending it
|
|
2309
2381
|
* over the wire, ensuring no work is lost during connection outages.
|
|
2382
|
+
*
|
|
2383
|
+
* Updates applied by the server (origin === this) and updates replayed
|
|
2384
|
+
* from the offline store (origin === this.offlineStore) are both skipped
|
|
2385
|
+
* to avoid loops.
|
|
2310
2386
|
*/
|
|
2311
2387
|
documentUpdateHandler(update, origin) {
|
|
2312
2388
|
if (origin === this) return;
|
|
2389
|
+
if (this.offlineStore !== null && origin === this.offlineStore) return;
|
|
2313
2390
|
this.offlineStore?.persistUpdate(update).catch(() => null);
|
|
2314
2391
|
super.documentUpdateHandler(update, origin);
|
|
2315
2392
|
}
|
|
2316
2393
|
/**
|
|
2317
|
-
* After reconnect + sync
|
|
2318
|
-
*
|
|
2394
|
+
* After reconnect + sync:
|
|
2395
|
+
* 1. Flush any updates that were generated while offline.
|
|
2396
|
+
* 2. Flush any queued subdoc registrations.
|
|
2397
|
+
* 3. Save a fresh full document snapshot for the next offline session.
|
|
2398
|
+
*
|
|
2399
|
+
* Uses a local `store` reference captured at the start so that a concurrent
|
|
2400
|
+
* `destroy()` call setting `offlineStore = null` does not cause null-ref
|
|
2401
|
+
* errors across async await boundaries.
|
|
2319
2402
|
*/
|
|
2320
2403
|
async flushPendingUpdates() {
|
|
2321
|
-
|
|
2322
|
-
|
|
2404
|
+
const store = this.offlineStore;
|
|
2405
|
+
if (!store) return;
|
|
2406
|
+
const updates = await store.getPendingUpdates();
|
|
2323
2407
|
if (updates.length > 0) {
|
|
2324
2408
|
for (const update of updates) this.send(UpdateMessage, {
|
|
2325
2409
|
update,
|
|
2326
2410
|
documentName: this.configuration.name
|
|
2327
2411
|
});
|
|
2328
|
-
await
|
|
2412
|
+
await store.clearPendingUpdates();
|
|
2329
2413
|
}
|
|
2330
|
-
const pendingSubdocs = await
|
|
2414
|
+
const pendingSubdocs = await store.getPendingSubdocs();
|
|
2331
2415
|
for (const { childId } of pendingSubdocs) this.send(SubdocMessage, {
|
|
2332
2416
|
documentName: this.configuration.name,
|
|
2333
2417
|
childDocumentName: childId
|
|
2334
2418
|
});
|
|
2419
|
+
const snapshot = Y.encodeStateAsUpdate(this.document);
|
|
2420
|
+
await store.saveDocSnapshot(snapshot).catch(() => null);
|
|
2335
2421
|
}
|
|
2336
2422
|
get isConnected() {
|
|
2337
2423
|
return this.configuration.websocketProvider.status === "connected";
|