@rgby/collab-core 1.0.1

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.
@@ -0,0 +1,1389 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { HocuspocusProvider, HocuspocusProviderWebsocket } from "@hocuspocus/provider";
10
+ import * as Yjs from "yjs";
11
+ import { IndexeddbPersistence } from "y-indexeddb";
12
+ import { UploadQueueManager } from "./upload-queue.js";
13
+ // Re-export Yjs as Y for compatibility
14
+ export const Y = Yjs;
15
+ export class LocalStorageAdapter {
16
+ getItem(key) {
17
+ try {
18
+ return localStorage.getItem(key);
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ setItem(key, value) {
25
+ try {
26
+ localStorage.setItem(key, value);
27
+ }
28
+ catch {
29
+ // Ignore errors (quota exceeded, etc.)
30
+ }
31
+ }
32
+ removeItem(key) {
33
+ try {
34
+ localStorage.removeItem(key);
35
+ }
36
+ catch {
37
+ // Ignore errors
38
+ }
39
+ }
40
+ }
41
+ export class SessionStorageAdapter {
42
+ getItem(key) {
43
+ try {
44
+ return sessionStorage.getItem(key);
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ setItem(key, value) {
51
+ try {
52
+ sessionStorage.setItem(key, value);
53
+ }
54
+ catch {
55
+ // Ignore errors
56
+ }
57
+ }
58
+ removeItem(key) {
59
+ try {
60
+ sessionStorage.removeItem(key);
61
+ }
62
+ catch {
63
+ // Ignore errors
64
+ }
65
+ }
66
+ }
67
+ export class MemoryStorageAdapter {
68
+ store = new Map();
69
+ getItem(key) {
70
+ return this.store.get(key) ?? null;
71
+ }
72
+ setItem(key, value) {
73
+ this.store.set(key, value);
74
+ }
75
+ removeItem(key) {
76
+ this.store.delete(key);
77
+ }
78
+ }
79
+ export class AuthManager {
80
+ baseUrl;
81
+ storage;
82
+ storageKey;
83
+ autoRefresh;
84
+ refreshBuffer;
85
+ session = null;
86
+ subscribers = new Set();
87
+ refreshTimer = null;
88
+ constructor(options) {
89
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
90
+ this.storage = options.storage ?? new LocalStorageAdapter();
91
+ this.storageKey = options.storageKey ?? 'collab_session';
92
+ this.autoRefresh = options.autoRefresh ?? true;
93
+ this.refreshBuffer = options.refreshBuffer ?? 3600; // 1 hour default
94
+ // Restore session from storage
95
+ this.restoreSession();
96
+ }
97
+ async restoreSession() {
98
+ try {
99
+ const stored = await this.storage.getItem(this.storageKey);
100
+ if (stored) {
101
+ const session = JSON.parse(stored);
102
+ // Check if session is still valid
103
+ const now = Math.floor(Date.now() / 1000);
104
+ if (session.expiresAt > now) {
105
+ this.session = session;
106
+ this.scheduleRefresh();
107
+ this.notifySubscribers();
108
+ }
109
+ else {
110
+ // Session expired, clear it
111
+ await this.storage.removeItem(this.storageKey);
112
+ }
113
+ }
114
+ }
115
+ catch (e) {
116
+ console.error('[AuthManager] Failed to restore session:', e);
117
+ }
118
+ }
119
+ async persistSession(session) {
120
+ try {
121
+ if (session) {
122
+ await this.storage.setItem(this.storageKey, JSON.stringify(session));
123
+ }
124
+ else {
125
+ await this.storage.removeItem(this.storageKey);
126
+ }
127
+ }
128
+ catch (e) {
129
+ console.error('[AuthManager] Failed to persist session:', e);
130
+ }
131
+ }
132
+ scheduleRefresh() {
133
+ if (!this.autoRefresh || !this.session)
134
+ return;
135
+ // Clear existing timer
136
+ if (this.refreshTimer) {
137
+ clearTimeout(this.refreshTimer);
138
+ }
139
+ const now = Math.floor(Date.now() / 1000);
140
+ const timeUntilRefresh = (this.session.expiresAt - this.refreshBuffer) - now;
141
+ if (timeUntilRefresh > 0) {
142
+ this.refreshTimer = setTimeout(() => {
143
+ this.refreshSession();
144
+ }, timeUntilRefresh * 1000);
145
+ }
146
+ }
147
+ async refreshSession() {
148
+ if (!this.session)
149
+ return;
150
+ try {
151
+ const res = await fetch(`${this.baseUrl}/api/auth/session`, {
152
+ headers: {
153
+ 'Authorization': `Bearer ${this.session.token}`
154
+ }
155
+ });
156
+ if (res.ok) {
157
+ const data = await res.json();
158
+ if (data.session?.token && data.user) {
159
+ const newSession = {
160
+ token: data.session.token,
161
+ user: {
162
+ id: data.user.id,
163
+ name: data.user.name,
164
+ email: data.user.email,
165
+ image: data.user.image
166
+ },
167
+ expiresAt: data.session.expiresAt
168
+ };
169
+ this.session = newSession;
170
+ await this.persistSession(newSession);
171
+ this.scheduleRefresh();
172
+ this.notifySubscribers();
173
+ }
174
+ }
175
+ }
176
+ catch (e) {
177
+ console.error('[AuthManager] Failed to refresh session:', e);
178
+ }
179
+ }
180
+ notifySubscribers() {
181
+ this.subscribers.forEach(callback => callback(this.session));
182
+ }
183
+ async login(email, password) {
184
+ const res = await fetch(`${this.baseUrl}/api/auth/sign-in/email`, {
185
+ method: 'POST',
186
+ headers: {
187
+ 'Content-Type': 'application/json',
188
+ 'Origin': this.baseUrl
189
+ },
190
+ body: JSON.stringify({ email, password })
191
+ });
192
+ if (!res.ok) {
193
+ const error = await res.json().catch(() => ({ message: res.statusText }));
194
+ throw new Error(error.message || 'Login failed');
195
+ }
196
+ const data = await res.json();
197
+ // Better Auth can return token in different formats
198
+ const token = data.token || data.session?.token;
199
+ const expiresAt = data.session?.expiresAt || data.expiresAt;
200
+ if (!token || !data.user) {
201
+ throw new Error('Invalid response from server');
202
+ }
203
+ const session = {
204
+ token,
205
+ user: {
206
+ id: data.user.id,
207
+ name: data.user.name,
208
+ email: data.user.email,
209
+ image: data.user.image
210
+ },
211
+ expiresAt: expiresAt || (Math.floor(Date.now() / 1000) + 7200) // 2 hours default
212
+ };
213
+ this.session = session;
214
+ await this.persistSession(session);
215
+ this.scheduleRefresh();
216
+ this.notifySubscribers();
217
+ return session;
218
+ }
219
+ async signup(email, password, name) {
220
+ const res = await fetch(`${this.baseUrl}/api/auth/sign-up/email`, {
221
+ method: 'POST',
222
+ headers: {
223
+ 'Content-Type': 'application/json',
224
+ 'Origin': this.baseUrl
225
+ },
226
+ body: JSON.stringify({ email, password, name })
227
+ });
228
+ if (!res.ok) {
229
+ const error = await res.json().catch(() => ({ message: res.statusText }));
230
+ throw new Error(error.message || 'Signup failed');
231
+ }
232
+ const data = await res.json();
233
+ // Better Auth can return token in different formats
234
+ const token = data.token || data.session?.token;
235
+ const expiresAt = data.session?.expiresAt || data.expiresAt;
236
+ if (!token || !data.user) {
237
+ throw new Error('Invalid response from server');
238
+ }
239
+ const session = {
240
+ token,
241
+ user: {
242
+ id: data.user.id,
243
+ name: data.user.name,
244
+ email: data.user.email,
245
+ image: data.user.image
246
+ },
247
+ expiresAt: expiresAt || (Math.floor(Date.now() / 1000) + 7200) // 2 hours default
248
+ };
249
+ this.session = session;
250
+ await this.persistSession(session);
251
+ this.scheduleRefresh();
252
+ this.notifySubscribers();
253
+ return session;
254
+ }
255
+ async logout() {
256
+ if (!this.session)
257
+ return;
258
+ try {
259
+ await fetch(`${this.baseUrl}/api/auth/sign-out`, {
260
+ method: 'POST',
261
+ headers: {
262
+ 'Authorization': `Bearer ${this.session.token}`
263
+ }
264
+ });
265
+ }
266
+ catch (e) {
267
+ console.error('[AuthManager] Logout request failed:', e);
268
+ }
269
+ this.session = null;
270
+ await this.persistSession(null);
271
+ if (this.refreshTimer) {
272
+ clearTimeout(this.refreshTimer);
273
+ this.refreshTimer = null;
274
+ }
275
+ this.notifySubscribers();
276
+ }
277
+ getSession() {
278
+ return this.session;
279
+ }
280
+ getToken() {
281
+ return this.session?.token ?? null;
282
+ }
283
+ subscribe(callback) {
284
+ this.subscribers.add(callback);
285
+ return () => this.subscribers.delete(callback);
286
+ }
287
+ // AuthClient compatibility methods
288
+ async getSessionCompat() {
289
+ if (!this.session)
290
+ return null;
291
+ return {
292
+ session: {
293
+ token: this.session.token
294
+ }
295
+ };
296
+ }
297
+ subscribeCompat(callback) {
298
+ const wrappedCallback = (session) => {
299
+ callback(session ? { session: { token: session.token } } : null);
300
+ };
301
+ return this.subscribe(wrappedCallback);
302
+ }
303
+ }
304
+ export class PersistenceManager {
305
+ persistence = null;
306
+ isReady = false;
307
+ readyPromise;
308
+ listeners = new Set();
309
+ constructor(options) {
310
+ const { documentName, document, enablePersistence = true, persistenceDbName = 'y-collab' } = options;
311
+ if (!enablePersistence || typeof indexedDB === 'undefined') {
312
+ this.readyPromise = Promise.resolve();
313
+ this.isReady = true;
314
+ console.log('[PersistenceManager] Persistence disabled or IndexedDB unavailable');
315
+ return;
316
+ }
317
+ // Check for private browsing mode
318
+ try {
319
+ const test = indexedDB.open('test');
320
+ test.onerror = () => {
321
+ console.warn('[PersistenceManager] IndexedDB not available (private browsing?)');
322
+ this.readyPromise = Promise.resolve();
323
+ this.isReady = true;
324
+ };
325
+ }
326
+ catch (e) {
327
+ console.warn('[PersistenceManager] IndexedDB check failed:', e);
328
+ this.readyPromise = Promise.resolve();
329
+ this.isReady = true;
330
+ return;
331
+ }
332
+ console.log(`[PersistenceManager] Initializing for ${documentName} in database ${persistenceDbName}`);
333
+ this.persistence = new IndexeddbPersistence(documentName, document);
334
+ this.readyPromise = new Promise((resolve) => {
335
+ if (!this.persistence) {
336
+ resolve();
337
+ return;
338
+ }
339
+ this.persistence.on('synced', () => {
340
+ console.log(`[PersistenceManager] Synced from IndexedDB for ${documentName}`);
341
+ this.isReady = true;
342
+ this.notifyListeners();
343
+ resolve();
344
+ });
345
+ });
346
+ }
347
+ waitForReady() {
348
+ return this.readyPromise;
349
+ }
350
+ isReadySync() {
351
+ return this.isReady;
352
+ }
353
+ onReady(callback) {
354
+ this.listeners.add(callback);
355
+ if (this.isReady) {
356
+ callback();
357
+ }
358
+ return () => this.listeners.delete(callback);
359
+ }
360
+ notifyListeners() {
361
+ this.listeners.forEach(callback => callback());
362
+ }
363
+ async clearData() {
364
+ if (this.persistence) {
365
+ try {
366
+ await this.persistence.clearData();
367
+ console.log('[PersistenceManager] Cleared local data');
368
+ }
369
+ catch (e) {
370
+ console.error('[PersistenceManager] Failed to clear data:', e);
371
+ }
372
+ }
373
+ }
374
+ destroy() {
375
+ if (this.persistence) {
376
+ this.persistence.destroy();
377
+ this.persistence = null;
378
+ }
379
+ this.listeners.clear();
380
+ }
381
+ }
382
+ export class CollabProviderWebsocket extends HocuspocusProviderWebsocket {
383
+ _authUnsubscribe = null;
384
+ spaceId;
385
+ _token;
386
+ _readyPromise;
387
+ _readyResolve;
388
+ _spaceDoc; // Y.Doc for the space itself
389
+ constructor(options) {
390
+ const { baseUrl, spaceId, auth, token, ...config } = options;
391
+ // Connect to the collaboration endpoint with spaceId for routing
392
+ // Matches server route /collaboration/:documentName
393
+ const url = `${baseUrl.replace(/^http/, 'ws')}/collaboration/space:${spaceId}`;
394
+ console.log(`[CollabProviderWebsocket] Creating with URL: ${url}, token: ${token ? 'present' : 'missing'}`);
395
+ // HocuspocusProviderWebsocket is just a WebSocket wrapper
396
+ // Individual HocuspocusProvider instances will attach and send sync messages
397
+ super({
398
+ url: (token ? `${url}?token=${token}` : url),
399
+ ...config,
400
+ });
401
+ this._spaceDoc = null; // No space doc needed for the WebSocket wrapper
402
+ this._token = token;
403
+ this.spaceId = spaceId;
404
+ // Set up ready promise
405
+ this._readyPromise = new Promise((resolve) => {
406
+ this._readyResolve = resolve;
407
+ // Also resolve immediately if already connected
408
+ if (this.status === 'connected') {
409
+ resolve();
410
+ }
411
+ });
412
+ // Listen for connection status
413
+ this.on('status', ({ status }) => {
414
+ console.log(`[CollabProviderWebsocket] Status changed to: ${status}`);
415
+ if (status === 'connected' && this._readyResolve) {
416
+ this._readyResolve();
417
+ }
418
+ });
419
+ // Debug: Listen to all events
420
+ this.on('synced', ({ state }) => {
421
+ console.log(`[CollabProviderWebsocket] Synced state: ${state}`);
422
+ });
423
+ // Message event listener removed - was causing blocking due to JSON.stringify
424
+ this.on('authenticationFailed', ({ reason }) => {
425
+ console.error(`[CollabProviderWebsocket] Authentication failed: ${reason}`);
426
+ });
427
+ // Awareness update listener removed - was causing blocking
428
+ if (auth && !token) {
429
+ // Only setup async auth if we don't have a token yet
430
+ console.log('[CollabProviderWebsocket] No token, setting up auth');
431
+ this.setupAuth(auth);
432
+ }
433
+ else if (auth) {
434
+ // Setup auth subscription for token refresh, but don't fetch initially
435
+ console.log('[CollabProviderWebsocket] Token provided, setting up auth subscription only');
436
+ this._authUnsubscribe = auth.subscribe(async (session) => {
437
+ const newToken = session?.session?.token;
438
+ if (newToken && newToken !== this._token) {
439
+ this.updateToken(newToken);
440
+ }
441
+ });
442
+ }
443
+ }
444
+ /**
445
+ * Wait for the websocket to be connected and ready
446
+ */
447
+ async waitForReady() {
448
+ // If already connected, resolve immediately
449
+ if (this.status === 'connected') {
450
+ return Promise.resolve();
451
+ }
452
+ return this._readyPromise;
453
+ }
454
+ async setupAuth(auth) {
455
+ // Subscribe to session changes
456
+ this._authUnsubscribe = auth.subscribe(async (session) => {
457
+ const token = session?.session?.token;
458
+ console.log('[CollabProviderWebsocket] Auth subscription callback, token:', token ? 'present' : 'missing');
459
+ if (token && token !== this._token) {
460
+ this.updateToken(token);
461
+ }
462
+ });
463
+ // Initial fetch if no token provided yet
464
+ if (!this._token) {
465
+ console.log('[CollabProviderWebsocket] Fetching initial session...');
466
+ const session = await auth.getSession();
467
+ console.log('[CollabProviderWebsocket] Session fetched, token:', session?.session?.token ? 'present' : 'missing');
468
+ if (session?.session?.token) {
469
+ this.updateToken(session.session.token);
470
+ // Connect now that we have the token
471
+ console.log('[CollabProviderWebsocket] Calling connect()');
472
+ this.connect();
473
+ }
474
+ }
475
+ }
476
+ updateToken(newToken) {
477
+ this._token = newToken;
478
+ // HocuspocusProviderWebsocket doesn't expose a clean way to update query params on the fly
479
+ // without reconnecting. We assume the server handles auth on connect.
480
+ // If we want to support seamless token rotation, we might need to send a message or reconnect.
481
+ // For now, we update the URL generic so next reconnect works.
482
+ const base = this.configuration.url.toString().split('?')[0];
483
+ this.configuration.url = `${base}?token=${newToken}`;
484
+ // If we are currently disconnected/connecting, this helps.
485
+ // If connected, the socket stays open until it expires or we force it.
486
+ // Some setups prefer forcing reconnect on token change:
487
+ // this.disconnect();
488
+ // this.connect();
489
+ }
490
+ getToken() {
491
+ return this._token;
492
+ }
493
+ getSpaceDoc() {
494
+ return this._spaceDoc;
495
+ }
496
+ destroy() {
497
+ if (this._authUnsubscribe) {
498
+ this._authUnsubscribe();
499
+ }
500
+ super.destroy();
501
+ }
502
+ }
503
+ /**
504
+ * HocuspocusProvider wrapper that handles constructing the correct name/url
505
+ * based on our platform's conventions.
506
+ */
507
+ export class CollabProvider extends HocuspocusProvider {
508
+ _authUnsubscribe = null;
509
+ documentId;
510
+ persistenceManager = null;
511
+ constructor(options) {
512
+ const { baseUrl, spaceId, documentId, websocketProvider, auth, token, enablePersistence = true, persistenceDbName, ...providerOptions } = options;
513
+ let config;
514
+ let parentToken = token;
515
+ // Mode 1: Multiplexing via Shared Socket
516
+ if (websocketProvider) {
517
+ const finalSpaceId = spaceId || (websocketProvider instanceof CollabProviderWebsocket ? websocketProvider.spaceId : spaceId);
518
+ const fullName = `space:${finalSpaceId}:doc:${documentId}`;
519
+ parentToken = parentToken || (websocketProvider instanceof CollabProviderWebsocket ? websocketProvider.getToken() : undefined);
520
+ console.log(`[CollabProvider] Creating in multiplexing mode`);
521
+ console.log(`[CollabProvider] Document name: ${fullName}`);
522
+ console.log(`[CollabProvider] WebsocketProvider status:`, websocketProvider.status);
523
+ console.log(`[CollabProvider] WebsocketProvider URL:`, websocketProvider.configuration?.url);
524
+ console.log(`[CollabProvider] Options passed:`, providerOptions);
525
+ config = {
526
+ name: fullName,
527
+ websocketProvider: websocketProvider,
528
+ token: parentToken,
529
+ ...providerOptions
530
+ };
531
+ console.log(`[CollabProvider] Final config:`, config);
532
+ }
533
+ // Mode 2: Standalone Connection
534
+ else if (baseUrl && spaceId) {
535
+ const fullName = `space:${spaceId}:doc:${documentId}`;
536
+ const url = `${baseUrl.replace(/^http/, 'ws')}/collaboration/${fullName}`;
537
+ console.log(`[CollabProvider] Creating in standalone mode`);
538
+ console.log(`[CollabProvider] URL: ${url}`);
539
+ console.log(`[CollabProvider] Document name: ${fullName}`);
540
+ config = {
541
+ url,
542
+ name: fullName,
543
+ token,
544
+ ...providerOptions
545
+ };
546
+ }
547
+ else {
548
+ throw new Error("CollabProvider requires either 'websocketProvider' or 'baseUrl' + 'spaceId'");
549
+ }
550
+ super(config);
551
+ this.documentId = documentId;
552
+ console.log(`[CollabProvider] Created, status:`, this.status);
553
+ console.log(`[CollabProvider] Awareness instance present:`, !!this.awareness);
554
+ console.log(`[CollabProvider] Awareness check:`, this.awareness);
555
+ // Initialize persistence
556
+ const fullName = config.name;
557
+ this.persistenceManager = new PersistenceManager({
558
+ documentName: fullName,
559
+ document: this.document,
560
+ enablePersistence,
561
+ persistenceDbName
562
+ });
563
+ // When using websocketProvider (multiplexing), we must manually attach
564
+ // The parent constructor only calls attach() if manageSocket is true (standalone mode)
565
+ // The attach() method will automatically call onOpen() if the websocket is already connected
566
+ if (websocketProvider) {
567
+ console.log(`[CollabProvider] Attaching to parent websocket`);
568
+ console.time('[CollabProvider] attach()');
569
+ this.attach();
570
+ console.timeEnd('[CollabProvider] attach()');
571
+ }
572
+ // Debug: Listen to events
573
+ this.on('status', ({ status }) => {
574
+ const timestamp = new Date().toISOString();
575
+ console.log(`[CollabProvider] ${timestamp} Status changed to: ${status}`, {
576
+ documentId: this.documentId,
577
+ status: status
578
+ });
579
+ });
580
+ this.on('synced', ({ state }) => {
581
+ const timestamp = new Date().toISOString();
582
+ console.log(`[CollabProvider] ${timestamp} Synced state: ${state}`, {
583
+ documentId: this.documentId,
584
+ synced: state
585
+ });
586
+ });
587
+ // Message event listener removed - was causing blocking
588
+ this.on('authenticationFailed', ({ reason }) => {
589
+ const timestamp = new Date().toISOString();
590
+ console.error(`[CollabProvider] ${timestamp} Authentication failed: ${reason}`, {
591
+ documentId: this.documentId,
592
+ reason: reason
593
+ });
594
+ });
595
+ // Awareness logging removed - was causing blocking
596
+ // Setup auth watchers
597
+ if (!websocketProvider && auth) {
598
+ this.setupAuth(auth);
599
+ }
600
+ else if (websocketProvider && websocketProvider instanceof CollabProviderWebsocket) {
601
+ // In multiplexed mode, we might want to listen to the parent socket's token changes?
602
+ // Actually, HocuspocusProvider reads `this.configuration.token` during authentication steps.
603
+ // We should ensure `this.configuration.token` is kept fresh.
604
+ // Currently Hocuspocus doesn't re-read it actively unless we push it.
605
+ }
606
+ }
607
+ async setupAuth(auth) {
608
+ this._authUnsubscribe = auth.subscribe(async (session) => {
609
+ const token = session?.session?.token;
610
+ if (token) {
611
+ this.setToken(token);
612
+ }
613
+ });
614
+ const session = await auth.getSession();
615
+ if (session?.session?.token) {
616
+ this.setToken(session.session.token);
617
+ }
618
+ }
619
+ setToken(token) {
620
+ this.configuration.token = token;
621
+ // If connected, send auth message
622
+ if (this.status === "connected") {
623
+ this.sendStateless(JSON.stringify({ type: "auth", token: token }));
624
+ }
625
+ }
626
+ destroy() {
627
+ if (this._authUnsubscribe) {
628
+ this._authUnsubscribe();
629
+ }
630
+ if (this.persistenceManager) {
631
+ this.persistenceManager.destroy();
632
+ }
633
+ super.destroy();
634
+ }
635
+ /**
636
+ * Wait for persistence to be ready
637
+ */
638
+ async waitForPersistence() {
639
+ if (this.persistenceManager) {
640
+ return this.persistenceManager.waitForReady();
641
+ }
642
+ }
643
+ /**
644
+ * Check if persistence is ready
645
+ */
646
+ isPersistenceReady() {
647
+ return this.persistenceManager?.isReadySync() ?? false;
648
+ }
649
+ /**
650
+ * Clear local persistence data
651
+ */
652
+ async clearLocalData() {
653
+ if (this.persistenceManager) {
654
+ return this.persistenceManager.clearData();
655
+ }
656
+ }
657
+ /**
658
+ * Send a comment action via stateless message
659
+ */
660
+ sendComment(action, data) {
661
+ this.sendStateless(JSON.stringify({
662
+ type: "comment",
663
+ action,
664
+ data
665
+ }));
666
+ }
667
+ /**
668
+ * Listen for real-time comment events
669
+ */
670
+ onComment(callback) {
671
+ const handler = (payload) => {
672
+ try {
673
+ const msg = JSON.parse(payload.payload);
674
+ if (msg.type === "comment" || msg.type === "comment_error") {
675
+ callback(msg);
676
+ }
677
+ }
678
+ catch (e) {
679
+ // Ignore parsing errors
680
+ }
681
+ };
682
+ this.on("stateless", handler);
683
+ return () => this.off("stateless", handler);
684
+ }
685
+ }
686
+ // ----------------------------------------------------------------------
687
+ // CollabClient (Full SDK)
688
+ // ----------------------------------------------------------------------
689
+ export class CollabClient {
690
+ baseUrl;
691
+ auth;
692
+ authManager = null;
693
+ offlineOptions;
694
+ // Cache for shared websockets by spaceId
695
+ _sockets = new Map();
696
+ _token = null;
697
+ // Upload queue for offline uploads
698
+ uploadQueue = null;
699
+ constructor(options) {
700
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
701
+ this.offlineOptions = {
702
+ enablePersistence: true,
703
+ enableImageCache: true,
704
+ enableUploadQueue: true,
705
+ persistenceDbName: 'y-collab',
706
+ maxCacheSize: 50, // MB
707
+ maxDocuments: 20,
708
+ autoCleanup: true,
709
+ cleanupThreshold: 0.9,
710
+ ...options.offline
711
+ };
712
+ if (options.authClient) {
713
+ // Custom auth implementation
714
+ this.auth = options.authClient;
715
+ }
716
+ else {
717
+ // Managed auth (default)
718
+ this.authManager = new AuthManager({
719
+ baseUrl: this.baseUrl,
720
+ storage: options.managedAuth?.storage,
721
+ autoRefresh: options.managedAuth?.autoRefresh,
722
+ refreshBuffer: options.managedAuth?.refreshBuffer
723
+ });
724
+ this.auth = {
725
+ getSession: () => this.authManager.getSessionCompat(),
726
+ subscribe: (callback) => this.authManager.subscribeCompat(callback)
727
+ };
728
+ }
729
+ this.auth.subscribe((session) => {
730
+ this._token = session?.session?.token || null;
731
+ });
732
+ this.auth.getSession().then(s => {
733
+ this._token = s?.session?.token || null;
734
+ });
735
+ // Initialize upload queue
736
+ if (this.offlineOptions.enableUploadQueue && typeof indexedDB !== 'undefined') {
737
+ this.uploadQueue = new UploadQueueManager();
738
+ this.uploadQueue.init().catch((error) => {
739
+ console.error('[CollabClient] Failed to initialize upload queue:', error);
740
+ this.uploadQueue = null;
741
+ });
742
+ // Process queue when client is created and online
743
+ if (navigator.onLine) {
744
+ setTimeout(() => {
745
+ this.processUploadQueue().catch(error => {
746
+ console.error('[CollabClient] Failed to process upload queue:', error);
747
+ });
748
+ }, 1000);
749
+ }
750
+ }
751
+ }
752
+ // Convenience methods (only available when using managed auth)
753
+ async login(email, password) {
754
+ if (!this.authManager) {
755
+ throw new Error('login() is only available in managed auth mode');
756
+ }
757
+ return this.authManager.login(email, password);
758
+ }
759
+ async signup(email, password, name) {
760
+ if (!this.authManager) {
761
+ throw new Error('signup() is only available in managed auth mode');
762
+ }
763
+ return this.authManager.signup(email, password, name);
764
+ }
765
+ async logout() {
766
+ if (!this.authManager) {
767
+ throw new Error('logout() is only available in managed auth mode');
768
+ }
769
+ return this.authManager.logout();
770
+ }
771
+ getSession() {
772
+ if (!this.authManager) {
773
+ throw new Error('getSession() is only available in managed auth mode');
774
+ }
775
+ return this.authManager.getSession();
776
+ }
777
+ async getToken() {
778
+ if (this._token)
779
+ return this._token;
780
+ const session = await this.auth.getSession();
781
+ this._token = session?.session?.token || null;
782
+ if (!this._token) {
783
+ throw new Error("Not authenticated");
784
+ }
785
+ return this._token;
786
+ }
787
+ async fetch(path, options = {}) {
788
+ const token = await this.getToken();
789
+ const headers = new Headers(options.headers);
790
+ headers.set("Authorization", `Bearer ${token}`);
791
+ if (!headers.has("Content-Type") && !(options.body instanceof FormData)) {
792
+ headers.set("Content-Type", "application/json");
793
+ }
794
+ const res = await fetch(`${this.baseUrl}/api${path}`, {
795
+ ...options,
796
+ headers,
797
+ });
798
+ if (!res.ok) {
799
+ const error = await res.json().catch(() => ({ message: res.statusText }));
800
+ throw new Error(error.message || error.error || `Request failed: ${res.status}`);
801
+ }
802
+ // Return empty for 204
803
+ if (res.status === 204)
804
+ return {};
805
+ return res.json();
806
+ }
807
+ // --- Extensions ---
808
+ async getExtensions(deps) {
809
+ // Make dependencies available for extension scripts that use bare specifiers
810
+ if (deps) {
811
+ globalThis.__collab_deps = { ...globalThis.__collab_deps, ...deps };
812
+ }
813
+ const res = await this.fetch("/extensions/config");
814
+ const defs = res.extensions || [];
815
+ const loadedExtensions = [];
816
+ for (const def of defs) {
817
+ if (def.scriptUrl) {
818
+ try {
819
+ const fullUrl = new URL(def.scriptUrl, this.baseUrl).toString();
820
+ // Fetch script text and rewrite bare specifiers to use provided deps
821
+ const response = await fetch(fullUrl);
822
+ const scriptText = await response.text();
823
+ // Replace bare module specifiers with global deps lookup
824
+ const rewritten = scriptText.replace(/import\s*\{([^}]+)\}\s*from\s*['"]([^./][^'"]*)['"]/g, (_match, imports, specifier) => {
825
+ return `const {${imports}} = globalThis.__collab_deps?.['${specifier}'] ?? (() => { throw new Error('Missing dependency: ${specifier}') })()`;
826
+ });
827
+ const blob = new Blob([rewritten], { type: 'application/javascript' });
828
+ const blobUrl = URL.createObjectURL(blob);
829
+ try {
830
+ // @ts-ignore
831
+ const module = await import(__rewriteRelativeImportExtension(/* @vite-ignore */ blobUrl));
832
+ if (module.default) {
833
+ if (typeof module.default.configure === 'function') {
834
+ loadedExtensions.push(module.default.configure(def.options || {}));
835
+ }
836
+ else {
837
+ loadedExtensions.push(module.default);
838
+ }
839
+ }
840
+ }
841
+ finally {
842
+ URL.revokeObjectURL(blobUrl);
843
+ }
844
+ }
845
+ catch (e) {
846
+ console.error(`[CollabClient] Failed to load extension ${def.name}`, e);
847
+ }
848
+ }
849
+ }
850
+ return loadedExtensions;
851
+ }
852
+ // --- WebSocket / Provider Factory ---
853
+ async getWebsocketProvider(spaceId) {
854
+ const cached = this._sockets.get(spaceId);
855
+ // Check if cached socket exists and is still valid (not destroyed)
856
+ if (cached) {
857
+ const status = cached.status;
858
+ const shouldConnect = cached.shouldConnect;
859
+ // Only reuse if the websocket hasn't been destroyed (shouldConnect should be true for active sockets)
860
+ if (shouldConnect !== false && status !== 'destroyed') {
861
+ console.log(`[CollabClient] Reusing cached websocket for space: ${spaceId}`);
862
+ return cached;
863
+ }
864
+ // Remove destroyed websocket from cache
865
+ console.log(`[CollabClient] Removing destroyed websocket from cache for space: ${spaceId}`);
866
+ this._sockets.delete(spaceId);
867
+ }
868
+ console.log(`[CollabClient] Creating new websocket for space: ${spaceId}`);
869
+ // Get token first before creating websocket
870
+ const token = await this.getToken();
871
+ console.log(`[CollabClient] Token obtained:`, token ? 'present' : 'missing');
872
+ const ws = new CollabProviderWebsocket({
873
+ baseUrl: this.baseUrl,
874
+ spaceId,
875
+ auth: this.auth,
876
+ token, // Pass token directly so it connects immediately
877
+ });
878
+ this._sockets.set(spaceId, ws);
879
+ return ws;
880
+ }
881
+ /**
882
+ * Get a CollabProvider for a specific document.
883
+ * Uses a shared WebSocket for the space if possible.
884
+ */
885
+ async getProvider(spaceId, documentId, options = {}) {
886
+ const documentName = `space:${spaceId}:doc:${documentId}`;
887
+ // Check if this document is already attached to the websocket
888
+ const socket = await this.getWebsocketProvider(spaceId);
889
+ const providerMap = socket.configuration?.providerMap;
890
+ // CRITICAL: If the same document is already attached, create a NEW websocket
891
+ // because providerMap.set() overwrites - only one provider per document name per socket
892
+ if (providerMap && providerMap.has(documentName)) {
893
+ console.log(`[CollabClient] Document ${documentId} already attached to this websocket - creating separate websocket for this tab`);
894
+ // Create a unique websocket for this tab
895
+ const token = await this.getToken();
896
+ const newSocket = new CollabProviderWebsocket({
897
+ baseUrl: this.baseUrl,
898
+ spaceId,
899
+ auth: this.auth,
900
+ token,
901
+ });
902
+ // Don't cache this one - it's tab-specific
903
+ console.log(`[CollabClient] Created separate websocket for tab accessing doc ${documentId}`);
904
+ await newSocket.waitForReady();
905
+ return new CollabProvider({
906
+ websocketProvider: newSocket,
907
+ spaceId,
908
+ documentId,
909
+ enablePersistence: this.offlineOptions.enablePersistence,
910
+ persistenceDbName: this.offlineOptions.persistenceDbName,
911
+ ...options
912
+ });
913
+ }
914
+ // Normal path: document not yet attached, safe to use shared websocket
915
+ console.log(`[CollabClient] Waiting for websocket to be ready before creating provider for doc ${documentId}`);
916
+ await socket.waitForReady();
917
+ console.log(`[CollabClient] Websocket ready, creating provider for doc ${documentId}`);
918
+ return new CollabProvider({
919
+ websocketProvider: socket,
920
+ spaceId,
921
+ documentId,
922
+ enablePersistence: this.offlineOptions.enablePersistence,
923
+ persistenceDbName: this.offlineOptions.persistenceDbName,
924
+ ...options
925
+ });
926
+ }
927
+ // --- REST API: Documents ---
928
+ documents = {
929
+ /**
930
+ * List documents in a space
931
+ * @param parentId - null for root only, undefined for all, string for specific parent
932
+ */
933
+ list: (spaceId, params) => {
934
+ const searchParams = new URLSearchParams();
935
+ if (params?.parentId === null) {
936
+ searchParams.set('parentId', 'null');
937
+ }
938
+ else if (params?.parentId) {
939
+ searchParams.set('parentId', params.parentId);
940
+ }
941
+ if (params?.limit)
942
+ searchParams.set('limit', String(params.limit));
943
+ if (params?.offset)
944
+ searchParams.set('offset', String(params.offset));
945
+ const qs = searchParams.toString() ? `?${searchParams}` : '';
946
+ return this.fetch(`/spaces/${spaceId}/documents${qs}`).then(r => ({ items: r.data, pagination: r.pagination }));
947
+ },
948
+ get: (spaceId, docId) => this.fetch(`/spaces/${spaceId}/documents/${docId}`).then(r => r.data),
949
+ create: (spaceId, options) => this.fetch(`/spaces/${spaceId}/documents`, {
950
+ method: "POST",
951
+ body: JSON.stringify(options || {}),
952
+ }),
953
+ update: (spaceId, docId, data) => this.fetch(`/spaces/${spaceId}/documents/${docId}`, {
954
+ method: "PATCH",
955
+ body: JSON.stringify(data),
956
+ }),
957
+ delete: (spaceId, docId) => this.fetch(`/spaces/${spaceId}/documents/${docId}`, {
958
+ method: "DELETE",
959
+ }),
960
+ getContent: (spaceId, docId) => this.fetch(`/spaces/${spaceId}/documents/${docId}/content`).then(r => r.content),
961
+ /**
962
+ * Update document content via patch (merge or replace strategy)
963
+ */
964
+ patch: (spaceId, docId, content, options) => this.fetch(`/spaces/${spaceId}/documents/${docId}/content`, {
965
+ method: "PATCH",
966
+ body: JSON.stringify({ content, ...options }),
967
+ }).then(r => r.document),
968
+ /**
969
+ * Apply transformation operations to document
970
+ */
971
+ transform: (spaceId, docId, operations) => this.fetch(`/spaces/${spaceId}/documents/${docId}/transform`, {
972
+ method: "POST",
973
+ body: JSON.stringify({ operations }),
974
+ }).then(r => r.document),
975
+ // Hierarchy methods
976
+ tree: (spaceId) => this.fetch(`/spaces/${spaceId}/documents/tree`).then(r => r.data),
977
+ children: (spaceId, docId, params) => {
978
+ const searchParams = new URLSearchParams();
979
+ if (params?.limit)
980
+ searchParams.set('limit', String(params.limit));
981
+ if (params?.offset)
982
+ searchParams.set('offset', String(params.offset));
983
+ const qs = searchParams.toString() ? `?${searchParams}` : '';
984
+ return this.fetch(`/spaces/${spaceId}/documents/${docId}/children${qs}`).then(r => ({ items: r.data, pagination: r.pagination }));
985
+ },
986
+ ancestors: (spaceId, docId) => this.fetch(`/spaces/${spaceId}/documents/${docId}/ancestors`).then(r => r.data),
987
+ move: (spaceId, docId, parentId, position) => this.fetch(`/spaces/${spaceId}/documents/${docId}/move`, {
988
+ method: "POST",
989
+ body: JSON.stringify({ parentId, position }),
990
+ }),
991
+ // History / Snapshots
992
+ getSnapshots: (spaceId, docId) => this.fetch(`/spaces/${spaceId}/documents/${docId}/snapshots`).then(r => r.snapshots),
993
+ restoreSnapshot: (spaceId, docId, snapshotId) => this.fetch(`/spaces/${spaceId}/documents/${docId}/restore/${snapshotId}`, {
994
+ method: "POST",
995
+ }),
996
+ // Maintenance / Recovery
997
+ listDeleted: (spaceId, params) => {
998
+ const searchParams = new URLSearchParams();
999
+ if (params?.limit)
1000
+ searchParams.set('limit', String(params.limit));
1001
+ if (params?.offset)
1002
+ searchParams.set('offset', String(params.offset));
1003
+ const qs = searchParams.toString() ? `?${searchParams}` : '';
1004
+ return this.fetch(`/spaces/${spaceId}/documents/deleted${qs}`).then(r => ({ items: r.data, pagination: r.pagination }));
1005
+ },
1006
+ restore: (spaceId, docId) => this.fetch(`/spaces/${spaceId}/documents/${docId}/restore`, {
1007
+ method: "POST",
1008
+ }),
1009
+ deletePermanent: (spaceId, docId) => this.fetch(`/spaces/${spaceId}/documents/${docId}/permanent`, {
1010
+ method: "DELETE",
1011
+ }),
1012
+ // Export - returns raw HTML string or JSON object depending on format
1013
+ export: async (spaceId, docId, format = 'html') => {
1014
+ const token = await this.getToken();
1015
+ const res = await fetch(`${this.baseUrl}/api/spaces/${spaceId}/documents/${docId}/export?format=${format}`, {
1016
+ headers: { Authorization: `Bearer ${token}` },
1017
+ });
1018
+ if (!res.ok)
1019
+ throw new Error(`Export failed: ${res.status}`);
1020
+ return format === 'json' ? res.json() : res.text();
1021
+ },
1022
+ };
1023
+ // --- REST API: Spaces ---
1024
+ spaces = {
1025
+ list: () => this.fetch("/spaces").then(r => r.data),
1026
+ get: (spaceId) => this.fetch(`/spaces/${spaceId}`).then(r => r.data),
1027
+ getMembers: (spaceId) => this.fetch(`/spaces/${spaceId}/members`).then(r => r.data),
1028
+ create: (data) => this.fetch("/organizations", {
1029
+ method: "POST",
1030
+ headers: { "Content-Type": "application/json" },
1031
+ body: JSON.stringify(data),
1032
+ }),
1033
+ /**
1034
+ * Add a member to a space/organization
1035
+ */
1036
+ addMember: (spaceId, email, role) => this.fetch(`/organizations/${spaceId}/members`, {
1037
+ method: "POST",
1038
+ body: JSON.stringify({ email, role }),
1039
+ }),
1040
+ /**
1041
+ * Update a member's role
1042
+ */
1043
+ updateRole: (spaceId, userId, role) => this.fetch(`/organizations/${spaceId}/members/${userId}`, {
1044
+ method: "PATCH",
1045
+ body: JSON.stringify({ role }),
1046
+ }),
1047
+ /**
1048
+ * Remove a member from a space/organization
1049
+ */
1050
+ removeMember: (spaceId, userId) => this.fetch(`/organizations/${spaceId}/members/${userId}`, {
1051
+ method: "DELETE",
1052
+ }),
1053
+ };
1054
+ // --- REST API: Uploads ---
1055
+ uploads = {
1056
+ list: (spaceId, documentId, params) => {
1057
+ const searchParams = new URLSearchParams();
1058
+ if (documentId)
1059
+ searchParams.set('documentId', documentId);
1060
+ if (params?.limit)
1061
+ searchParams.set('limit', String(params.limit));
1062
+ if (params?.offset)
1063
+ searchParams.set('offset', String(params.offset));
1064
+ const qs = searchParams.toString() ? `?${searchParams}` : '';
1065
+ return this.fetch(`/spaces/${spaceId}/files${qs}`).then(r => ({ items: r.data, pagination: r.pagination }));
1066
+ },
1067
+ upload: async (spaceId, file, documentId) => {
1068
+ // Check if online
1069
+ if (!navigator.onLine && this.uploadQueue) {
1070
+ console.log('[CollabClient] Offline - queuing upload:', file.name);
1071
+ // Add to queue and return temp data
1072
+ const pendingUpload = await this.uploadQueue.add({
1073
+ spaceId,
1074
+ documentId: documentId || null,
1075
+ file,
1076
+ filename: file.name,
1077
+ mimeType: file.type
1078
+ });
1079
+ // Return temporary file info that looks like a FileUpload
1080
+ return {
1081
+ id: pendingUpload.id,
1082
+ url: pendingUpload.tempUrl,
1083
+ filename: pendingUpload.filename,
1084
+ mimeType: pendingUpload.mimeType,
1085
+ size: file.size
1086
+ };
1087
+ }
1088
+ // Online - upload directly
1089
+ const formData = new FormData();
1090
+ formData.append("file", file);
1091
+ const qs = documentId ? `?documentId=${documentId}` : "";
1092
+ return this.fetch(`/spaces/${spaceId}/upload${qs}`, {
1093
+ method: "POST",
1094
+ body: formData,
1095
+ }).then(r => r.data);
1096
+ },
1097
+ delete: (spaceId, fileId) => this.fetch(`/spaces/${spaceId}/files/${fileId}`, {
1098
+ method: "DELETE",
1099
+ }),
1100
+ deletePermanent: (spaceId, fileId) => this.fetch(`/spaces/${spaceId}/files/${fileId}/permanent`, {
1101
+ method: "DELETE",
1102
+ }),
1103
+ };
1104
+ // --- REST API: Comments ---
1105
+ comments = {
1106
+ /**
1107
+ * List comments for a document (threaded)
1108
+ */
1109
+ list: (spaceId, docId) => this.fetch(`/spaces/${spaceId}/documents/${docId}/comments`).then(r => ({ threads: r.data, meta: r.meta })),
1110
+ /**
1111
+ * Get a single comment
1112
+ */
1113
+ get: (spaceId, docId, commentId) => this.fetch(`/spaces/${spaceId}/documents/${docId}/comments/${commentId}`).then(r => r.data),
1114
+ /**
1115
+ * Create a new comment
1116
+ */
1117
+ create: (spaceId, docId, data) => this.fetch(`/spaces/${spaceId}/documents/${docId}/comments`, {
1118
+ method: "POST",
1119
+ body: JSON.stringify(data),
1120
+ }).then(r => r.data),
1121
+ /**
1122
+ * Update a comment's content
1123
+ */
1124
+ update: (spaceId, docId, commentId, content) => this.fetch(`/spaces/${spaceId}/documents/${docId}/comments/${commentId}`, {
1125
+ method: "PATCH",
1126
+ body: JSON.stringify({ content }),
1127
+ }).then(r => r.data),
1128
+ /**
1129
+ * Delete a comment
1130
+ */
1131
+ delete: (spaceId, docId, commentId) => this.fetch(`/spaces/${spaceId}/documents/${docId}/comments/${commentId}`, {
1132
+ method: "DELETE",
1133
+ }),
1134
+ /**
1135
+ * Resolve or unresolve a comment thread
1136
+ */
1137
+ resolve: (spaceId, docId, commentId, resolved) => this.fetch(`/spaces/${spaceId}/documents/${docId}/comments/${commentId}/resolve`, {
1138
+ method: "POST",
1139
+ body: JSON.stringify({ resolved }),
1140
+ }).then(r => r.data),
1141
+ };
1142
+ // --- REST API: Sharing ---
1143
+ share = {
1144
+ /**
1145
+ * Get share status for a document
1146
+ */
1147
+ get: async (spaceId, docId) => {
1148
+ const token = await this.getToken();
1149
+ const res = await fetch(`${this.baseUrl}/api/spaces/${spaceId}/documents/${docId}/share`, {
1150
+ headers: { Authorization: `Bearer ${token}` },
1151
+ });
1152
+ if (!res.ok)
1153
+ throw new Error("Failed to get share status");
1154
+ return res.json();
1155
+ },
1156
+ /**
1157
+ * Enable sharing for a document
1158
+ */
1159
+ enable: async (spaceId, docId) => {
1160
+ const token = await this.getToken();
1161
+ const res = await fetch(`${this.baseUrl}/api/spaces/${spaceId}/documents/${docId}/share`, {
1162
+ method: "POST",
1163
+ headers: { Authorization: `Bearer ${token}` },
1164
+ });
1165
+ if (!res.ok)
1166
+ throw new Error("Failed to enable sharing");
1167
+ return res.json();
1168
+ },
1169
+ /**
1170
+ * Disable sharing for a document
1171
+ */
1172
+ disable: async (spaceId, docId) => {
1173
+ const token = await this.getToken();
1174
+ const res = await fetch(`${this.baseUrl}/api/spaces/${spaceId}/documents/${docId}/share`, {
1175
+ method: "DELETE",
1176
+ headers: { Authorization: `Bearer ${token}` },
1177
+ });
1178
+ if (!res.ok)
1179
+ throw new Error("Failed to disable sharing");
1180
+ return res.json();
1181
+ }
1182
+ };
1183
+ /**
1184
+ * Process the upload queue
1185
+ */
1186
+ async processUploadQueue() {
1187
+ if (!this.uploadQueue)
1188
+ return;
1189
+ await this.uploadQueue.processQueue(async (upload) => {
1190
+ console.log('[CollabClient] Processing queued upload:', upload.filename);
1191
+ // Re-create File from Blob
1192
+ const file = new File([upload.file], upload.filename, { type: upload.mimeType });
1193
+ // Upload to server
1194
+ const formData = new FormData();
1195
+ formData.append("file", file);
1196
+ const qs = upload.documentId ? `?documentId=${upload.documentId}` : "";
1197
+ const result = await this.fetch(`/spaces/${upload.spaceId}/upload${qs}`, {
1198
+ method: "POST",
1199
+ body: formData,
1200
+ });
1201
+ return result.data;
1202
+ });
1203
+ }
1204
+ /**
1205
+ * Get pending uploads count
1206
+ */
1207
+ async getPendingUploadsCount() {
1208
+ if (!this.uploadQueue)
1209
+ return 0;
1210
+ return this.uploadQueue.count();
1211
+ }
1212
+ /**
1213
+ * Get all pending uploads
1214
+ */
1215
+ async getPendingUploads() {
1216
+ if (!this.uploadQueue)
1217
+ return [];
1218
+ return this.uploadQueue.getAll();
1219
+ }
1220
+ /**
1221
+ * Subscribe to upload queue events
1222
+ */
1223
+ onUploadQueue(event, callback) {
1224
+ if (!this.uploadQueue)
1225
+ return () => { };
1226
+ return this.uploadQueue.on(event, callback);
1227
+ }
1228
+ /**
1229
+ * Get storage quota information
1230
+ */
1231
+ async getStorageInfo() {
1232
+ if (!navigator.storage || !navigator.storage.estimate) {
1233
+ return { usage: 0, quota: 0, percentUsed: 0 };
1234
+ }
1235
+ try {
1236
+ const estimate = await navigator.storage.estimate();
1237
+ const usage = estimate.usage || 0;
1238
+ const quota = estimate.quota || 0;
1239
+ const percentUsed = quota > 0 ? (usage / quota) * 100 : 0;
1240
+ return { usage, quota, percentUsed };
1241
+ }
1242
+ catch (error) {
1243
+ console.error('[CollabClient] Failed to get storage info:', error);
1244
+ return { usage: 0, quota: 0, percentUsed: 0 };
1245
+ }
1246
+ }
1247
+ /**
1248
+ * Clear all local data (IndexedDB and caches)
1249
+ */
1250
+ async clearAllLocalData() {
1251
+ console.log('[CollabClient] Clearing all local data...');
1252
+ // Clear upload queue
1253
+ if (this.uploadQueue) {
1254
+ await this.uploadQueue.clear();
1255
+ }
1256
+ // Clear all IndexedDB databases
1257
+ if (typeof indexedDB !== 'undefined') {
1258
+ try {
1259
+ // Get all database names
1260
+ const databases = await indexedDB.databases();
1261
+ for (const db of databases) {
1262
+ if (db.name) {
1263
+ console.log('[CollabClient] Deleting database:', db.name);
1264
+ await new Promise((resolve, reject) => {
1265
+ const request = indexedDB.deleteDatabase(db.name);
1266
+ request.onsuccess = () => resolve();
1267
+ request.onerror = () => reject(request.error);
1268
+ });
1269
+ }
1270
+ }
1271
+ }
1272
+ catch (error) {
1273
+ console.error('[CollabClient] Failed to clear IndexedDB:', error);
1274
+ }
1275
+ }
1276
+ // Clear caches
1277
+ if (typeof caches !== 'undefined') {
1278
+ try {
1279
+ const cacheNames = await caches.keys();
1280
+ for (const name of cacheNames) {
1281
+ console.log('[CollabClient] Deleting cache:', name);
1282
+ await caches.delete(name);
1283
+ }
1284
+ }
1285
+ catch (error) {
1286
+ console.error('[CollabClient] Failed to clear caches:', error);
1287
+ }
1288
+ }
1289
+ console.log('[CollabClient] Local data cleared');
1290
+ }
1291
+ /**
1292
+ * Clear specific document cache
1293
+ */
1294
+ async clearDocumentCache(spaceId, docId) {
1295
+ const dbName = this.offlineOptions.persistenceDbName || 'y-collab';
1296
+ const documentName = `space:${spaceId}:doc:${docId}`;
1297
+ try {
1298
+ // Open the database and clear the specific document
1299
+ const db = await new Promise((resolve, reject) => {
1300
+ const request = indexedDB.open(dbName);
1301
+ request.onsuccess = () => resolve(request.result);
1302
+ request.onerror = () => reject(request.error);
1303
+ });
1304
+ // Check if the store exists
1305
+ if (!db.objectStoreNames.contains(documentName)) {
1306
+ db.close();
1307
+ return;
1308
+ }
1309
+ // Clear the document store
1310
+ await new Promise((resolve, reject) => {
1311
+ const transaction = db.transaction([documentName], 'readwrite');
1312
+ const store = transaction.objectStore(documentName);
1313
+ const request = store.clear();
1314
+ request.onsuccess = () => resolve();
1315
+ request.onerror = () => reject(request.error);
1316
+ });
1317
+ db.close();
1318
+ console.log('[CollabClient] Document cache cleared:', documentName);
1319
+ }
1320
+ catch (error) {
1321
+ console.error('[CollabClient] Failed to clear document cache:', error);
1322
+ }
1323
+ }
1324
+ /**
1325
+ * Perform cleanup when storage quota is exceeded
1326
+ */
1327
+ async performCleanup() {
1328
+ console.log('[CollabClient] Performing storage cleanup...');
1329
+ const { percentUsed } = await this.getStorageInfo();
1330
+ const threshold = (this.offlineOptions.cleanupThreshold || 0.9) * 100;
1331
+ if (percentUsed < threshold) {
1332
+ console.log('[CollabClient] Storage usage below threshold, no cleanup needed');
1333
+ return;
1334
+ }
1335
+ // Strategy: Clear image cache first (non-critical data)
1336
+ if (typeof caches !== 'undefined') {
1337
+ try {
1338
+ const cacheNames = await caches.keys();
1339
+ for (const name of cacheNames) {
1340
+ if (name.includes('image')) {
1341
+ console.log('[CollabClient] Clearing image cache:', name);
1342
+ await caches.delete(name);
1343
+ }
1344
+ }
1345
+ }
1346
+ catch (error) {
1347
+ console.error('[CollabClient] Failed to clear image caches:', error);
1348
+ }
1349
+ }
1350
+ // Check again
1351
+ const { percentUsed: newPercentUsed } = await this.getStorageInfo();
1352
+ if (newPercentUsed < threshold) {
1353
+ console.log('[CollabClient] Cleanup complete, storage usage:', newPercentUsed, '%');
1354
+ return;
1355
+ }
1356
+ // If still over threshold, implement LRU eviction for documents
1357
+ // This is a placeholder - would need to track document access times
1358
+ console.warn('[CollabClient] Storage still over threshold after cleanup');
1359
+ }
1360
+ /**
1361
+ * Check storage quota and perform cleanup if needed
1362
+ */
1363
+ async checkStorageQuota() {
1364
+ if (!this.offlineOptions.autoCleanup) {
1365
+ return;
1366
+ }
1367
+ const { percentUsed } = await this.getStorageInfo();
1368
+ const threshold = (this.offlineOptions.cleanupThreshold || 0.9) * 100;
1369
+ if (percentUsed >= threshold) {
1370
+ console.warn('[CollabClient] Storage quota exceeded:', percentUsed, '%');
1371
+ await this.performCleanup();
1372
+ }
1373
+ }
1374
+ /**
1375
+ * Get document content using a share token (public access)
1376
+ */
1377
+ async getPublicContent(spaceId, docId, shareToken) {
1378
+ const res = await fetch(`${this.baseUrl}/api/spaces/${spaceId}/documents/${docId}/content`, {
1379
+ headers: {
1380
+ "Share-Token": shareToken
1381
+ },
1382
+ });
1383
+ if (!res.ok) {
1384
+ throw new Error(`Failed to load public document: ${res.status}`);
1385
+ }
1386
+ return res.json();
1387
+ }
1388
+ }
1389
+ //# sourceMappingURL=collab-provider.js.map