@abraca/dabra 0.6.0 → 0.8.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.
@@ -6,7 +6,10 @@ import type {
6
6
  PublicKeyInfo,
7
7
  PermissionEntry,
8
8
  HealthStatus,
9
+ ServerInfo,
10
+ InviteRow,
9
11
  } from "./types.ts";
12
+ import type { DocumentCache } from "./DocumentCache.ts";
10
13
 
11
14
  export interface AbracadabraClientConfig {
12
15
  /** Server base URL (http or https). WebSocket URL is derived automatically. */
@@ -19,6 +22,13 @@ export interface AbracadabraClientConfig {
19
22
  storageKey?: string;
20
23
  /** Custom fetch implementation (useful for Node.js or testing). */
21
24
  fetch?: typeof globalThis.fetch;
25
+ /**
26
+ * Optional metadata cache. When provided, read methods (getDoc, listChildren,
27
+ * getMe, listPermissions, listUploads) check the cache before hitting the
28
+ * network. Write methods (deleteDoc, upload, deleteUpload) invalidate affected
29
+ * cache entries automatically.
30
+ */
31
+ cache?: DocumentCache;
22
32
  }
23
33
 
24
34
  export class AbracadabraClient {
@@ -27,12 +37,14 @@ export class AbracadabraClient {
27
37
  private readonly persistAuth: boolean;
28
38
  private readonly storageKey: string;
29
39
  private readonly _fetch: typeof globalThis.fetch;
40
+ readonly cache: DocumentCache | null;
30
41
 
31
42
  constructor(config: AbracadabraClientConfig) {
32
43
  this.baseUrl = config.url.replace(/\/+$/, "");
33
44
  this.persistAuth = config.persistAuth ?? typeof localStorage !== "undefined";
34
45
  this.storageKey = config.storageKey ?? "abracadabra:auth";
35
46
  this._fetch = config.fetch ?? globalThis.fetch.bind(globalThis);
47
+ this.cache = config.cache ?? null;
36
48
 
37
49
  // Load token: explicit > persisted > null
38
50
  this._token = config.token ?? this.loadPersistedToken() ?? null;
@@ -74,6 +86,7 @@ export class AbracadabraClient {
74
86
  password: string;
75
87
  email?: string;
76
88
  displayName?: string;
89
+ inviteCode?: string;
77
90
  }): Promise<UserProfile> {
78
91
  return this.request<UserProfile>("POST", "/auth/register", {
79
92
  body: opts,
@@ -91,6 +104,7 @@ export class AbracadabraClient {
91
104
  deviceName?: string;
92
105
  displayName?: string;
93
106
  email?: string;
107
+ inviteCode?: string;
94
108
  }): Promise<UserProfile> {
95
109
  const username = opts.username ?? `user-${opts.publicKey.slice(0, 8)}`;
96
110
  return this.request<UserProfile>("POST", "/auth/register", {
@@ -100,6 +114,7 @@ export class AbracadabraClient {
100
114
  deviceName: opts.deviceName,
101
115
  displayName: opts.displayName,
102
116
  email: opts.email,
117
+ inviteCode: opts.inviteCode,
103
118
  },
104
119
  auth: false,
105
120
  });
@@ -175,7 +190,15 @@ export class AbracadabraClient {
175
190
 
176
191
  /** Get the current user's profile. */
177
192
  async getMe(): Promise<UserProfile> {
178
- return this.request<UserProfile>("GET", "/users/me");
193
+ if (this.cache) {
194
+ const cached = await this.cache.getCurrentProfile();
195
+ if (cached) return cached;
196
+ }
197
+ const profile = await this.request<UserProfile>("GET", "/users/me");
198
+ if (this.cache) {
199
+ await this.cache.setCurrentProfile(profile).catch(() => null);
200
+ }
201
+ return profile;
179
202
  }
180
203
 
181
204
  /** Update the current user's display name. */
@@ -192,20 +215,38 @@ export class AbracadabraClient {
192
215
 
193
216
  /** Get document metadata. */
194
217
  async getDoc(docId: string): Promise<DocumentMeta> {
195
- return this.request<DocumentMeta>("GET", `/docs/${encodeURIComponent(docId)}`);
218
+ if (this.cache) {
219
+ const cached = await this.cache.getDoc(docId);
220
+ if (cached) return cached;
221
+ }
222
+ const meta = await this.request<DocumentMeta>("GET", `/docs/${encodeURIComponent(docId)}`);
223
+ if (this.cache) {
224
+ await this.cache.setDoc(meta).catch(() => null);
225
+ }
226
+ return meta;
196
227
  }
197
228
 
198
229
  /** Delete a document (requires Owner role). Cascades to children and uploads. */
199
230
  async deleteDoc(docId: string): Promise<void> {
200
231
  await this.request("DELETE", `/docs/${encodeURIComponent(docId)}`);
232
+ if (this.cache) {
233
+ await this.cache.invalidateDoc(docId).catch(() => null);
234
+ }
201
235
  }
202
236
 
203
237
  /** List immediate child documents. */
204
238
  async listChildren(docId: string): Promise<string[]> {
239
+ if (this.cache) {
240
+ const cached = await this.cache.getChildren(docId);
241
+ if (cached) return cached;
242
+ }
205
243
  const res = await this.request<{ children: string[] }>(
206
244
  "GET",
207
245
  `/docs/${encodeURIComponent(docId)}/children`,
208
246
  );
247
+ if (this.cache) {
248
+ await this.cache.setChildren(docId, res.children).catch(() => null);
249
+ }
209
250
  return res.children;
210
251
  }
211
252
 
@@ -222,10 +263,17 @@ export class AbracadabraClient {
222
263
 
223
264
  /** List all permissions for a document (requires read access). */
224
265
  async listPermissions(docId: string): Promise<PermissionEntry[]> {
266
+ if (this.cache) {
267
+ const cached = await this.cache.getPermissions(docId);
268
+ if (cached) return cached;
269
+ }
225
270
  const res = await this.request<{ permissions: PermissionEntry[] }>(
226
271
  "GET",
227
272
  `/docs/${encodeURIComponent(docId)}/permissions`,
228
273
  );
274
+ if (this.cache) {
275
+ await this.cache.setPermissions(docId, res.permissions).catch(() => null);
276
+ }
229
277
  return res.permissions;
230
278
  }
231
279
 
@@ -273,15 +321,26 @@ export class AbracadabraClient {
273
321
  if (!res.ok) {
274
322
  throw await this.toError(res);
275
323
  }
276
- return res.json() as Promise<UploadMeta>;
324
+ const meta = await res.json() as UploadMeta;
325
+ if (this.cache) {
326
+ await this.cache.invalidateUploads(docId).catch(() => null);
327
+ }
328
+ return meta;
277
329
  }
278
330
 
279
331
  /** List all uploads for a document. */
280
332
  async listUploads(docId: string): Promise<UploadInfo[]> {
333
+ if (this.cache) {
334
+ const cached = await this.cache.getUploads(docId);
335
+ if (cached) return cached;
336
+ }
281
337
  const res = await this.request<{ uploads: UploadInfo[] }>(
282
338
  "GET",
283
339
  `/docs/${encodeURIComponent(docId)}/uploads`,
284
340
  );
341
+ if (this.cache) {
342
+ await this.cache.setUploads(docId, res.uploads).catch(() => null);
343
+ }
285
344
  return res.uploads;
286
345
  }
287
346
 
@@ -308,6 +367,32 @@ export class AbracadabraClient {
308
367
  "DELETE",
309
368
  `/docs/${encodeURIComponent(docId)}/uploads/${encodeURIComponent(uploadId)}`,
310
369
  );
370
+ if (this.cache) {
371
+ await this.cache.invalidateUploads(docId).catch(() => null);
372
+ }
373
+ }
374
+
375
+ // ── Invites ──────────────────────────────────────────────────────────────
376
+
377
+ /** Create an invite code (requires permission per server config). */
378
+ async createInvite(opts?: { role?: string; maxUses?: number; expiresIn?: number }): Promise<InviteRow> {
379
+ return this.request<InviteRow>("POST", "/invites", { body: opts ?? {} });
380
+ }
381
+
382
+ /** List invite codes visible to the current user. */
383
+ async listInvites(): Promise<InviteRow[]> {
384
+ const res = await this.request<{ invites: InviteRow[] }>("GET", "/invites");
385
+ return res.invites;
386
+ }
387
+
388
+ /** Revoke an invite by its code. */
389
+ async revokeInvite(code: string): Promise<void> {
390
+ await this.request("DELETE", `/invites/${encodeURIComponent(code)}`);
391
+ }
392
+
393
+ /** Redeem an invite code for the currently authenticated user. */
394
+ async redeemInvite(code: string): Promise<void> {
395
+ await this.request("POST", "/invites/redeem", { body: { code } });
311
396
  }
312
397
 
313
398
  // ── System ───────────────────────────────────────────────────────────────
@@ -317,6 +402,14 @@ export class AbracadabraClient {
317
402
  return this.request<HealthStatus>("GET", "/health", { auth: false });
318
403
  }
319
404
 
405
+ /**
406
+ * Fetch server metadata including the optional `index_doc_id` entry point.
407
+ * No auth required.
408
+ */
409
+ async serverInfo(): Promise<ServerInfo> {
410
+ return this.request<ServerInfo>("GET", "/info", { auth: false });
411
+ }
412
+
320
413
  // ── Internals ────────────────────────────────────────────────────────────
321
414
 
322
415
  private async request<T = void>(
@@ -1,7 +1,7 @@
1
1
  import * as Y from "yjs";
2
- import { HocuspocusProvider } from "./HocuspocusProvider.ts";
3
- import type { HocuspocusProviderConfiguration } from "./HocuspocusProvider.ts";
4
- import type { HocuspocusProviderWebsocket } from "./HocuspocusProviderWebsocket.ts";
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<HocuspocusProviderConfiguration, "url" | "websocketProvider"> {
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?: HocuspocusProviderWebsocket;
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 HocuspocusProvider with:
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 HocuspocusProvider {
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 HocuspocusProviderConfiguration;
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 HocuspocusProviderWebsocket | undefined)?.url ??
168
+ (config.websocketProvider as AbracadabraWS | undefined)?.url ??
169
169
  client?.wsUrl;
170
170
  if (url) return new URL(url).hostname;
171
171
  } catch {
@@ -429,7 +429,7 @@ export class AbracadabraProvider extends HocuspocusProvider {
429
429
  this.registerSubdoc(childDoc);
430
430
 
431
431
  // Each child gets its own WebSocket connection. Omitting
432
- // websocketProvider lets HocuspocusProvider create one automatically
432
+ // websocketProvider lets AbracadabraBaseProvider create one automatically
433
433
  // (manageSocket = true), so we do NOT call attach() manually.
434
434
  const childProvider = new AbracadabraProvider({
435
435
  name: childId,
@@ -527,7 +527,7 @@ export class AbracadabraProvider extends HocuspocusProvider {
527
527
 
528
528
  get isConnected(): boolean {
529
529
  return (
530
- (this.configuration.websocketProvider as HocuspocusProviderWebsocket)
530
+ (this.configuration.websocketProvider as AbracadabraWS)
531
531
  .status === "connected"
532
532
  );
533
533
  }
@@ -1,9 +1,9 @@
1
- import { WsReadyStates } from "@abraca/dabra-common";
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 { HocuspocusProvider } from "./HocuspocusProvider.ts";
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 HocuspocusWebSocket = WebSocket & { identifier: string };
22
- export type HocusPocusWebSocket = HocuspocusWebSocket;
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 HocuspocusProviderWebsocketConfiguration = Required<
25
- Pick<CompleteHocuspocusProviderWebsocketConfiguration, "url">
27
+ export type AbracadabraWSConfiguration = Required<
28
+ Pick<CompleteAbracadabraWSConfiguration, "url">
26
29
  > &
27
- Partial<CompleteHocuspocusProviderWebsocketConfiguration>;
30
+ Partial<CompleteAbracadabraWSConfiguration>;
28
31
 
29
- export interface CompleteHocuspocusProviderWebsocketConfiguration {
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, HocuspocusProvider>;
108
+ providerMap: Map<string, AbracadabraBaseProvider>;
103
109
  }
104
110
 
105
- export class HocuspocusProviderWebsocket extends EventEmitter {
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: CompleteHocuspocusProviderWebsocketConfiguration = {
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: HocusPocusWebSocket | null = null;
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: HocuspocusProviderWebsocketConfiguration) {
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: HocuspocusProvider) {
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: HocuspocusProvider) {
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<HocuspocusProviderWebsocketConfiguration> = {},
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 dont throw an error
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: HocusPocusWebSocket, reject: Function) {
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
- // Dont check the connection when its not even established
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
- // Dont close the connection while waiting for the first message
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
- // Dont close the connection when a message was received recently
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
- // Lets update the connection status.
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
+ };