@abraca/dabra 0.5.0 → 0.7.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 +737 -81
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +715 -77
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +246 -31
- package/package.json +1 -2
- package/src/{HocuspocusProvider.ts → AbracadabraBaseProvider.ts} +33 -22
- package/src/AbracadabraClient.ts +69 -3
- package/src/AbracadabraProvider.ts +18 -14
- package/src/{HocuspocusProviderWebsocket.ts → AbracadabraWS.ts} +36 -22
- package/src/CloseEvents.ts +49 -0
- package/src/DocumentCache.ts +210 -0
- package/src/FileBlobStore.ts +300 -0
- package/src/MessageReceiver.ts +8 -8
- package/src/OfflineStore.ts +11 -5
- package/src/OutgoingMessages/AuthenticationMessage.ts +1 -1
- package/src/SearchIndex.ts +247 -0
- package/src/auth.ts +62 -0
- package/src/awarenessStatesToArray.ts +10 -0
- package/src/index.ts +9 -2
- package/src/types.ts +46 -1
package/src/AbracadabraClient.ts
CHANGED
|
@@ -6,7 +6,9 @@ import type {
|
|
|
6
6
|
PublicKeyInfo,
|
|
7
7
|
PermissionEntry,
|
|
8
8
|
HealthStatus,
|
|
9
|
+
ServerInfo,
|
|
9
10
|
} from "./types.ts";
|
|
11
|
+
import type { DocumentCache } from "./DocumentCache.ts";
|
|
10
12
|
|
|
11
13
|
export interface AbracadabraClientConfig {
|
|
12
14
|
/** Server base URL (http or https). WebSocket URL is derived automatically. */
|
|
@@ -19,6 +21,13 @@ export interface AbracadabraClientConfig {
|
|
|
19
21
|
storageKey?: string;
|
|
20
22
|
/** Custom fetch implementation (useful for Node.js or testing). */
|
|
21
23
|
fetch?: typeof globalThis.fetch;
|
|
24
|
+
/**
|
|
25
|
+
* Optional metadata cache. When provided, read methods (getDoc, listChildren,
|
|
26
|
+
* getMe, listPermissions, listUploads) check the cache before hitting the
|
|
27
|
+
* network. Write methods (deleteDoc, upload, deleteUpload) invalidate affected
|
|
28
|
+
* cache entries automatically.
|
|
29
|
+
*/
|
|
30
|
+
cache?: DocumentCache;
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
export class AbracadabraClient {
|
|
@@ -27,12 +36,14 @@ export class AbracadabraClient {
|
|
|
27
36
|
private readonly persistAuth: boolean;
|
|
28
37
|
private readonly storageKey: string;
|
|
29
38
|
private readonly _fetch: typeof globalThis.fetch;
|
|
39
|
+
readonly cache: DocumentCache | null;
|
|
30
40
|
|
|
31
41
|
constructor(config: AbracadabraClientConfig) {
|
|
32
42
|
this.baseUrl = config.url.replace(/\/+$/, "");
|
|
33
43
|
this.persistAuth = config.persistAuth ?? typeof localStorage !== "undefined";
|
|
34
44
|
this.storageKey = config.storageKey ?? "abracadabra:auth";
|
|
35
45
|
this._fetch = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
46
|
+
this.cache = config.cache ?? null;
|
|
36
47
|
|
|
37
48
|
// Load token: explicit > persisted > null
|
|
38
49
|
this._token = config.token ?? this.loadPersistedToken() ?? null;
|
|
@@ -175,7 +186,15 @@ export class AbracadabraClient {
|
|
|
175
186
|
|
|
176
187
|
/** Get the current user's profile. */
|
|
177
188
|
async getMe(): Promise<UserProfile> {
|
|
178
|
-
|
|
189
|
+
if (this.cache) {
|
|
190
|
+
const cached = await this.cache.getCurrentProfile();
|
|
191
|
+
if (cached) return cached;
|
|
192
|
+
}
|
|
193
|
+
const profile = await this.request<UserProfile>("GET", "/users/me");
|
|
194
|
+
if (this.cache) {
|
|
195
|
+
await this.cache.setCurrentProfile(profile).catch(() => null);
|
|
196
|
+
}
|
|
197
|
+
return profile;
|
|
179
198
|
}
|
|
180
199
|
|
|
181
200
|
/** Update the current user's display name. */
|
|
@@ -192,20 +211,38 @@ export class AbracadabraClient {
|
|
|
192
211
|
|
|
193
212
|
/** Get document metadata. */
|
|
194
213
|
async getDoc(docId: string): Promise<DocumentMeta> {
|
|
195
|
-
|
|
214
|
+
if (this.cache) {
|
|
215
|
+
const cached = await this.cache.getDoc(docId);
|
|
216
|
+
if (cached) return cached;
|
|
217
|
+
}
|
|
218
|
+
const meta = await this.request<DocumentMeta>("GET", `/docs/${encodeURIComponent(docId)}`);
|
|
219
|
+
if (this.cache) {
|
|
220
|
+
await this.cache.setDoc(meta).catch(() => null);
|
|
221
|
+
}
|
|
222
|
+
return meta;
|
|
196
223
|
}
|
|
197
224
|
|
|
198
225
|
/** Delete a document (requires Owner role). Cascades to children and uploads. */
|
|
199
226
|
async deleteDoc(docId: string): Promise<void> {
|
|
200
227
|
await this.request("DELETE", `/docs/${encodeURIComponent(docId)}`);
|
|
228
|
+
if (this.cache) {
|
|
229
|
+
await this.cache.invalidateDoc(docId).catch(() => null);
|
|
230
|
+
}
|
|
201
231
|
}
|
|
202
232
|
|
|
203
233
|
/** List immediate child documents. */
|
|
204
234
|
async listChildren(docId: string): Promise<string[]> {
|
|
235
|
+
if (this.cache) {
|
|
236
|
+
const cached = await this.cache.getChildren(docId);
|
|
237
|
+
if (cached) return cached;
|
|
238
|
+
}
|
|
205
239
|
const res = await this.request<{ children: string[] }>(
|
|
206
240
|
"GET",
|
|
207
241
|
`/docs/${encodeURIComponent(docId)}/children`,
|
|
208
242
|
);
|
|
243
|
+
if (this.cache) {
|
|
244
|
+
await this.cache.setChildren(docId, res.children).catch(() => null);
|
|
245
|
+
}
|
|
209
246
|
return res.children;
|
|
210
247
|
}
|
|
211
248
|
|
|
@@ -222,10 +259,17 @@ export class AbracadabraClient {
|
|
|
222
259
|
|
|
223
260
|
/** List all permissions for a document (requires read access). */
|
|
224
261
|
async listPermissions(docId: string): Promise<PermissionEntry[]> {
|
|
262
|
+
if (this.cache) {
|
|
263
|
+
const cached = await this.cache.getPermissions(docId);
|
|
264
|
+
if (cached) return cached;
|
|
265
|
+
}
|
|
225
266
|
const res = await this.request<{ permissions: PermissionEntry[] }>(
|
|
226
267
|
"GET",
|
|
227
268
|
`/docs/${encodeURIComponent(docId)}/permissions`,
|
|
228
269
|
);
|
|
270
|
+
if (this.cache) {
|
|
271
|
+
await this.cache.setPermissions(docId, res.permissions).catch(() => null);
|
|
272
|
+
}
|
|
229
273
|
return res.permissions;
|
|
230
274
|
}
|
|
231
275
|
|
|
@@ -273,15 +317,26 @@ export class AbracadabraClient {
|
|
|
273
317
|
if (!res.ok) {
|
|
274
318
|
throw await this.toError(res);
|
|
275
319
|
}
|
|
276
|
-
|
|
320
|
+
const meta = await res.json() as UploadMeta;
|
|
321
|
+
if (this.cache) {
|
|
322
|
+
await this.cache.invalidateUploads(docId).catch(() => null);
|
|
323
|
+
}
|
|
324
|
+
return meta;
|
|
277
325
|
}
|
|
278
326
|
|
|
279
327
|
/** List all uploads for a document. */
|
|
280
328
|
async listUploads(docId: string): Promise<UploadInfo[]> {
|
|
329
|
+
if (this.cache) {
|
|
330
|
+
const cached = await this.cache.getUploads(docId);
|
|
331
|
+
if (cached) return cached;
|
|
332
|
+
}
|
|
281
333
|
const res = await this.request<{ uploads: UploadInfo[] }>(
|
|
282
334
|
"GET",
|
|
283
335
|
`/docs/${encodeURIComponent(docId)}/uploads`,
|
|
284
336
|
);
|
|
337
|
+
if (this.cache) {
|
|
338
|
+
await this.cache.setUploads(docId, res.uploads).catch(() => null);
|
|
339
|
+
}
|
|
285
340
|
return res.uploads;
|
|
286
341
|
}
|
|
287
342
|
|
|
@@ -308,6 +363,9 @@ export class AbracadabraClient {
|
|
|
308
363
|
"DELETE",
|
|
309
364
|
`/docs/${encodeURIComponent(docId)}/uploads/${encodeURIComponent(uploadId)}`,
|
|
310
365
|
);
|
|
366
|
+
if (this.cache) {
|
|
367
|
+
await this.cache.invalidateUploads(docId).catch(() => null);
|
|
368
|
+
}
|
|
311
369
|
}
|
|
312
370
|
|
|
313
371
|
// ── System ───────────────────────────────────────────────────────────────
|
|
@@ -317,6 +375,14 @@ export class AbracadabraClient {
|
|
|
317
375
|
return this.request<HealthStatus>("GET", "/health", { auth: false });
|
|
318
376
|
}
|
|
319
377
|
|
|
378
|
+
/**
|
|
379
|
+
* Fetch server metadata including the optional `index_doc_id` entry point.
|
|
380
|
+
* No auth required.
|
|
381
|
+
*/
|
|
382
|
+
async serverInfo(): Promise<ServerInfo> {
|
|
383
|
+
return this.request<ServerInfo>("GET", "/info", { auth: false });
|
|
384
|
+
}
|
|
385
|
+
|
|
320
386
|
// ── Internals ────────────────────────────────────────────────────────────
|
|
321
387
|
|
|
322
388
|
private async request<T = void>(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as Y from "yjs";
|
|
2
|
-
import {
|
|
3
|
-
import type {
|
|
4
|
-
import type {
|
|
2
|
+
import { AbracadabraBaseProvider } from "./AbracadabraBaseProvider.ts";
|
|
3
|
+
import type { AbracadabraBaseProviderConfiguration } from "./AbracadabraBaseProvider.ts";
|
|
4
|
+
import type { AbracadabraWS } from "./AbracadabraWS.ts";
|
|
5
5
|
import { OfflineStore } from "./OfflineStore.ts";
|
|
6
6
|
import { SubdocMessage } from "./OutgoingMessages/SubdocMessage.ts";
|
|
7
7
|
import { UpdateMessage } from "./OutgoingMessages/UpdateMessage.ts";
|
|
@@ -15,7 +15,7 @@ import { AuthenticationMessage } from "./OutgoingMessages/AuthenticationMessage.
|
|
|
15
15
|
import type { AbracadabraClient } from "./AbracadabraClient.ts";
|
|
16
16
|
|
|
17
17
|
export interface AbracadabraProviderConfiguration
|
|
18
|
-
extends Omit<
|
|
18
|
+
extends Omit<AbracadabraBaseProviderConfiguration, "url" | "websocketProvider"> {
|
|
19
19
|
/**
|
|
20
20
|
* Subdocument loading strategy.
|
|
21
21
|
* - "lazy" (default) – child providers are created only when explicitly requested.
|
|
@@ -58,7 +58,7 @@ export interface AbracadabraProviderConfiguration
|
|
|
58
58
|
url?: string;
|
|
59
59
|
|
|
60
60
|
/** Shared WebSocket connection (use when multiplexing multiple root documents). */
|
|
61
|
-
websocketProvider?:
|
|
61
|
+
websocketProvider?: AbracadabraWS;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/** Validate that a string is a UUID acceptable by the server's DocId parser. */
|
|
@@ -67,7 +67,7 @@ function isValidDocId(id: string): boolean {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
|
-
* AbracadabraProvider extends
|
|
70
|
+
* AbracadabraProvider extends AbracadabraBaseProvider with:
|
|
71
71
|
*
|
|
72
72
|
* 1. Subdocument lifecycle – intercepts Y.Doc subdoc events and syncs them
|
|
73
73
|
* with the server via MSG_SUBDOC (4) frames. Child documents get their
|
|
@@ -87,7 +87,7 @@ function isValidDocId(id: string): boolean {
|
|
|
87
87
|
* can gate write operations without a network round-trip. Role is
|
|
88
88
|
* refreshed from the server on every reconnect.
|
|
89
89
|
*/
|
|
90
|
-
export class AbracadabraProvider extends
|
|
90
|
+
export class AbracadabraProvider extends AbracadabraBaseProvider {
|
|
91
91
|
public effectiveRole: EffectiveRole = null;
|
|
92
92
|
|
|
93
93
|
private _client: AbracadabraClient | null;
|
|
@@ -110,7 +110,7 @@ export class AbracadabraProvider extends HocuspocusProvider {
|
|
|
110
110
|
|
|
111
111
|
constructor(configuration: AbracadabraProviderConfiguration) {
|
|
112
112
|
// Derive URL and token from client when not explicitly set.
|
|
113
|
-
const resolved = { ...configuration } as
|
|
113
|
+
const resolved = { ...configuration } as AbracadabraBaseProviderConfiguration;
|
|
114
114
|
const client = configuration.client ?? null;
|
|
115
115
|
|
|
116
116
|
if (client) {
|
|
@@ -165,7 +165,7 @@ export class AbracadabraProvider extends HocuspocusProvider {
|
|
|
165
165
|
try {
|
|
166
166
|
const url =
|
|
167
167
|
config.url ??
|
|
168
|
-
(config.websocketProvider as
|
|
168
|
+
(config.websocketProvider as AbracadabraWS | undefined)?.url ??
|
|
169
169
|
client?.wsUrl;
|
|
170
170
|
if (url) return new URL(url).hostname;
|
|
171
171
|
} catch {
|
|
@@ -188,12 +188,16 @@ export class AbracadabraProvider extends HocuspocusProvider {
|
|
|
188
188
|
private async _initFromOfflineStore(): Promise<void> {
|
|
189
189
|
if (!this.offlineStore) return;
|
|
190
190
|
|
|
191
|
-
|
|
191
|
+
// Fetch snapshot and pending updates in parallel — each call opens the
|
|
192
|
+
// IDB database, so running them concurrently roughly halves the latency.
|
|
193
|
+
const [snapshot, pending] = await Promise.all([
|
|
194
|
+
this.offlineStore.getDocSnapshot().catch(() => null),
|
|
195
|
+
this.offlineStore.getPendingUpdates().catch(() => []),
|
|
196
|
+
]);
|
|
197
|
+
|
|
192
198
|
if (snapshot) {
|
|
193
199
|
Y.applyUpdate(this.document, snapshot, this.offlineStore);
|
|
194
200
|
}
|
|
195
|
-
|
|
196
|
-
const pending = await this.offlineStore.getPendingUpdates().catch(() => []);
|
|
197
201
|
for (const update of pending) {
|
|
198
202
|
Y.applyUpdate(this.document, update, this.offlineStore);
|
|
199
203
|
}
|
|
@@ -425,7 +429,7 @@ export class AbracadabraProvider extends HocuspocusProvider {
|
|
|
425
429
|
this.registerSubdoc(childDoc);
|
|
426
430
|
|
|
427
431
|
// Each child gets its own WebSocket connection. Omitting
|
|
428
|
-
// websocketProvider lets
|
|
432
|
+
// websocketProvider lets AbracadabraBaseProvider create one automatically
|
|
429
433
|
// (manageSocket = true), so we do NOT call attach() manually.
|
|
430
434
|
const childProvider = new AbracadabraProvider({
|
|
431
435
|
name: childId,
|
|
@@ -523,7 +527,7 @@ export class AbracadabraProvider extends HocuspocusProvider {
|
|
|
523
527
|
|
|
524
528
|
get isConnected(): boolean {
|
|
525
529
|
return (
|
|
526
|
-
(this.configuration.websocketProvider as
|
|
530
|
+
(this.configuration.websocketProvider as AbracadabraWS)
|
|
527
531
|
.status === "connected"
|
|
528
532
|
);
|
|
529
533
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { WsReadyStates } from "
|
|
1
|
+
import { WsReadyStates } from "./types.ts";
|
|
2
2
|
import { retry } from "@lifeomic/attempt";
|
|
3
3
|
import * as time from "lib0/time";
|
|
4
4
|
import type { Event, MessageEvent } from "ws";
|
|
5
5
|
import EventEmitter from "./EventEmitter.ts";
|
|
6
|
-
import type {
|
|
6
|
+
import type { AbracadabraBaseProvider } from "./AbracadabraBaseProvider.ts";
|
|
7
7
|
import { IncomingMessage } from "./IncomingMessage.ts";
|
|
8
8
|
import { CloseMessage } from "./OutgoingMessages/CloseMessage.ts";
|
|
9
9
|
import {
|
|
@@ -18,15 +18,21 @@ import {
|
|
|
18
18
|
type onStatusParameters,
|
|
19
19
|
} from "./types.ts";
|
|
20
20
|
|
|
21
|
-
export type
|
|
22
|
-
|
|
21
|
+
export type AbracadabraWebSocketConn = WebSocket & { identifier: string };
|
|
22
|
+
/** @deprecated Use AbracadabraWebSocketConn */
|
|
23
|
+
export type HocuspocusWebSocket = AbracadabraWebSocketConn;
|
|
24
|
+
/** @deprecated Use AbracadabraWebSocketConn */
|
|
25
|
+
export type HocusPocusWebSocket = AbracadabraWebSocketConn;
|
|
23
26
|
|
|
24
|
-
export type
|
|
25
|
-
Pick<
|
|
27
|
+
export type AbracadabraWSConfiguration = Required<
|
|
28
|
+
Pick<CompleteAbracadabraWSConfiguration, "url">
|
|
26
29
|
> &
|
|
27
|
-
Partial<
|
|
30
|
+
Partial<CompleteAbracadabraWSConfiguration>;
|
|
28
31
|
|
|
29
|
-
|
|
32
|
+
/** @deprecated Use AbracadabraWSConfiguration */
|
|
33
|
+
export type HocuspocusProviderWebsocketConfiguration = AbracadabraWSConfiguration;
|
|
34
|
+
|
|
35
|
+
export interface CompleteAbracadabraWSConfiguration {
|
|
30
36
|
/**
|
|
31
37
|
* Whether to connect automatically when creating the provider instance. Default=true
|
|
32
38
|
*/
|
|
@@ -99,13 +105,16 @@ export interface CompleteHocuspocusProviderWebsocketConfiguration {
|
|
|
99
105
|
/**
|
|
100
106
|
* Map of attached providers keyed by documentName.
|
|
101
107
|
*/
|
|
102
|
-
providerMap: Map<string,
|
|
108
|
+
providerMap: Map<string, AbracadabraBaseProvider>;
|
|
103
109
|
}
|
|
104
110
|
|
|
105
|
-
|
|
111
|
+
/** @deprecated Use CompleteAbracadabraWSConfiguration */
|
|
112
|
+
export type CompleteHocuspocusProviderWebsocketConfiguration = CompleteAbracadabraWSConfiguration;
|
|
113
|
+
|
|
114
|
+
export class AbracadabraWS extends EventEmitter {
|
|
106
115
|
private messageQueue: any[] = [];
|
|
107
116
|
|
|
108
|
-
public configuration:
|
|
117
|
+
public configuration: CompleteAbracadabraWSConfiguration = {
|
|
109
118
|
url: "",
|
|
110
119
|
autoConnect: true,
|
|
111
120
|
preserveTrailingSlash: false,
|
|
@@ -144,7 +153,7 @@ export class HocuspocusProviderWebsocket extends EventEmitter {
|
|
|
144
153
|
providerMap: new Map(),
|
|
145
154
|
};
|
|
146
155
|
|
|
147
|
-
webSocket:
|
|
156
|
+
webSocket: AbracadabraWebSocketConn | null = null;
|
|
148
157
|
|
|
149
158
|
webSocketHandlers: { [key: string]: any } = {};
|
|
150
159
|
|
|
@@ -165,7 +174,7 @@ export class HocuspocusProviderWebsocket extends EventEmitter {
|
|
|
165
174
|
reject: (reason?: any) => void;
|
|
166
175
|
} | null = null;
|
|
167
176
|
|
|
168
|
-
constructor(configuration:
|
|
177
|
+
constructor(configuration: AbracadabraWSConfiguration) {
|
|
169
178
|
super();
|
|
170
179
|
this.setConfiguration(configuration);
|
|
171
180
|
|
|
@@ -208,7 +217,7 @@ export class HocuspocusProviderWebsocket extends EventEmitter {
|
|
|
208
217
|
this.receivedOnOpenPayload = event;
|
|
209
218
|
}
|
|
210
219
|
|
|
211
|
-
attach(provider:
|
|
220
|
+
attach(provider: AbracadabraBaseProvider) {
|
|
212
221
|
this.configuration.providerMap.set(provider.configuration.name, provider);
|
|
213
222
|
|
|
214
223
|
if (this.status === WebSocketStatus.Disconnected && this.shouldConnect) {
|
|
@@ -220,7 +229,7 @@ export class HocuspocusProviderWebsocket extends EventEmitter {
|
|
|
220
229
|
}
|
|
221
230
|
}
|
|
222
231
|
|
|
223
|
-
detach(provider:
|
|
232
|
+
detach(provider: AbracadabraBaseProvider) {
|
|
224
233
|
if (this.configuration.providerMap.has(provider.configuration.name)) {
|
|
225
234
|
provider.send(CloseMessage, {
|
|
226
235
|
documentName: provider.configuration.name,
|
|
@@ -230,7 +239,7 @@ export class HocuspocusProviderWebsocket extends EventEmitter {
|
|
|
230
239
|
}
|
|
231
240
|
|
|
232
241
|
public setConfiguration(
|
|
233
|
-
configuration: Partial<
|
|
242
|
+
configuration: Partial<AbracadabraWSConfiguration> = {},
|
|
234
243
|
): void {
|
|
235
244
|
this.configuration = { ...this.configuration, ...configuration };
|
|
236
245
|
|
|
@@ -274,7 +283,7 @@ export class HocuspocusProviderWebsocket extends EventEmitter {
|
|
|
274
283
|
}
|
|
275
284
|
},
|
|
276
285
|
}).catch((error: any) => {
|
|
277
|
-
// If we aborted the connection attempt then don
|
|
286
|
+
// If we aborted the connection attempt then don't throw an error
|
|
278
287
|
// ref: https://github.com/lifeomic/attempt/blob/master/src/index.ts#L136
|
|
279
288
|
if (error && error.code !== "ATTEMPT_ABORTED") {
|
|
280
289
|
throw error;
|
|
@@ -296,7 +305,7 @@ export class HocuspocusProviderWebsocket extends EventEmitter {
|
|
|
296
305
|
}
|
|
297
306
|
|
|
298
307
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
|
299
|
-
attachWebSocketListeners(ws:
|
|
308
|
+
attachWebSocketListeners(ws: AbracadabraWebSocketConn, reject: Function) {
|
|
300
309
|
const { identifier } = ws;
|
|
301
310
|
const onMessageHandler = (payload: any) => this.emit("message", payload);
|
|
302
311
|
const onCloseHandler = (payload: any) => this.emit("close", { event: payload });
|
|
@@ -410,17 +419,17 @@ export class HocuspocusProviderWebsocket extends EventEmitter {
|
|
|
410
419
|
closeTries = 0;
|
|
411
420
|
|
|
412
421
|
checkConnection() {
|
|
413
|
-
// Don
|
|
422
|
+
// Don't check the connection when it's not even established
|
|
414
423
|
if (this.status !== WebSocketStatus.Connected) {
|
|
415
424
|
return;
|
|
416
425
|
}
|
|
417
426
|
|
|
418
|
-
// Don
|
|
427
|
+
// Don't close the connection while waiting for the first message
|
|
419
428
|
if (!this.lastMessageReceived) {
|
|
420
429
|
return;
|
|
421
430
|
}
|
|
422
431
|
|
|
423
|
-
// Don
|
|
432
|
+
// Don't close the connection when a message was received recently
|
|
424
433
|
if (
|
|
425
434
|
this.configuration.messageReconnectTimeout >=
|
|
426
435
|
time.getUnixTime() - this.lastMessageReceived
|
|
@@ -497,7 +506,7 @@ export class HocuspocusProviderWebsocket extends EventEmitter {
|
|
|
497
506
|
this.rejectConnectionAttempt();
|
|
498
507
|
}
|
|
499
508
|
|
|
500
|
-
// Let
|
|
509
|
+
// Let's update the connection status.
|
|
501
510
|
this.status = WebSocketStatus.Disconnected;
|
|
502
511
|
this.emit("status", { status: WebSocketStatus.Disconnected });
|
|
503
512
|
|
|
@@ -535,3 +544,8 @@ export class HocuspocusProviderWebsocket extends EventEmitter {
|
|
|
535
544
|
this.cleanupWebSocket();
|
|
536
545
|
}
|
|
537
546
|
}
|
|
547
|
+
|
|
548
|
+
/** @deprecated Use AbracadabraWS */
|
|
549
|
+
export const HocuspocusProviderWebsocket = AbracadabraWS;
|
|
550
|
+
/** @deprecated Use AbracadabraWS */
|
|
551
|
+
export type HocuspocusProviderWebsocket = AbracadabraWS;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface CloseEvent {
|
|
2
|
+
code: number;
|
|
3
|
+
reason: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The server is terminating the connection because a data frame was received
|
|
8
|
+
* that is too large.
|
|
9
|
+
* See: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code
|
|
10
|
+
*/
|
|
11
|
+
export const MessageTooBig: CloseEvent = {
|
|
12
|
+
code: 1009,
|
|
13
|
+
reason: "Message Too Big",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The server successfully processed the request, asks that the requester reset
|
|
18
|
+
* its document view, and is not returning any content.
|
|
19
|
+
*/
|
|
20
|
+
export const ResetConnection: CloseEvent = {
|
|
21
|
+
code: 4205,
|
|
22
|
+
reason: "Reset Connection",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Similar to Forbidden, but specifically for use when authentication is required and has
|
|
27
|
+
* failed or has not yet been provided.
|
|
28
|
+
*/
|
|
29
|
+
export const Unauthorized: CloseEvent = {
|
|
30
|
+
code: 4401,
|
|
31
|
+
reason: "Unauthorized",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The request contained valid data and was understood by the server, but the server
|
|
36
|
+
* is refusing action.
|
|
37
|
+
*/
|
|
38
|
+
export const Forbidden: CloseEvent = {
|
|
39
|
+
code: 4403,
|
|
40
|
+
reason: "Forbidden",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The server timed out waiting for the request.
|
|
45
|
+
*/
|
|
46
|
+
export const ConnectionTimeout: CloseEvent = {
|
|
47
|
+
code: 4408,
|
|
48
|
+
reason: "Connection Timeout",
|
|
49
|
+
};
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IndexedDB-backed cache for REST API metadata (documents, children, profiles,
|
|
3
|
+
* permissions, uploads). Provides offline-first read access to data fetched
|
|
4
|
+
* from the server.
|
|
5
|
+
*
|
|
6
|
+
* Each entry carries a `cachedAt` timestamp; reads return null once the TTL
|
|
7
|
+
* has elapsed. The cache is scoped by server origin so the same app can work
|
|
8
|
+
* against multiple backends without cross-contamination.
|
|
9
|
+
*
|
|
10
|
+
* Designed to be passed to AbracadabraClientConfig.cache so the client
|
|
11
|
+
* automatically checks the cache before hitting the network.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
DocumentMeta,
|
|
16
|
+
UserProfile,
|
|
17
|
+
PermissionEntry,
|
|
18
|
+
UploadInfo,
|
|
19
|
+
} from "./types.ts";
|
|
20
|
+
|
|
21
|
+
const DB_VERSION = 1;
|
|
22
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
23
|
+
|
|
24
|
+
function idbAvailable(): boolean {
|
|
25
|
+
return typeof globalThis !== "undefined" && "indexedDB" in globalThis;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function openDb(origin: string): Promise<IDBDatabase> {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const req = globalThis.indexedDB.open(
|
|
31
|
+
`abracadabra:meta-cache:${origin}`,
|
|
32
|
+
DB_VERSION,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
req.onupgradeneeded = (event) => {
|
|
36
|
+
const db = (event.target as IDBOpenDBRequest).result;
|
|
37
|
+
for (const name of ["doc_meta", "children", "user_profile", "permissions", "uploads"]) {
|
|
38
|
+
if (!db.objectStoreNames.contains(name)) {
|
|
39
|
+
db.createObjectStore(name);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
req.onsuccess = () => resolve(req.result);
|
|
45
|
+
req.onerror = () => reject(req.error);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function txPromise<T>(store: IDBObjectStore, request: IDBRequest<T>): Promise<T> {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
request.onsuccess = () => resolve(request.result);
|
|
52
|
+
request.onerror = () => reject(request.error);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface DocumentCacheOptions {
|
|
57
|
+
/** How long cached entries remain valid. Default: 5 minutes. */
|
|
58
|
+
ttlMs?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class DocumentCache {
|
|
62
|
+
private readonly origin: string;
|
|
63
|
+
private readonly ttlMs: number;
|
|
64
|
+
private dbPromise: Promise<IDBDatabase | null> | null = null;
|
|
65
|
+
private db: IDBDatabase | null = null;
|
|
66
|
+
|
|
67
|
+
constructor(serverOrigin: string, opts?: DocumentCacheOptions) {
|
|
68
|
+
this.origin = serverOrigin;
|
|
69
|
+
this.ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private getDb(): Promise<IDBDatabase | null> {
|
|
73
|
+
if (!idbAvailable()) return Promise.resolve(null);
|
|
74
|
+
if (!this.dbPromise) {
|
|
75
|
+
this.dbPromise = openDb(this.origin)
|
|
76
|
+
.catch(() => null)
|
|
77
|
+
.then((db) => {
|
|
78
|
+
this.db = db;
|
|
79
|
+
return db;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return this.dbPromise;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private isExpired(cachedAt: number): boolean {
|
|
86
|
+
return Date.now() - cachedAt > this.ttlMs;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Generic TTL helpers ───────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
private async getWithTtl<T>(
|
|
92
|
+
storeName: string,
|
|
93
|
+
key: string,
|
|
94
|
+
): Promise<T | null> {
|
|
95
|
+
const db = await this.getDb();
|
|
96
|
+
if (!db) return null;
|
|
97
|
+
const tx = db.transaction(storeName, "readonly");
|
|
98
|
+
const result = await txPromise<{ value: T; cachedAt: number } | undefined>(
|
|
99
|
+
tx.objectStore(storeName),
|
|
100
|
+
tx.objectStore(storeName).get(key),
|
|
101
|
+
);
|
|
102
|
+
if (!result) return null;
|
|
103
|
+
if (this.isExpired(result.cachedAt)) {
|
|
104
|
+
// Stale — evict asynchronously and signal miss
|
|
105
|
+
this.deleteKey(storeName, key).catch(() => null);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return result.value;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private async setWithTtl<T>(storeName: string, key: string, value: T): Promise<void> {
|
|
112
|
+
const db = await this.getDb();
|
|
113
|
+
if (!db) return;
|
|
114
|
+
const entry = { value, cachedAt: Date.now() };
|
|
115
|
+
const tx = db.transaction(storeName, "readwrite");
|
|
116
|
+
await txPromise(
|
|
117
|
+
tx.objectStore(storeName),
|
|
118
|
+
tx.objectStore(storeName).put(entry, key),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private async deleteKey(storeName: string, key: string): Promise<void> {
|
|
123
|
+
const db = await this.getDb();
|
|
124
|
+
if (!db) return;
|
|
125
|
+
const tx = db.transaction(storeName, "readwrite");
|
|
126
|
+
await txPromise(tx.objectStore(storeName), tx.objectStore(storeName).delete(key));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Document metadata ─────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
async getDoc(docId: string): Promise<DocumentMeta | null> {
|
|
132
|
+
return this.getWithTtl<DocumentMeta>("doc_meta", docId);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async setDoc(meta: DocumentMeta): Promise<void> {
|
|
136
|
+
return this.setWithTtl("doc_meta", meta.id, meta);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async invalidateDoc(docId: string): Promise<void> {
|
|
140
|
+
await this.deleteKey("doc_meta", docId).catch(() => null);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Children list ─────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
async getChildren(parentId: string): Promise<string[] | null> {
|
|
146
|
+
return this.getWithTtl<string[]>("children", parentId);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async setChildren(parentId: string, items: string[]): Promise<void> {
|
|
150
|
+
return this.setWithTtl("children", parentId, items);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async invalidateChildren(parentId: string): Promise<void> {
|
|
154
|
+
await this.deleteKey("children", parentId).catch(() => null);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── User profile ──────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
async getProfile(userId: string): Promise<UserProfile | null> {
|
|
160
|
+
return this.getWithTtl<UserProfile>("user_profile", userId);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async setProfile(profile: UserProfile): Promise<void> {
|
|
164
|
+
return this.setWithTtl("user_profile", profile.id, profile);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Get the cached profile for the currently authenticated user. */
|
|
168
|
+
async getCurrentProfile(): Promise<UserProfile | null> {
|
|
169
|
+
return this.getWithTtl<UserProfile>("user_profile", "__current__");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Cache a profile both by its ID and as the current user. */
|
|
173
|
+
async setCurrentProfile(profile: UserProfile): Promise<void> {
|
|
174
|
+
await Promise.all([
|
|
175
|
+
this.setWithTtl("user_profile", profile.id, profile),
|
|
176
|
+
this.setWithTtl("user_profile", "__current__", profile),
|
|
177
|
+
]);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Permissions ───────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
async getPermissions(docId: string): Promise<PermissionEntry[] | null> {
|
|
183
|
+
return this.getWithTtl<PermissionEntry[]>("permissions", docId);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async setPermissions(docId: string, items: PermissionEntry[]): Promise<void> {
|
|
187
|
+
return this.setWithTtl("permissions", docId, items);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Uploads list ──────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
async getUploads(docId: string): Promise<UploadInfo[] | null> {
|
|
193
|
+
return this.getWithTtl<UploadInfo[]>("uploads", docId);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async setUploads(docId: string, items: UploadInfo[]): Promise<void> {
|
|
197
|
+
return this.setWithTtl("uploads", docId, items);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async invalidateUploads(docId: string): Promise<void> {
|
|
201
|
+
await this.deleteKey("uploads", docId).catch(() => null);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
destroy(): void {
|
|
207
|
+
this.db?.close();
|
|
208
|
+
this.db = null;
|
|
209
|
+
}
|
|
210
|
+
}
|