@abraca/dabra 0.1.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/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@abraca/dabra",
3
+ "version": "0.1.0",
4
+ "description": "abracadabra provider",
5
+ "keywords": [
6
+ "abracadabra",
7
+ "websocket",
8
+ "provider",
9
+ "yjs"
10
+ ],
11
+ "license": "MIT",
12
+ "type": "module",
13
+ "main": "dist/abracadabra-provider.cjs",
14
+ "module": "dist/abracadabra-provider.esm.js",
15
+ "types": "dist/index.d.ts",
16
+ "exports": {
17
+ "source": {
18
+ "import": "./src/index.ts"
19
+ },
20
+ "default": {
21
+ "import": "./dist/abracadabra-provider.esm.js",
22
+ "require": "./dist/abracadabra-provider.cjs",
23
+ "types": "./dist/index.d.ts"
24
+ }
25
+ },
26
+ "files": [
27
+ "src",
28
+ "dist"
29
+ ],
30
+ "dependencies": {
31
+ "@abraca/dabra-common": "workspace:^",
32
+ "@lifeomic/attempt": "^3.0.2",
33
+ "@noble/ed25519": "^2.1.0",
34
+ "@noble/hashes": "^1.4.0",
35
+ "lib0": "^0.2.87",
36
+ "ws": "^8.17.1"
37
+ },
38
+ "peerDependencies": {
39
+ "y-protocols": "^1.0.6",
40
+ "yjs": "^13.6.8"
41
+ }
42
+ }
@@ -0,0 +1,381 @@
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";
5
+ import { OfflineStore } from "./OfflineStore.ts";
6
+ import { SubdocMessage } from "./OutgoingMessages/SubdocMessage.ts";
7
+ import { UpdateMessage } from "./OutgoingMessages/UpdateMessage.ts";
8
+ import type {
9
+ CryptoIdentity,
10
+ EffectiveRole,
11
+ onSubdocRegisteredParameters,
12
+ onSubdocLoadedParameters,
13
+ } from "./types.ts";
14
+ import { AuthenticationMessage } from "./OutgoingMessages/AuthenticationMessage.ts";
15
+
16
+ export interface AbracadabraProviderConfiguration
17
+ extends HocuspocusProviderConfiguration {
18
+ /**
19
+ * Subdocument loading strategy.
20
+ * - "lazy" (default) – child providers are created only when explicitly requested.
21
+ * - "eager" – all children returned by the server on connect are loaded automatically.
22
+ */
23
+ subdocLoading?: "lazy" | "eager";
24
+
25
+ /** Called when the server confirms a subdoc registration. */
26
+ onSubdocRegistered?: (data: onSubdocRegisteredParameters) => void;
27
+
28
+ /** Called when a child AbracadabraProvider has been created and attached. */
29
+ onSubdocLoaded?: (data: onSubdocLoadedParameters) => void;
30
+
31
+ /**
32
+ * Disable the IndexedDB offline store. Useful for server-side rendering
33
+ * or testing environments that lack IndexedDB.
34
+ */
35
+ disableOfflineStore?: boolean;
36
+
37
+ /**
38
+ * Identity for passwordless crypto auth (Model B multi-key).
39
+ * Mutually exclusive with token.
40
+ */
41
+ cryptoIdentity?: CryptoIdentity | (() => Promise<CryptoIdentity>);
42
+
43
+ /**
44
+ * Signs a base64url challenge and returns a base64url signature.
45
+ * Required when cryptoIdentity is set.
46
+ */
47
+ signChallenge?: (challenge: string) => Promise<string>;
48
+ }
49
+
50
+ /**
51
+ * AbracadabraProvider extends HocuspocusProvider with:
52
+ *
53
+ * 1. Subdocument lifecycle – intercepts Y.Doc subdoc events and syncs them
54
+ * with the server via MSG_SUBDOC (4) frames. Child documents get their
55
+ * own AbracadabraProvider instances sharing the same WebSocket connection.
56
+ *
57
+ * 2. Offline-first – persists CRDT updates to IndexedDB so they survive
58
+ * page reloads and network outages. On reconnect, pending updates are
59
+ * flushed before resuming normal sync.
60
+ *
61
+ * 3. Permission snapshotting – stores the resolved role locally so the UI
62
+ * can gate write operations without a network round-trip. Role is
63
+ * refreshed from the server on every reconnect.
64
+ */
65
+ export class AbracadabraProvider extends HocuspocusProvider {
66
+ public effectiveRole: EffectiveRole = null;
67
+
68
+ private offlineStore: OfflineStore | null;
69
+ private childProviders = new Map<string, AbracadabraProvider>();
70
+ private subdocLoading: "lazy" | "eager";
71
+
72
+ private abracadabraConfig: AbracadabraProviderConfiguration;
73
+
74
+ private readonly boundHandleYSubdocsChange = this.handleYSubdocsChange.bind(this);
75
+
76
+ constructor(configuration: AbracadabraProviderConfiguration) {
77
+ super(configuration);
78
+ this.abracadabraConfig = configuration;
79
+ this.subdocLoading = configuration.subdocLoading ?? "lazy";
80
+
81
+ this.offlineStore = configuration.disableOfflineStore
82
+ ? null
83
+ : new OfflineStore(configuration.name);
84
+
85
+ this.on(
86
+ "subdocRegistered",
87
+ configuration.onSubdocRegistered ?? (() => null),
88
+ );
89
+ this.on("subdocLoaded", configuration.onSubdocLoaded ?? (() => null));
90
+
91
+ // Intercept Y.Doc subdoc lifecycle events.
92
+ this.document.on("subdocs", this.boundHandleYSubdocsChange);
93
+
94
+ // Flush offline updates once we're back online and synced.
95
+ this.on("synced", () => this.flushPendingUpdates());
96
+
97
+ // Restore permission snapshot while offline.
98
+ this.restorePermissionSnapshot();
99
+ }
100
+
101
+ // ── Auth / permission snapshot ────────────────────────────────────────────
102
+
103
+ override authenticatedHandler(scope: string) {
104
+ super.authenticatedHandler(scope);
105
+
106
+ this.effectiveRole =
107
+ scope === "read-write" ? "editor" : "viewer";
108
+
109
+ this.offlineStore?.savePermissionSnapshot(this.effectiveRole);
110
+ }
111
+
112
+ /**
113
+ * Override sendToken to send an identity declaration instead of a JWT
114
+ * when cryptoIdentity is configured.
115
+ */
116
+ override async sendToken() {
117
+ const { cryptoIdentity } = this.abracadabraConfig;
118
+ if (cryptoIdentity) {
119
+ const id =
120
+ typeof cryptoIdentity === "function"
121
+ ? await cryptoIdentity()
122
+ : cryptoIdentity;
123
+ const json = JSON.stringify({
124
+ type: "identity",
125
+ username: id.username,
126
+ publicKey: id.publicKey,
127
+ });
128
+ this.send(AuthenticationMessage, {
129
+ token: json,
130
+ documentName: this.configuration.name,
131
+ });
132
+ } else {
133
+ await super.sendToken();
134
+ }
135
+ }
136
+
137
+ /** Handle an auth_challenge message from the server. */
138
+ private async handleAuthChallenge(
139
+ challenge: string,
140
+ expiresAt: number,
141
+ ): Promise<void> {
142
+ const { signChallenge, cryptoIdentity } = this.abracadabraConfig;
143
+ if (!signChallenge || !cryptoIdentity) {
144
+ this.permissionDeniedHandler("No signChallenge callback configured");
145
+ return;
146
+ }
147
+ if (Date.now() > expiresAt * 1000) {
148
+ this.permissionDeniedHandler("Challenge expired");
149
+ return;
150
+ }
151
+ const id =
152
+ typeof cryptoIdentity === "function"
153
+ ? await cryptoIdentity()
154
+ : cryptoIdentity;
155
+ const signature = await signChallenge(challenge);
156
+ const proof = JSON.stringify({
157
+ type: "proof",
158
+ username: id.username,
159
+ publicKey: id.publicKey,
160
+ signature,
161
+ challenge,
162
+ });
163
+ this.send(AuthenticationMessage, {
164
+ token: proof,
165
+ documentName: this.configuration.name,
166
+ });
167
+ }
168
+
169
+ private async restorePermissionSnapshot() {
170
+ if (!this.offlineStore) return;
171
+ const role = await this.offlineStore.getPermissionSnapshot();
172
+ if (role && !this.effectiveRole) {
173
+ this.effectiveRole = role as EffectiveRole;
174
+ }
175
+ }
176
+
177
+ get canWrite(): boolean {
178
+ return this.effectiveRole === "owner" || this.effectiveRole === "editor";
179
+ }
180
+
181
+ // ── Stateless message interception ────────────────────────────────────────
182
+
183
+ /**
184
+ * Called when a MSG_STATELESS frame arrives from the server.
185
+ * Abracadabra uses stateless frames to deliver subdoc confirmations
186
+ * ({ type: "subdoc_registered", child_id, parent_id }).
187
+ */
188
+ override receiveStateless(payload: string) {
189
+ let parsed: unknown;
190
+ try {
191
+ parsed = JSON.parse(payload);
192
+ } catch {
193
+ super.receiveStateless(payload);
194
+ return;
195
+ }
196
+
197
+ const msg = parsed as {
198
+ type?: string;
199
+ child_id?: string;
200
+ parent_id?: string;
201
+ challenge?: string;
202
+ expiresAt?: number;
203
+ };
204
+
205
+ // Intercept auth_challenge before subdoc handling.
206
+ if (msg.type === "auth_challenge" && msg.challenge && msg.expiresAt) {
207
+ this.handleAuthChallenge(msg.challenge, msg.expiresAt).catch(() => null);
208
+ return;
209
+ }
210
+
211
+ if (msg.type === "subdoc_registered" && msg.child_id && msg.parent_id) {
212
+ const event: onSubdocRegisteredParameters = {
213
+ childId: msg.child_id,
214
+ parentId: msg.parent_id,
215
+ };
216
+ this.emit("subdocRegistered", event);
217
+
218
+ // Remove from the offline queue now the server confirmed.
219
+ this.offlineStore?.removeSubdocFromQueue(msg.child_id);
220
+
221
+ // Eager mode: auto-load the confirmed child.
222
+ if (this.subdocLoading === "eager") {
223
+ this.loadChild(msg.child_id);
224
+ }
225
+ return;
226
+ }
227
+
228
+ // Unknown stateless payload – pass through to base class.
229
+ super.receiveStateless(payload);
230
+ }
231
+
232
+ // ── Subdocument support ───────────────────────────────────────────────────
233
+
234
+ /**
235
+ * Y.Doc emits 'subdocs' whenever subdocuments are added, removed, or loaded.
236
+ * We intercept additions to register them with the Abracadabra server.
237
+ */
238
+ private handleYSubdocsChange({
239
+ added,
240
+ removed,
241
+ }: {
242
+ added: Set<Y.Doc>;
243
+ removed: Set<Y.Doc>;
244
+ loaded: Set<Y.Doc>;
245
+ }) {
246
+ for (const subdoc of added) {
247
+ this.registerSubdoc(subdoc);
248
+ }
249
+ for (const subdoc of removed) {
250
+ this.unloadChild(subdoc.guid);
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Send a subdoc registration frame to the server.
256
+ * If offline the event is queued in IndexedDB and replayed on reconnect.
257
+ */
258
+ private registerSubdoc(subdoc: Y.Doc) {
259
+ const childId = subdoc.guid;
260
+
261
+ if (this.isConnected) {
262
+ this.send(SubdocMessage, {
263
+ documentName: this.configuration.name,
264
+ childDocumentName: childId,
265
+ } as any);
266
+ } else {
267
+ // Queue for later replay when we reconnect.
268
+ this.offlineStore?.queueSubdoc({
269
+ childId,
270
+ parentId: this.configuration.name,
271
+ createdAt: Date.now(),
272
+ });
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Create (or return cached) a child AbracadabraProvider for a given
278
+ * child document id. The child shares the parent's WebSocket connection.
279
+ */
280
+ async loadChild(childId: string): Promise<AbracadabraProvider> {
281
+ if (this.childProviders.has(childId)) {
282
+ return this.childProviders.get(childId)!;
283
+ }
284
+
285
+ const childDoc = new Y.Doc({ guid: childId });
286
+
287
+ const parentWsp = this.configuration.websocketProvider as HocuspocusProviderWebsocket;
288
+ const childProvider = new AbracadabraProvider({
289
+ name: childId,
290
+ document: childDoc,
291
+ url: parentWsp.configuration.url,
292
+ WebSocketPolyfill: parentWsp.configuration.WebSocketPolyfill,
293
+ token: this.configuration.token,
294
+ subdocLoading: this.subdocLoading,
295
+ disableOfflineStore: this.abracadabraConfig.disableOfflineStore,
296
+ });
297
+ this.childProviders.set(childId, childProvider);
298
+
299
+ this.emit("subdocLoaded", { childId, provider: childProvider });
300
+
301
+ return childProvider;
302
+ }
303
+
304
+ private unloadChild(childId: string) {
305
+ const provider = this.childProviders.get(childId);
306
+ if (provider) {
307
+ provider.destroy();
308
+ this.childProviders.delete(childId);
309
+ }
310
+ }
311
+
312
+ /** Return all currently-loaded child providers. */
313
+ get children(): Map<string, AbracadabraProvider> {
314
+ return this.childProviders;
315
+ }
316
+
317
+ // ── Offline-first update persistence ─────────────────────────────────────
318
+
319
+ /**
320
+ * Override to persist every local update to IndexedDB before sending it
321
+ * over the wire, ensuring no work is lost during connection outages.
322
+ */
323
+ override documentUpdateHandler(update: Uint8Array, origin: unknown) {
324
+ if (origin === this) return;
325
+
326
+ // Persist locally first (fire-and-forget; errors are non-fatal).
327
+ this.offlineStore?.persistUpdate(update).catch(() => null);
328
+
329
+ super.documentUpdateHandler(update, origin);
330
+ }
331
+
332
+ /**
333
+ * After reconnect + sync, flush any updates that were generated while
334
+ * offline, then flush any queued subdoc registrations.
335
+ */
336
+ private async flushPendingUpdates() {
337
+ if (!this.offlineStore) return;
338
+
339
+ const updates = await this.offlineStore.getPendingUpdates();
340
+ if (updates.length > 0) {
341
+ for (const update of updates) {
342
+ this.send(UpdateMessage, {
343
+ update,
344
+ documentName: this.configuration.name,
345
+ });
346
+ }
347
+ await this.offlineStore.clearPendingUpdates();
348
+ }
349
+
350
+ const pendingSubdocs = await this.offlineStore.getPendingSubdocs();
351
+ for (const { childId } of pendingSubdocs) {
352
+ this.send(SubdocMessage, {
353
+ documentName: this.configuration.name,
354
+ childDocumentName: childId,
355
+ } as any);
356
+ }
357
+ }
358
+
359
+ get isConnected(): boolean {
360
+ return (
361
+ (this.configuration.websocketProvider as HocuspocusProviderWebsocket)
362
+ .status === "connected"
363
+ );
364
+ }
365
+
366
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
367
+
368
+ override destroy() {
369
+ this.document.off("subdocs", this.boundHandleYSubdocsChange);
370
+
371
+ for (const provider of this.childProviders.values()) {
372
+ provider.destroy();
373
+ }
374
+ this.childProviders.clear();
375
+
376
+ this.offlineStore?.destroy();
377
+ this.offlineStore = null;
378
+
379
+ super.destroy();
380
+ }
381
+ }