@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,1871 @@
1
+
2
+ import { HocuspocusProvider, HocuspocusProviderWebsocket } from "@hocuspocus/provider";
3
+ import * as Yjs from "yjs";
4
+ import { IndexeddbPersistence } from "y-indexeddb";
5
+ import { UploadQueueManager, type PendingUpload } from "./upload-queue.js";
6
+
7
+ // Re-export Yjs as Y for compatibility
8
+ export const Y = Yjs;
9
+
10
+ type HocuspocusProviderConfiguration = ConstructorParameters<typeof HocuspocusProvider>[0];
11
+ type HocuspocusProviderWebsocketConfiguration = ConstructorParameters<typeof HocuspocusProviderWebsocket>[0];
12
+
13
+ // ----------------------------------------------------------------------
14
+ // Interfaces & Types
15
+ // ----------------------------------------------------------------------
16
+
17
+ export interface AuthClient {
18
+ getSession: () => Promise<{ session: { token: string } | null } | null>;
19
+ subscribe: (callback: (session: { session: { token: string } | null } | null) => void) => () => void;
20
+ }
21
+
22
+ // ----------------------------------------------------------------------
23
+ // Storage Abstraction
24
+ // ----------------------------------------------------------------------
25
+
26
+ export interface StorageAdapter {
27
+ getItem(key: string): Promise<string | null> | string | null;
28
+ setItem(key: string, value: string): Promise<void> | void;
29
+ removeItem(key: string): Promise<void> | void;
30
+ }
31
+
32
+ export class LocalStorageAdapter implements StorageAdapter {
33
+ getItem(key: string): string | null {
34
+ try {
35
+ return localStorage.getItem(key);
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ setItem(key: string, value: string): void {
42
+ try {
43
+ localStorage.setItem(key, value);
44
+ } catch {
45
+ // Ignore errors (quota exceeded, etc.)
46
+ }
47
+ }
48
+
49
+ removeItem(key: string): void {
50
+ try {
51
+ localStorage.removeItem(key);
52
+ } catch {
53
+ // Ignore errors
54
+ }
55
+ }
56
+ }
57
+
58
+ export class SessionStorageAdapter implements StorageAdapter {
59
+ getItem(key: string): string | null {
60
+ try {
61
+ return sessionStorage.getItem(key);
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ setItem(key: string, value: string): void {
68
+ try {
69
+ sessionStorage.setItem(key, value);
70
+ } catch {
71
+ // Ignore errors
72
+ }
73
+ }
74
+
75
+ removeItem(key: string): void {
76
+ try {
77
+ sessionStorage.removeItem(key);
78
+ } catch {
79
+ // Ignore errors
80
+ }
81
+ }
82
+ }
83
+
84
+ export class MemoryStorageAdapter implements StorageAdapter {
85
+ private store: Map<string, string> = new Map();
86
+
87
+ getItem(key: string): string | null {
88
+ return this.store.get(key) ?? null;
89
+ }
90
+
91
+ setItem(key: string, value: string): void {
92
+ this.store.set(key, value);
93
+ }
94
+
95
+ removeItem(key: string): void {
96
+ this.store.delete(key);
97
+ }
98
+ }
99
+
100
+ // ----------------------------------------------------------------------
101
+ // AuthManager
102
+ // ----------------------------------------------------------------------
103
+
104
+ export interface SessionData {
105
+ token: string;
106
+ user: { id: string; name: string; email: string; image?: string };
107
+ expiresAt: number; // Unix timestamp (seconds)
108
+ }
109
+
110
+ export interface AuthManagerOptions {
111
+ baseUrl: string;
112
+ storage?: StorageAdapter;
113
+ storageKey?: string;
114
+ autoRefresh?: boolean;
115
+ refreshBuffer?: number; // Seconds before expiry to refresh
116
+ }
117
+
118
+ export class AuthManager {
119
+ private baseUrl: string;
120
+ private storage: StorageAdapter;
121
+ private storageKey: string;
122
+ private autoRefresh: boolean;
123
+ private refreshBuffer: number;
124
+ private session: SessionData | null = null;
125
+ private subscribers: Set<(session: SessionData | null) => void> = new Set();
126
+ private refreshTimer: ReturnType<typeof setTimeout> | null = null;
127
+
128
+ constructor(options: AuthManagerOptions) {
129
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
130
+ this.storage = options.storage ?? new LocalStorageAdapter();
131
+ this.storageKey = options.storageKey ?? 'collab_session';
132
+ this.autoRefresh = options.autoRefresh ?? true;
133
+ this.refreshBuffer = options.refreshBuffer ?? 3600; // 1 hour default
134
+
135
+ // Restore session from storage
136
+ this.restoreSession();
137
+ }
138
+
139
+ private async restoreSession(): Promise<void> {
140
+ try {
141
+ const stored = await this.storage.getItem(this.storageKey);
142
+ if (stored) {
143
+ const session = JSON.parse(stored) as SessionData;
144
+
145
+ // Check if session is still valid
146
+ const now = Math.floor(Date.now() / 1000);
147
+ if (session.expiresAt > now) {
148
+ this.session = session;
149
+ this.scheduleRefresh();
150
+ this.notifySubscribers();
151
+ } else {
152
+ // Session expired, clear it
153
+ await this.storage.removeItem(this.storageKey);
154
+ }
155
+ }
156
+ } catch (e) {
157
+ console.error('[AuthManager] Failed to restore session:', e);
158
+ }
159
+ }
160
+
161
+ private async persistSession(session: SessionData | null): Promise<void> {
162
+ try {
163
+ if (session) {
164
+ await this.storage.setItem(this.storageKey, JSON.stringify(session));
165
+ } else {
166
+ await this.storage.removeItem(this.storageKey);
167
+ }
168
+ } catch (e) {
169
+ console.error('[AuthManager] Failed to persist session:', e);
170
+ }
171
+ }
172
+
173
+ private scheduleRefresh(): void {
174
+ if (!this.autoRefresh || !this.session) return;
175
+
176
+ // Clear existing timer
177
+ if (this.refreshTimer) {
178
+ clearTimeout(this.refreshTimer);
179
+ }
180
+
181
+ const now = Math.floor(Date.now() / 1000);
182
+ const timeUntilRefresh = (this.session.expiresAt - this.refreshBuffer) - now;
183
+
184
+ if (timeUntilRefresh > 0) {
185
+ this.refreshTimer = setTimeout(() => {
186
+ this.refreshSession();
187
+ }, timeUntilRefresh * 1000);
188
+ }
189
+ }
190
+
191
+ private async refreshSession(): Promise<void> {
192
+ if (!this.session) return;
193
+
194
+ try {
195
+ const res = await fetch(`${this.baseUrl}/api/auth/session`, {
196
+ headers: {
197
+ 'Authorization': `Bearer ${this.session.token}`
198
+ }
199
+ });
200
+
201
+ if (res.ok) {
202
+ const data = await res.json();
203
+ if (data.session?.token && data.user) {
204
+ const newSession: SessionData = {
205
+ token: data.session.token,
206
+ user: {
207
+ id: data.user.id,
208
+ name: data.user.name,
209
+ email: data.user.email,
210
+ image: data.user.image
211
+ },
212
+ expiresAt: data.session.expiresAt
213
+ };
214
+ this.session = newSession;
215
+ await this.persistSession(newSession);
216
+ this.scheduleRefresh();
217
+ this.notifySubscribers();
218
+ }
219
+ }
220
+ } catch (e) {
221
+ console.error('[AuthManager] Failed to refresh session:', e);
222
+ }
223
+ }
224
+
225
+ private notifySubscribers(): void {
226
+ this.subscribers.forEach(callback => callback(this.session));
227
+ }
228
+
229
+ async login(email: string, password: string): Promise<SessionData> {
230
+ const res = await fetch(`${this.baseUrl}/api/auth/sign-in/email`, {
231
+ method: 'POST',
232
+ headers: {
233
+ 'Content-Type': 'application/json',
234
+ 'Origin': this.baseUrl
235
+ },
236
+ body: JSON.stringify({ email, password })
237
+ });
238
+
239
+ if (!res.ok) {
240
+ const error = await res.json().catch(() => ({ message: res.statusText }));
241
+ throw new Error(error.message || 'Login failed');
242
+ }
243
+
244
+ const data = await res.json();
245
+
246
+ // Better Auth can return token in different formats
247
+ const token = data.token || data.session?.token;
248
+ const expiresAt = data.session?.expiresAt || data.expiresAt;
249
+
250
+ if (!token || !data.user) {
251
+ throw new Error('Invalid response from server');
252
+ }
253
+
254
+ const session: SessionData = {
255
+ token,
256
+ user: {
257
+ id: data.user.id,
258
+ name: data.user.name,
259
+ email: data.user.email,
260
+ image: data.user.image
261
+ },
262
+ expiresAt: expiresAt || (Math.floor(Date.now() / 1000) + 7200) // 2 hours default
263
+ };
264
+
265
+ this.session = session;
266
+ await this.persistSession(session);
267
+ this.scheduleRefresh();
268
+ this.notifySubscribers();
269
+
270
+ return session;
271
+ }
272
+
273
+ async signup(email: string, password: string, name: string): Promise<SessionData> {
274
+ const res = await fetch(`${this.baseUrl}/api/auth/sign-up/email`, {
275
+ method: 'POST',
276
+ headers: {
277
+ 'Content-Type': 'application/json',
278
+ 'Origin': this.baseUrl
279
+ },
280
+ body: JSON.stringify({ email, password, name })
281
+ });
282
+
283
+ if (!res.ok) {
284
+ const error = await res.json().catch(() => ({ message: res.statusText }));
285
+ throw new Error(error.message || 'Signup failed');
286
+ }
287
+
288
+ const data = await res.json();
289
+
290
+ // Better Auth can return token in different formats
291
+ const token = data.token || data.session?.token;
292
+ const expiresAt = data.session?.expiresAt || data.expiresAt;
293
+
294
+ if (!token || !data.user) {
295
+ throw new Error('Invalid response from server');
296
+ }
297
+
298
+ const session: SessionData = {
299
+ token,
300
+ user: {
301
+ id: data.user.id,
302
+ name: data.user.name,
303
+ email: data.user.email,
304
+ image: data.user.image
305
+ },
306
+ expiresAt: expiresAt || (Math.floor(Date.now() / 1000) + 7200) // 2 hours default
307
+ };
308
+
309
+ this.session = session;
310
+ await this.persistSession(session);
311
+ this.scheduleRefresh();
312
+ this.notifySubscribers();
313
+
314
+ return session;
315
+ }
316
+
317
+ async logout(): Promise<void> {
318
+ if (!this.session) return;
319
+
320
+ try {
321
+ await fetch(`${this.baseUrl}/api/auth/sign-out`, {
322
+ method: 'POST',
323
+ headers: {
324
+ 'Authorization': `Bearer ${this.session.token}`
325
+ }
326
+ });
327
+ } catch (e) {
328
+ console.error('[AuthManager] Logout request failed:', e);
329
+ }
330
+
331
+ this.session = null;
332
+ await this.persistSession(null);
333
+
334
+ if (this.refreshTimer) {
335
+ clearTimeout(this.refreshTimer);
336
+ this.refreshTimer = null;
337
+ }
338
+
339
+ this.notifySubscribers();
340
+ }
341
+
342
+ getSession(): SessionData | null {
343
+ return this.session;
344
+ }
345
+
346
+ getToken(): string | null {
347
+ return this.session?.token ?? null;
348
+ }
349
+
350
+ subscribe(callback: (session: SessionData | null) => void): () => void {
351
+ this.subscribers.add(callback);
352
+ return () => this.subscribers.delete(callback);
353
+ }
354
+
355
+ // AuthClient compatibility methods
356
+ async getSessionCompat(): Promise<{ session: { token: string } | null } | null> {
357
+ if (!this.session) return null;
358
+ return {
359
+ session: {
360
+ token: this.session.token
361
+ }
362
+ };
363
+ }
364
+
365
+ subscribeCompat(callback: (session: { session: { token: string } | null } | null) => void): () => void {
366
+ const wrappedCallback = (session: SessionData | null) => {
367
+ callback(session ? { session: { token: session.token } } : null);
368
+ };
369
+ return this.subscribe(wrappedCallback);
370
+ }
371
+ }
372
+
373
+ export interface ManagedAuthOptions {
374
+ storage?: StorageAdapter;
375
+ autoRefresh?: boolean;
376
+ refreshBuffer?: number; // Seconds before expiry to refresh (default: 3600)
377
+ }
378
+
379
+ export interface OfflineOptions {
380
+ enablePersistence?: boolean;
381
+ enableImageCache?: boolean;
382
+ enableUploadQueue?: boolean;
383
+ persistenceDbName?: string;
384
+ maxCacheSize?: number; // MB
385
+ maxDocuments?: number;
386
+ autoCleanup?: boolean;
387
+ cleanupThreshold?: number; // 0-1, e.g., 0.9 for 90%
388
+ }
389
+
390
+ export interface CollabClientOptions {
391
+ baseUrl: string;
392
+
393
+ // Optional: Provide custom auth implementation
394
+ // If not provided, uses managed auth by default
395
+ authClient?: AuthClient;
396
+
397
+ // Optional: Configure managed auth (only used if authClient not provided)
398
+ managedAuth?: ManagedAuthOptions;
399
+
400
+ // Optional: Configure offline features
401
+ offline?: OfflineOptions;
402
+ }
403
+
404
+ export interface ClientExtensionDefinition {
405
+ name: string;
406
+ options?: Record<string, any>;
407
+ scriptUrl?: string;
408
+ }
409
+
410
+ export interface Document {
411
+ id: string;
412
+ name: string;
413
+ spaceId: string;
414
+ parentId: string | null;
415
+ position: number;
416
+ createdBy: string;
417
+ createdAt?: string;
418
+ updatedAt?: string;
419
+ childrenCount?: number;
420
+ }
421
+
422
+ export interface DocumentTreeNode extends Document {
423
+ children: DocumentTreeNode[];
424
+ depth: number;
425
+ }
426
+
427
+ export interface Space {
428
+ id: string;
429
+ name: string;
430
+ slug: string;
431
+ logo?: string | null;
432
+ createdAt: string;
433
+ role: string;
434
+ memberCount?: number;
435
+ documentCount?: number;
436
+ }
437
+
438
+ export interface SpaceMember {
439
+ id: string;
440
+ userId: string;
441
+ role: string;
442
+ user: {
443
+ name: string;
444
+ email: string;
445
+ image?: string;
446
+ };
447
+ }
448
+
449
+ export interface FileUpload {
450
+ id: string;
451
+ url: string;
452
+ filename: string;
453
+ mimeType: string;
454
+ size: number;
455
+ }
456
+
457
+ export interface DocumentSnapshot {
458
+ id: string;
459
+ createdAt: string;
460
+ createdBy: string;
461
+ size: number;
462
+ }
463
+
464
+ export interface PaginationInfo {
465
+ total: number;
466
+ limit: number;
467
+ offset: number;
468
+ hasMore: boolean;
469
+ }
470
+
471
+ export interface CommentAnchor {
472
+ from: number;
473
+ to: number;
474
+ text: string;
475
+ }
476
+
477
+ export interface Comment {
478
+ id: string;
479
+ documentId: string;
480
+ spaceId: string;
481
+ parentId: string | null;
482
+ authorId: string;
483
+ authorName: string;
484
+ content: string;
485
+ anchor: CommentAnchor | null;
486
+ resolved: boolean;
487
+ resolvedBy: string | null;
488
+ resolvedAt: string | null;
489
+ createdAt: string;
490
+ updatedAt: string;
491
+ }
492
+
493
+ export interface CommentThread extends Comment {
494
+ replies: Comment[];
495
+ }
496
+
497
+ export interface CommentEvent {
498
+ type: "comment" | "comment_error";
499
+ action: string;
500
+ data?: any;
501
+ error?: string;
502
+ userId: string;
503
+ timestamp?: number;
504
+ }
505
+
506
+ // ----------------------------------------------------------------------
507
+ // PersistenceManager (IndexedDB Persistence)
508
+ // ----------------------------------------------------------------------
509
+
510
+ export interface PersistenceManagerOptions {
511
+ documentName: string;
512
+ document: Yjs.Doc;
513
+ enablePersistence?: boolean;
514
+ persistenceDbName?: string;
515
+ }
516
+
517
+ export class PersistenceManager {
518
+ private persistence: IndexeddbPersistence | null = null;
519
+ private isReady: boolean = false;
520
+ private readyPromise: Promise<void>;
521
+ private listeners: Set<() => void> = new Set();
522
+
523
+ constructor(options: PersistenceManagerOptions) {
524
+ const { documentName, document, enablePersistence = true, persistenceDbName = 'y-collab' } = options;
525
+
526
+ if (!enablePersistence || typeof indexedDB === 'undefined') {
527
+ this.readyPromise = Promise.resolve();
528
+ this.isReady = true;
529
+ console.log('[PersistenceManager] Persistence disabled or IndexedDB unavailable');
530
+ return;
531
+ }
532
+
533
+ // Check for private browsing mode
534
+ try {
535
+ const test = indexedDB.open('test');
536
+ test.onerror = () => {
537
+ console.warn('[PersistenceManager] IndexedDB not available (private browsing?)');
538
+ this.readyPromise = Promise.resolve();
539
+ this.isReady = true;
540
+ };
541
+ } catch (e) {
542
+ console.warn('[PersistenceManager] IndexedDB check failed:', e);
543
+ this.readyPromise = Promise.resolve();
544
+ this.isReady = true;
545
+ return;
546
+ }
547
+
548
+ console.log(`[PersistenceManager] Initializing for ${documentName} in database ${persistenceDbName}`);
549
+
550
+ this.persistence = new IndexeddbPersistence(documentName, document);
551
+
552
+ this.readyPromise = new Promise<void>((resolve) => {
553
+ if (!this.persistence) {
554
+ resolve();
555
+ return;
556
+ }
557
+
558
+ this.persistence.on('synced', () => {
559
+ console.log(`[PersistenceManager] Synced from IndexedDB for ${documentName}`);
560
+ this.isReady = true;
561
+ this.notifyListeners();
562
+ resolve();
563
+ });
564
+ });
565
+ }
566
+
567
+ public waitForReady(): Promise<void> {
568
+ return this.readyPromise;
569
+ }
570
+
571
+ public isReadySync(): boolean {
572
+ return this.isReady;
573
+ }
574
+
575
+ public onReady(callback: () => void): () => void {
576
+ this.listeners.add(callback);
577
+ if (this.isReady) {
578
+ callback();
579
+ }
580
+ return () => this.listeners.delete(callback);
581
+ }
582
+
583
+ private notifyListeners(): void {
584
+ this.listeners.forEach(callback => callback());
585
+ }
586
+
587
+ public async clearData(): Promise<void> {
588
+ if (this.persistence) {
589
+ try {
590
+ await this.persistence.clearData();
591
+ console.log('[PersistenceManager] Cleared local data');
592
+ } catch (e) {
593
+ console.error('[PersistenceManager] Failed to clear data:', e);
594
+ }
595
+ }
596
+ }
597
+
598
+ public destroy(): void {
599
+ if (this.persistence) {
600
+ this.persistence.destroy();
601
+ this.persistence = null;
602
+ }
603
+ this.listeners.clear();
604
+ }
605
+ }
606
+
607
+ // ----------------------------------------------------------------------
608
+ // CollabProviderWebsocket (Shared Connection)
609
+ // ----------------------------------------------------------------------
610
+
611
+ export interface CollabProviderWebsocketOptions extends Omit<HocuspocusProviderWebsocketConfiguration, "url"> {
612
+ baseUrl: string;
613
+ spaceId: string;
614
+ auth?: AuthClient; // Optional for standalone usage, but Client usually provides it
615
+ token?: string;
616
+ }
617
+
618
+ export class CollabProviderWebsocket extends HocuspocusProviderWebsocket {
619
+ private _authUnsubscribe: (() => void) | null = null;
620
+ readonly spaceId: string;
621
+ private _token: string | undefined;
622
+ private _readyPromise: Promise<void>;
623
+ private _readyResolve!: () => void;
624
+ private _spaceDoc: any; // Y.Doc for the space itself
625
+
626
+ constructor(options: CollabProviderWebsocketOptions) {
627
+ const { baseUrl, spaceId, auth, token, ...config } = options;
628
+
629
+ // Connect to the collaboration endpoint with spaceId for routing
630
+ // Matches server route /collaboration/:documentName
631
+ const url = `${baseUrl.replace(/^http/, 'ws')}/collaboration/space:${spaceId}`;
632
+ console.log(`[CollabProviderWebsocket] Creating with URL: ${url}, token: ${token ? 'present' : 'missing'}`);
633
+
634
+ // HocuspocusProviderWebsocket is just a WebSocket wrapper
635
+ // Individual HocuspocusProvider instances will attach and send sync messages
636
+ super({
637
+ url: (token ? `${url}?token=${token}` : url),
638
+ ...config,
639
+ });
640
+
641
+ this._spaceDoc = null; // No space doc needed for the WebSocket wrapper
642
+
643
+ this._token = token;
644
+ this.spaceId = spaceId;
645
+
646
+ // Set up ready promise
647
+ this._readyPromise = new Promise<void>((resolve) => {
648
+ this._readyResolve = resolve;
649
+
650
+ // Also resolve immediately if already connected
651
+ if ((this as any).status === 'connected') {
652
+ resolve();
653
+ }
654
+ });
655
+
656
+ // Listen for connection status
657
+ this.on('status', ({ status }: any) => {
658
+ console.log(`[CollabProviderWebsocket] Status changed to: ${status}`);
659
+ if (status === 'connected' && this._readyResolve) {
660
+ this._readyResolve();
661
+ }
662
+ });
663
+
664
+ // Debug: Listen to all events
665
+ this.on('synced', ({ state }: any) => {
666
+ console.log(`[CollabProviderWebsocket] Synced state: ${state}`);
667
+ });
668
+
669
+ // Message event listener removed - was causing blocking due to JSON.stringify
670
+
671
+ this.on('authenticationFailed', ({ reason }: any) => {
672
+ console.error(`[CollabProviderWebsocket] Authentication failed: ${reason}`);
673
+ });
674
+
675
+ // Awareness update listener removed - was causing blocking
676
+
677
+ if (auth && !token) {
678
+ // Only setup async auth if we don't have a token yet
679
+ console.log('[CollabProviderWebsocket] No token, setting up auth');
680
+ this.setupAuth(auth);
681
+ } else if (auth) {
682
+ // Setup auth subscription for token refresh, but don't fetch initially
683
+ console.log('[CollabProviderWebsocket] Token provided, setting up auth subscription only');
684
+ this._authUnsubscribe = auth.subscribe(async (session: any) => {
685
+ const newToken = session?.session?.token;
686
+ if (newToken && newToken !== this._token) {
687
+ this.updateToken(newToken);
688
+ }
689
+ });
690
+ }
691
+ }
692
+
693
+ /**
694
+ * Wait for the websocket to be connected and ready
695
+ */
696
+ public async waitForReady(): Promise<void> {
697
+ // If already connected, resolve immediately
698
+ if ((this as any).status === 'connected') {
699
+ return Promise.resolve();
700
+ }
701
+ return this._readyPromise;
702
+ }
703
+
704
+ private async setupAuth(auth: AuthClient) {
705
+ // Subscribe to session changes
706
+ this._authUnsubscribe = auth.subscribe(async (session: any) => {
707
+ const token = session?.session?.token;
708
+ console.log('[CollabProviderWebsocket] Auth subscription callback, token:', token ? 'present' : 'missing');
709
+ if (token && token !== this._token) {
710
+ this.updateToken(token);
711
+ }
712
+ });
713
+
714
+ // Initial fetch if no token provided yet
715
+ if (!this._token) {
716
+ console.log('[CollabProviderWebsocket] Fetching initial session...');
717
+ const session = await auth.getSession();
718
+ console.log('[CollabProviderWebsocket] Session fetched, token:', session?.session?.token ? 'present' : 'missing');
719
+ if (session?.session?.token) {
720
+ this.updateToken(session.session.token);
721
+ // Connect now that we have the token
722
+ console.log('[CollabProviderWebsocket] Calling connect()');
723
+ this.connect();
724
+ }
725
+ }
726
+ }
727
+
728
+ private updateToken(newToken: string) {
729
+ this._token = newToken;
730
+ // HocuspocusProviderWebsocket doesn't expose a clean way to update query params on the fly
731
+ // without reconnecting. We assume the server handles auth on connect.
732
+ // If we want to support seamless token rotation, we might need to send a message or reconnect.
733
+ // For now, we update the URL generic so next reconnect works.
734
+ const base = this.configuration.url.toString().split('?')[0];
735
+ this.configuration.url = `${base}?token=${newToken}`;
736
+
737
+ // If we are currently disconnected/connecting, this helps.
738
+ // If connected, the socket stays open until it expires or we force it.
739
+ // Some setups prefer forcing reconnect on token change:
740
+ // this.disconnect();
741
+ // this.connect();
742
+ }
743
+
744
+ public getToken() {
745
+ return this._token;
746
+ }
747
+
748
+ public getSpaceDoc() {
749
+ return this._spaceDoc;
750
+ }
751
+
752
+ destroy() {
753
+ if (this._authUnsubscribe) {
754
+ this._authUnsubscribe();
755
+ }
756
+ super.destroy();
757
+ }
758
+ }
759
+
760
+ // ----------------------------------------------------------------------
761
+ // CollabProvider (Single Document Provider)
762
+ // ----------------------------------------------------------------------
763
+
764
+ export interface CollabProviderOptions {
765
+ // Option A: Pass a shared socket (can be base class or our extended class)
766
+ websocketProvider?: HocuspocusProviderWebsocket | CollabProviderWebsocket;
767
+
768
+ // Option B: Standalone mode
769
+ baseUrl?: string;
770
+ spaceId?: string;
771
+ documentId: string;
772
+ auth?: AuthClient;
773
+ token?: string | null | (() => string) | (() => Promise<string>);
774
+
775
+ // Offline/Persistence options
776
+ enablePersistence?: boolean;
777
+ persistenceDbName?: string;
778
+
779
+ // Additional provider options
780
+ document?: any;
781
+ awareness?: any;
782
+ onConnect?: () => void;
783
+ onSynced?: (data: any) => void;
784
+ onStatus?: (data: any) => void;
785
+ onAuthenticated?: (data: any) => void;
786
+ onAuthenticationFailed?: (data: any) => void;
787
+ onOpen?: (data: any) => void;
788
+ onMessage?: (data: any) => void;
789
+ onOutgoingMessage?: (data: any) => void;
790
+ onDisconnect?: (data: any) => void;
791
+ onClose?: (data: any) => void;
792
+ onDestroy?: () => void;
793
+ onAwarenessUpdate?: (data: any) => void;
794
+ onAwarenessChange?: (data: any) => void;
795
+ onStateless?: (data: any) => void;
796
+ forceSyncInterval?: number | false;
797
+ }
798
+
799
+ /**
800
+ * HocuspocusProvider wrapper that handles constructing the correct name/url
801
+ * based on our platform's conventions.
802
+ */
803
+ export class CollabProvider extends HocuspocusProvider {
804
+ private _authUnsubscribe: (() => void) | null = null;
805
+ readonly documentId: string;
806
+ private persistenceManager: PersistenceManager | null = null;
807
+
808
+ constructor(options: CollabProviderOptions) {
809
+ const {
810
+ baseUrl,
811
+ spaceId,
812
+ documentId,
813
+ websocketProvider,
814
+ auth,
815
+ token,
816
+ enablePersistence = true,
817
+ persistenceDbName,
818
+ ...providerOptions
819
+ } = options;
820
+
821
+ let config: HocuspocusProviderConfiguration;
822
+ let parentToken = token;
823
+
824
+ // Mode 1: Multiplexing via Shared Socket
825
+ if (websocketProvider) {
826
+ const finalSpaceId = spaceId || (websocketProvider instanceof CollabProviderWebsocket ? websocketProvider.spaceId : spaceId);
827
+ const fullName = `space:${finalSpaceId}:doc:${documentId}`;
828
+ parentToken = parentToken || (websocketProvider instanceof CollabProviderWebsocket ? websocketProvider.getToken() : undefined);
829
+
830
+ console.log(`[CollabProvider] Creating in multiplexing mode`);
831
+ console.log(`[CollabProvider] Document name: ${fullName}`);
832
+ console.log(`[CollabProvider] WebsocketProvider status:`, (websocketProvider as any).status);
833
+ console.log(`[CollabProvider] WebsocketProvider URL:`, (websocketProvider as any).configuration?.url);
834
+ console.log(`[CollabProvider] Options passed:`, providerOptions);
835
+
836
+ config = {
837
+ name: fullName,
838
+ websocketProvider: websocketProvider as any,
839
+ token: parentToken,
840
+ ...providerOptions
841
+ } as any;
842
+
843
+ console.log(`[CollabProvider] Final config:`, config);
844
+ }
845
+ // Mode 2: Standalone Connection
846
+ else if (baseUrl && spaceId) {
847
+ const fullName = `space:${spaceId}:doc:${documentId}`;
848
+ const url = `${baseUrl.replace(/^http/, 'ws')}/collaboration/${fullName}`;
849
+
850
+ console.log(`[CollabProvider] Creating in standalone mode`);
851
+ console.log(`[CollabProvider] URL: ${url}`);
852
+ console.log(`[CollabProvider] Document name: ${fullName}`);
853
+
854
+ config = {
855
+ url,
856
+ name: fullName,
857
+ token,
858
+ ...providerOptions
859
+ };
860
+ } else {
861
+ throw new Error("CollabProvider requires either 'websocketProvider' or 'baseUrl' + 'spaceId'");
862
+ }
863
+
864
+ super(config);
865
+ this.documentId = documentId;
866
+ console.log(`[CollabProvider] Created, status:`, (this as any).status);
867
+ console.log(`[CollabProvider] Awareness instance present:`, !!this.awareness);
868
+ console.log(`[CollabProvider] Awareness check:`, this.awareness);
869
+
870
+ // Initialize persistence
871
+ const fullName = config.name as string;
872
+ this.persistenceManager = new PersistenceManager({
873
+ documentName: fullName,
874
+ document: this.document,
875
+ enablePersistence,
876
+ persistenceDbName
877
+ });
878
+
879
+ // When using websocketProvider (multiplexing), we must manually attach
880
+ // The parent constructor only calls attach() if manageSocket is true (standalone mode)
881
+ // The attach() method will automatically call onOpen() if the websocket is already connected
882
+ if (websocketProvider) {
883
+ console.log(`[CollabProvider] Attaching to parent websocket`);
884
+ console.time('[CollabProvider] attach()');
885
+ this.attach();
886
+ console.timeEnd('[CollabProvider] attach()');
887
+ }
888
+
889
+ // Debug: Listen to events
890
+ this.on('status', ({ status }: any) => {
891
+ const timestamp = new Date().toISOString()
892
+ console.log(`[CollabProvider] ${timestamp} Status changed to: ${status}`, {
893
+ documentId: this.documentId,
894
+ status: status
895
+ });
896
+ });
897
+
898
+ this.on('synced', ({ state }: any) => {
899
+ const timestamp = new Date().toISOString()
900
+ console.log(`[CollabProvider] ${timestamp} Synced state: ${state}`, {
901
+ documentId: this.documentId,
902
+ synced: state
903
+ });
904
+ });
905
+
906
+ // Message event listener removed - was causing blocking
907
+
908
+ this.on('authenticationFailed', ({ reason }: any) => {
909
+ const timestamp = new Date().toISOString()
910
+ console.error(`[CollabProvider] ${timestamp} Authentication failed: ${reason}`, {
911
+ documentId: this.documentId,
912
+ reason: reason
913
+ });
914
+ });
915
+
916
+ // Awareness logging removed - was causing blocking
917
+
918
+ // Setup auth watchers
919
+ if (!websocketProvider && auth) {
920
+ this.setupAuth(auth);
921
+ } else if (websocketProvider && websocketProvider instanceof CollabProviderWebsocket) {
922
+ // In multiplexed mode, we might want to listen to the parent socket's token changes?
923
+ // Actually, HocuspocusProvider reads `this.configuration.token` during authentication steps.
924
+ // We should ensure `this.configuration.token` is kept fresh.
925
+ // Currently Hocuspocus doesn't re-read it actively unless we push it.
926
+ }
927
+ }
928
+
929
+ private async setupAuth(auth: AuthClient) {
930
+ this._authUnsubscribe = auth.subscribe(async (session: any) => {
931
+ const token = session?.session?.token;
932
+ if (token) {
933
+ this.setToken(token);
934
+ }
935
+ });
936
+
937
+ const session = await auth.getSession();
938
+ if (session?.session?.token) {
939
+ this.setToken(session.session.token);
940
+ }
941
+ }
942
+
943
+ public setToken(token: string) {
944
+ this.configuration.token = token;
945
+ // If connected, send auth message
946
+ if ((this as any).status === "connected") {
947
+ this.sendStateless(JSON.stringify({ type: "auth", token: token }));
948
+ }
949
+ }
950
+
951
+ destroy() {
952
+ if (this._authUnsubscribe) {
953
+ this._authUnsubscribe();
954
+ }
955
+ if (this.persistenceManager) {
956
+ this.persistenceManager.destroy();
957
+ }
958
+ super.destroy();
959
+ }
960
+
961
+ /**
962
+ * Wait for persistence to be ready
963
+ */
964
+ public async waitForPersistence(): Promise<void> {
965
+ if (this.persistenceManager) {
966
+ return this.persistenceManager.waitForReady();
967
+ }
968
+ }
969
+
970
+ /**
971
+ * Check if persistence is ready
972
+ */
973
+ public isPersistenceReady(): boolean {
974
+ return this.persistenceManager?.isReadySync() ?? false;
975
+ }
976
+
977
+ /**
978
+ * Clear local persistence data
979
+ */
980
+ public async clearLocalData(): Promise<void> {
981
+ if (this.persistenceManager) {
982
+ return this.persistenceManager.clearData();
983
+ }
984
+ }
985
+
986
+ /**
987
+ * Send a comment action via stateless message
988
+ */
989
+ public sendComment(action: "create" | "update" | "delete" | "resolve", data: any) {
990
+ this.sendStateless(JSON.stringify({
991
+ type: "comment",
992
+ action,
993
+ data
994
+ }));
995
+ }
996
+
997
+ /**
998
+ * Listen for real-time comment events
999
+ */
1000
+ public onComment(callback: (event: CommentEvent) => void): () => void {
1001
+ const handler = (payload: any) => {
1002
+ try {
1003
+ const msg = JSON.parse(payload.payload);
1004
+ if (msg.type === "comment" || msg.type === "comment_error") {
1005
+ callback(msg);
1006
+ }
1007
+ } catch (e) {
1008
+ // Ignore parsing errors
1009
+ }
1010
+ };
1011
+ this.on("stateless", handler);
1012
+ return () => this.off("stateless", handler);
1013
+ }
1014
+ }
1015
+
1016
+ // ----------------------------------------------------------------------
1017
+ // CollabClient (Full SDK)
1018
+ // ----------------------------------------------------------------------
1019
+
1020
+ export class CollabClient {
1021
+ readonly baseUrl: string;
1022
+ readonly auth: AuthClient;
1023
+ private readonly authManager: AuthManager | null = null;
1024
+ private readonly offlineOptions: OfflineOptions;
1025
+
1026
+ // Cache for shared websockets by spaceId
1027
+ private _sockets: Map<string, CollabProviderWebsocket> = new Map();
1028
+ private _token: string | null = null;
1029
+
1030
+ // Upload queue for offline uploads
1031
+ private uploadQueue: UploadQueueManager | null = null;
1032
+
1033
+ constructor(options: CollabClientOptions) {
1034
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
1035
+ this.offlineOptions = {
1036
+ enablePersistence: true,
1037
+ enableImageCache: true,
1038
+ enableUploadQueue: true,
1039
+ persistenceDbName: 'y-collab',
1040
+ maxCacheSize: 50, // MB
1041
+ maxDocuments: 20,
1042
+ autoCleanup: true,
1043
+ cleanupThreshold: 0.9,
1044
+ ...options.offline
1045
+ };
1046
+
1047
+ if (options.authClient) {
1048
+ // Custom auth implementation
1049
+ this.auth = options.authClient;
1050
+ } else {
1051
+ // Managed auth (default)
1052
+ this.authManager = new AuthManager({
1053
+ baseUrl: this.baseUrl,
1054
+ storage: options.managedAuth?.storage,
1055
+ autoRefresh: options.managedAuth?.autoRefresh,
1056
+ refreshBuffer: options.managedAuth?.refreshBuffer
1057
+ });
1058
+
1059
+ this.auth = {
1060
+ getSession: () => this.authManager!.getSessionCompat(),
1061
+ subscribe: (callback) => this.authManager!.subscribeCompat(callback)
1062
+ };
1063
+ }
1064
+
1065
+ this.auth.subscribe((session) => {
1066
+ this._token = session?.session?.token || null;
1067
+ });
1068
+
1069
+ this.auth.getSession().then(s => {
1070
+ this._token = s?.session?.token || null;
1071
+ });
1072
+
1073
+ // Initialize upload queue
1074
+ if (this.offlineOptions.enableUploadQueue && typeof indexedDB !== 'undefined') {
1075
+ this.uploadQueue = new UploadQueueManager();
1076
+ this.uploadQueue.init().catch((error: any) => {
1077
+ console.error('[CollabClient] Failed to initialize upload queue:', error);
1078
+ this.uploadQueue = null;
1079
+ });
1080
+
1081
+ // Process queue when client is created and online
1082
+ if (navigator.onLine) {
1083
+ setTimeout(() => {
1084
+ this.processUploadQueue().catch(error => {
1085
+ console.error('[CollabClient] Failed to process upload queue:', error);
1086
+ });
1087
+ }, 1000);
1088
+ }
1089
+ }
1090
+ }
1091
+
1092
+ // Convenience methods (only available when using managed auth)
1093
+ async login(email: string, password: string): Promise<SessionData> {
1094
+ if (!this.authManager) {
1095
+ throw new Error('login() is only available in managed auth mode');
1096
+ }
1097
+ return this.authManager.login(email, password);
1098
+ }
1099
+
1100
+ async signup(email: string, password: string, name: string): Promise<SessionData> {
1101
+ if (!this.authManager) {
1102
+ throw new Error('signup() is only available in managed auth mode');
1103
+ }
1104
+ return this.authManager.signup(email, password, name);
1105
+ }
1106
+
1107
+ async logout(): Promise<void> {
1108
+ if (!this.authManager) {
1109
+ throw new Error('logout() is only available in managed auth mode');
1110
+ }
1111
+ return this.authManager.logout();
1112
+ }
1113
+
1114
+ getSession(): SessionData | null {
1115
+ if (!this.authManager) {
1116
+ throw new Error('getSession() is only available in managed auth mode');
1117
+ }
1118
+ return this.authManager.getSession();
1119
+ }
1120
+
1121
+ private async getToken(): Promise<string> {
1122
+ if (this._token) return this._token;
1123
+ const session = await this.auth.getSession();
1124
+ this._token = session?.session?.token || null;
1125
+ if (!this._token) {
1126
+ throw new Error("Not authenticated");
1127
+ }
1128
+ return this._token;
1129
+ }
1130
+
1131
+ private async fetch<T>(path: string, options: RequestInit = {}): Promise<T> {
1132
+ const token = await this.getToken();
1133
+ const headers = new Headers(options.headers);
1134
+ headers.set("Authorization", `Bearer ${token}`);
1135
+ if (!headers.has("Content-Type") && !(options.body instanceof FormData)) {
1136
+ headers.set("Content-Type", "application/json");
1137
+ }
1138
+
1139
+ const res = await fetch(`${this.baseUrl}/api${path}`, {
1140
+ ...options,
1141
+ headers,
1142
+ });
1143
+
1144
+ if (!res.ok) {
1145
+ const error = await res.json().catch(() => ({ message: res.statusText }));
1146
+ throw new Error(error.message || error.error || `Request failed: ${res.status}`);
1147
+ }
1148
+
1149
+ // Return empty for 204
1150
+ if (res.status === 204) return {} as T;
1151
+
1152
+ return res.json();
1153
+ }
1154
+
1155
+ // --- Extensions ---
1156
+
1157
+ public async getExtensions(deps?: Record<string, any>): Promise<any[]> {
1158
+ // Make dependencies available for extension scripts that use bare specifiers
1159
+ if (deps) {
1160
+ (globalThis as any).__collab_deps = { ...(globalThis as any).__collab_deps, ...deps };
1161
+ }
1162
+
1163
+ const res = await this.fetch<{ extensions: ClientExtensionDefinition[] }>("/extensions/config");
1164
+ const defs = res.extensions || [];
1165
+ const loadedExtensions: any[] = [];
1166
+
1167
+ for (const def of defs) {
1168
+ if (def.scriptUrl) {
1169
+ try {
1170
+ const fullUrl = new URL(def.scriptUrl, this.baseUrl).toString();
1171
+ // Fetch script text and rewrite bare specifiers to use provided deps
1172
+ const response = await fetch(fullUrl);
1173
+ const scriptText = await response.text();
1174
+
1175
+ // Replace bare module specifiers with global deps lookup
1176
+ const rewritten = scriptText.replace(
1177
+ /import\s*\{([^}]+)\}\s*from\s*['"]([^./][^'"]*)['"]/g,
1178
+ (_match, imports, specifier) => {
1179
+ return `const {${imports}} = globalThis.__collab_deps?.['${specifier}'] ?? (() => { throw new Error('Missing dependency: ${specifier}') })()`;
1180
+ }
1181
+ );
1182
+
1183
+ const blob = new Blob([rewritten], { type: 'application/javascript' });
1184
+ const blobUrl = URL.createObjectURL(blob);
1185
+
1186
+ try {
1187
+ // @ts-ignore
1188
+ const module = await import(/* @vite-ignore */ blobUrl);
1189
+
1190
+ if (module.default) {
1191
+ if (typeof module.default.configure === 'function') {
1192
+ loadedExtensions.push(module.default.configure(def.options || {}));
1193
+ } else {
1194
+ loadedExtensions.push(module.default);
1195
+ }
1196
+ }
1197
+ } finally {
1198
+ URL.revokeObjectURL(blobUrl);
1199
+ }
1200
+ } catch (e) {
1201
+ console.error(`[CollabClient] Failed to load extension ${def.name}`, e);
1202
+ }
1203
+ }
1204
+ }
1205
+ return loadedExtensions;
1206
+ }
1207
+
1208
+ // --- WebSocket / Provider Factory ---
1209
+
1210
+ public async getWebsocketProvider(spaceId: string): Promise<CollabProviderWebsocket> {
1211
+ const cached = this._sockets.get(spaceId);
1212
+
1213
+ // Check if cached socket exists and is still valid (not destroyed)
1214
+ if (cached) {
1215
+ const status = (cached as any).status;
1216
+ const shouldConnect = (cached as any).shouldConnect;
1217
+
1218
+ // Only reuse if the websocket hasn't been destroyed (shouldConnect should be true for active sockets)
1219
+ if (shouldConnect !== false && status !== 'destroyed') {
1220
+ console.log(`[CollabClient] Reusing cached websocket for space: ${spaceId}`);
1221
+ return cached;
1222
+ }
1223
+
1224
+ // Remove destroyed websocket from cache
1225
+ console.log(`[CollabClient] Removing destroyed websocket from cache for space: ${spaceId}`);
1226
+ this._sockets.delete(spaceId);
1227
+ }
1228
+
1229
+ console.log(`[CollabClient] Creating new websocket for space: ${spaceId}`);
1230
+
1231
+ // Get token first before creating websocket
1232
+ const token = await this.getToken();
1233
+ console.log(`[CollabClient] Token obtained:`, token ? 'present' : 'missing');
1234
+
1235
+ const ws = new CollabProviderWebsocket({
1236
+ baseUrl: this.baseUrl,
1237
+ spaceId,
1238
+ auth: this.auth,
1239
+ token, // Pass token directly so it connects immediately
1240
+ });
1241
+
1242
+ this._sockets.set(spaceId, ws);
1243
+ return ws;
1244
+ }
1245
+
1246
+ /**
1247
+ * Get a CollabProvider for a specific document.
1248
+ * Uses a shared WebSocket for the space if possible.
1249
+ */
1250
+ public async getProvider(spaceId: string, documentId: string, options: Partial<HocuspocusProviderConfiguration> = {}): Promise<CollabProvider> {
1251
+ const documentName = `space:${spaceId}:doc:${documentId}`;
1252
+
1253
+ // Check if this document is already attached to the websocket
1254
+ const socket = await this.getWebsocketProvider(spaceId);
1255
+ const providerMap = (socket as any).configuration?.providerMap;
1256
+
1257
+ // CRITICAL: If the same document is already attached, create a NEW websocket
1258
+ // because providerMap.set() overwrites - only one provider per document name per socket
1259
+ if (providerMap && providerMap.has(documentName)) {
1260
+ console.log(`[CollabClient] Document ${documentId} already attached to this websocket - creating separate websocket for this tab`);
1261
+
1262
+ // Create a unique websocket for this tab
1263
+ const token = await this.getToken();
1264
+ const newSocket = new CollabProviderWebsocket({
1265
+ baseUrl: this.baseUrl,
1266
+ spaceId,
1267
+ auth: this.auth,
1268
+ token,
1269
+ });
1270
+
1271
+ // Don't cache this one - it's tab-specific
1272
+ console.log(`[CollabClient] Created separate websocket for tab accessing doc ${documentId}`);
1273
+ await newSocket.waitForReady();
1274
+
1275
+ return new CollabProvider({
1276
+ websocketProvider: newSocket,
1277
+ spaceId,
1278
+ documentId,
1279
+ enablePersistence: this.offlineOptions.enablePersistence,
1280
+ persistenceDbName: this.offlineOptions.persistenceDbName,
1281
+ ...options
1282
+ });
1283
+ }
1284
+
1285
+ // Normal path: document not yet attached, safe to use shared websocket
1286
+ console.log(`[CollabClient] Waiting for websocket to be ready before creating provider for doc ${documentId}`);
1287
+ await socket.waitForReady();
1288
+ console.log(`[CollabClient] Websocket ready, creating provider for doc ${documentId}`);
1289
+
1290
+ return new CollabProvider({
1291
+ websocketProvider: socket,
1292
+ spaceId,
1293
+ documentId,
1294
+ enablePersistence: this.offlineOptions.enablePersistence,
1295
+ persistenceDbName: this.offlineOptions.persistenceDbName,
1296
+ ...options
1297
+ });
1298
+ }
1299
+
1300
+ // --- REST API: Documents ---
1301
+
1302
+ public readonly documents = {
1303
+ /**
1304
+ * List documents in a space
1305
+ * @param parentId - null for root only, undefined for all, string for specific parent
1306
+ */
1307
+ list: (spaceId: string, params?: { parentId?: string | null; limit?: number; offset?: number }) => {
1308
+ const searchParams = new URLSearchParams();
1309
+ if (params?.parentId === null) {
1310
+ searchParams.set('parentId', 'null');
1311
+ } else if (params?.parentId) {
1312
+ searchParams.set('parentId', params.parentId);
1313
+ }
1314
+ if (params?.limit) searchParams.set('limit', String(params.limit));
1315
+ if (params?.offset) searchParams.set('offset', String(params.offset));
1316
+ const qs = searchParams.toString() ? `?${searchParams}` : '';
1317
+ return this.fetch<{ success: true; data: Document[]; pagination: PaginationInfo }>(
1318
+ `/spaces/${spaceId}/documents${qs}`
1319
+ ).then(r => ({ items: r.data, pagination: r.pagination }));
1320
+ },
1321
+
1322
+ get: (spaceId: string, docId: string) =>
1323
+ this.fetch<{ success: true; data: Document }>(`/spaces/${spaceId}/documents/${docId}`).then(r => r.data),
1324
+
1325
+ create: (spaceId: string, options?: { name?: string; parentId?: string | null }) =>
1326
+ this.fetch<Document & { wsUrl: string }>(`/spaces/${spaceId}/documents`, {
1327
+ method: "POST",
1328
+ body: JSON.stringify(options || {}),
1329
+ }),
1330
+
1331
+ update: (spaceId: string, docId: string, data: { name: string }) =>
1332
+ this.fetch<{ success: true }>(`/spaces/${spaceId}/documents/${docId}`, {
1333
+ method: "PATCH",
1334
+ body: JSON.stringify(data),
1335
+ }),
1336
+
1337
+ delete: (spaceId: string, docId: string) =>
1338
+ this.fetch<{ success: true }>(`/spaces/${spaceId}/documents/${docId}`, {
1339
+ method: "DELETE",
1340
+ }),
1341
+
1342
+ getContent: (spaceId: string, docId: string) =>
1343
+ this.fetch<{ id: string; spaceId: string; content: Record<string, unknown> }>(
1344
+ `/spaces/${spaceId}/documents/${docId}/content`
1345
+ ).then(r => r.content),
1346
+
1347
+ /**
1348
+ * Update document content via patch (merge or replace strategy)
1349
+ */
1350
+ patch: (spaceId: string, docId: string, content: any, options?: {
1351
+ merge?: boolean;
1352
+ strategy?: "replace" | "merge" | "append" | "prepend";
1353
+ path?: number[];
1354
+ }) =>
1355
+ this.fetch<{ success: true; document: Document }>(`/spaces/${spaceId}/documents/${docId}/content`, {
1356
+ method: "PATCH",
1357
+ body: JSON.stringify({ content, ...options }),
1358
+ }).then(r => r.document),
1359
+
1360
+ /**
1361
+ * Apply transformation operations to document
1362
+ */
1363
+ transform: (spaceId: string, docId: string, operations: any[]) =>
1364
+ this.fetch<{ success: true; document: Document }>(`/spaces/${spaceId}/documents/${docId}/transform`, {
1365
+ method: "POST",
1366
+ body: JSON.stringify({ operations }),
1367
+ }).then(r => r.document),
1368
+
1369
+ // Hierarchy methods
1370
+ tree: (spaceId: string) =>
1371
+ this.fetch<{ success: true; data: DocumentTreeNode[] }>(`/spaces/${spaceId}/documents/tree`).then(r => r.data),
1372
+
1373
+ children: (spaceId: string, docId: string, params?: { limit?: number; offset?: number }) => {
1374
+ const searchParams = new URLSearchParams();
1375
+ if (params?.limit) searchParams.set('limit', String(params.limit));
1376
+ if (params?.offset) searchParams.set('offset', String(params.offset));
1377
+ const qs = searchParams.toString() ? `?${searchParams}` : '';
1378
+ return this.fetch<{ success: true; data: Document[]; pagination: PaginationInfo }>(
1379
+ `/spaces/${spaceId}/documents/${docId}/children${qs}`
1380
+ ).then(r => ({ items: r.data, pagination: r.pagination }));
1381
+ },
1382
+
1383
+ ancestors: (spaceId: string, docId: string) =>
1384
+ this.fetch<{ success: true; data: Document[] }>(`/spaces/${spaceId}/documents/${docId}/ancestors`).then(r => r.data),
1385
+
1386
+ move: (spaceId: string, docId: string, parentId: string | null, position?: number) =>
1387
+ this.fetch<{ success: true }>(`/spaces/${spaceId}/documents/${docId}/move`, {
1388
+ method: "POST",
1389
+ body: JSON.stringify({ parentId, position }),
1390
+ }),
1391
+
1392
+ // History / Snapshots
1393
+ getSnapshots: (spaceId: string, docId: string) =>
1394
+ this.fetch<{ snapshots: DocumentSnapshot[] }>(`/spaces/${spaceId}/documents/${docId}/snapshots`).then(r => r.snapshots),
1395
+
1396
+ restoreSnapshot: (spaceId: string, docId: string, snapshotId: string) =>
1397
+ this.fetch<{ success: true; message: string }>(`/spaces/${spaceId}/documents/${docId}/restore/${snapshotId}`, {
1398
+ method: "POST",
1399
+ }),
1400
+
1401
+ // Maintenance / Recovery
1402
+ listDeleted: (spaceId: string, params?: { limit?: number; offset?: number }) => {
1403
+ const searchParams = new URLSearchParams();
1404
+ if (params?.limit) searchParams.set('limit', String(params.limit));
1405
+ if (params?.offset) searchParams.set('offset', String(params.offset));
1406
+ const qs = searchParams.toString() ? `?${searchParams}` : '';
1407
+ return this.fetch<{ success: true; data: Document[]; pagination: PaginationInfo }>(
1408
+ `/spaces/${spaceId}/documents/deleted${qs}`
1409
+ ).then(r => ({ items: r.data, pagination: r.pagination }));
1410
+ },
1411
+
1412
+ restore: (spaceId: string, docId: string) =>
1413
+ this.fetch<{ success: true }>(`/spaces/${spaceId}/documents/${docId}/restore`, {
1414
+ method: "POST",
1415
+ }),
1416
+
1417
+ deletePermanent: (spaceId: string, docId: string) =>
1418
+ this.fetch<{ success: true }>(`/spaces/${spaceId}/documents/${docId}/permanent`, {
1419
+ method: "DELETE",
1420
+ }),
1421
+
1422
+ // Export - returns raw HTML string or JSON object depending on format
1423
+ export: async (spaceId: string, docId: string, format: 'html' | 'json' = 'html') => {
1424
+ const token = await this.getToken();
1425
+ const res = await fetch(`${this.baseUrl}/api/spaces/${spaceId}/documents/${docId}/export?format=${format}`, {
1426
+ headers: { Authorization: `Bearer ${token}` },
1427
+ });
1428
+ if (!res.ok) throw new Error(`Export failed: ${res.status}`);
1429
+ return format === 'json' ? res.json() : res.text();
1430
+ },
1431
+ };
1432
+
1433
+ // --- REST API: Spaces ---
1434
+
1435
+ public readonly spaces = {
1436
+ list: () =>
1437
+ this.fetch<{ success: true; data: Space[] }>("/spaces").then(r => r.data),
1438
+
1439
+ get: (spaceId: string) =>
1440
+ this.fetch<{ success: true; data: Space }>(`/spaces/${spaceId}`).then(r => r.data),
1441
+
1442
+ getMembers: (spaceId: string) =>
1443
+ this.fetch<{ success: true; data: SpaceMember[] }>(`/spaces/${spaceId}/members`).then(r => r.data),
1444
+
1445
+ create: (data: { name: string; slug?: string }) =>
1446
+ this.fetch<Space>("/organizations", {
1447
+ method: "POST",
1448
+ headers: { "Content-Type": "application/json" },
1449
+ body: JSON.stringify(data),
1450
+ }),
1451
+
1452
+ /**
1453
+ * Add a member to a space/organization
1454
+ */
1455
+ addMember: (spaceId: string, email: string, role: string) =>
1456
+ this.fetch<{ success: true; memberId: string }>(`/organizations/${spaceId}/members`, {
1457
+ method: "POST",
1458
+ body: JSON.stringify({ email, role }),
1459
+ }),
1460
+
1461
+ /**
1462
+ * Update a member's role
1463
+ */
1464
+ updateRole: (spaceId: string, userId: string, role: string) =>
1465
+ this.fetch<{ success: true }>(`/organizations/${spaceId}/members/${userId}`, {
1466
+ method: "PATCH",
1467
+ body: JSON.stringify({ role }),
1468
+ }),
1469
+
1470
+ /**
1471
+ * Remove a member from a space/organization
1472
+ */
1473
+ removeMember: (spaceId: string, userId: string) =>
1474
+ this.fetch<{ success: true }>(`/organizations/${spaceId}/members/${userId}`, {
1475
+ method: "DELETE",
1476
+ }),
1477
+ };
1478
+
1479
+ // --- REST API: Uploads ---
1480
+
1481
+ public readonly uploads = {
1482
+ list: (spaceId: string, documentId?: string, params?: { limit?: number; offset?: number }) => {
1483
+ const searchParams = new URLSearchParams();
1484
+ if (documentId) searchParams.set('documentId', documentId);
1485
+ if (params?.limit) searchParams.set('limit', String(params.limit));
1486
+ if (params?.offset) searchParams.set('offset', String(params.offset));
1487
+ const qs = searchParams.toString() ? `?${searchParams}` : '';
1488
+ return this.fetch<{ success: true; data: FileUpload[]; pagination: PaginationInfo }>(
1489
+ `/spaces/${spaceId}/files${qs}`
1490
+ ).then(r => ({ items: r.data, pagination: r.pagination }));
1491
+ },
1492
+
1493
+ upload: async (spaceId: string, file: File, documentId?: string) => {
1494
+ // Check if online
1495
+ if (!navigator.onLine && this.uploadQueue) {
1496
+ console.log('[CollabClient] Offline - queuing upload:', file.name);
1497
+
1498
+ // Add to queue and return temp data
1499
+ const pendingUpload = await this.uploadQueue.add({
1500
+ spaceId,
1501
+ documentId: documentId || null,
1502
+ file,
1503
+ filename: file.name,
1504
+ mimeType: file.type
1505
+ });
1506
+
1507
+ // Return temporary file info that looks like a FileUpload
1508
+ return {
1509
+ id: pendingUpload.id,
1510
+ url: pendingUpload.tempUrl,
1511
+ filename: pendingUpload.filename,
1512
+ mimeType: pendingUpload.mimeType,
1513
+ size: file.size
1514
+ } as FileUpload;
1515
+ }
1516
+
1517
+ // Online - upload directly
1518
+ const formData = new FormData();
1519
+ formData.append("file", file);
1520
+ const qs = documentId ? `?documentId=${documentId}` : "";
1521
+ return this.fetch<{ success: true; data: FileUpload }>(`/spaces/${spaceId}/upload${qs}`, {
1522
+ method: "POST",
1523
+ body: formData,
1524
+ }).then(r => r.data);
1525
+ },
1526
+
1527
+ delete: (spaceId: string, fileId: string) =>
1528
+ this.fetch<{ success: true }>(`/spaces/${spaceId}/files/${fileId}`, {
1529
+ method: "DELETE",
1530
+ }),
1531
+
1532
+ deletePermanent: (spaceId: string, fileId: string) =>
1533
+ this.fetch<{ success: true }>(`/spaces/${spaceId}/files/${fileId}/permanent`, {
1534
+ method: "DELETE",
1535
+ }),
1536
+ };
1537
+
1538
+ // --- REST API: Comments ---
1539
+
1540
+ public readonly comments = {
1541
+ /**
1542
+ * List comments for a document (threaded)
1543
+ */
1544
+ list: (spaceId: string, docId: string) =>
1545
+ this.fetch<{ success: true; data: CommentThread[]; meta: { total: number; resolved: number } }>(
1546
+ `/spaces/${spaceId}/documents/${docId}/comments`
1547
+ ).then(r => ({ threads: r.data, meta: r.meta })),
1548
+
1549
+ /**
1550
+ * Get a single comment
1551
+ */
1552
+ get: (spaceId: string, docId: string, commentId: string) =>
1553
+ this.fetch<{ success: true; data: Comment }>(
1554
+ `/spaces/${spaceId}/documents/${docId}/comments/${commentId}`
1555
+ ).then(r => r.data),
1556
+
1557
+ /**
1558
+ * Create a new comment
1559
+ */
1560
+ create: (spaceId: string, docId: string, data: { content: string; anchor?: CommentAnchor; parentId?: string }) =>
1561
+ this.fetch<{ success: true; data: Comment }>(`/spaces/${spaceId}/documents/${docId}/comments`, {
1562
+ method: "POST",
1563
+ body: JSON.stringify(data),
1564
+ }).then(r => r.data),
1565
+
1566
+ /**
1567
+ * Update a comment's content
1568
+ */
1569
+ update: (spaceId: string, docId: string, commentId: string, content: string) =>
1570
+ this.fetch<{ success: true; data: Comment }>(`/spaces/${spaceId}/documents/${docId}/comments/${commentId}`, {
1571
+ method: "PATCH",
1572
+ body: JSON.stringify({ content }),
1573
+ }).then(r => r.data),
1574
+
1575
+ /**
1576
+ * Delete a comment
1577
+ */
1578
+ delete: (spaceId: string, docId: string, commentId: string) =>
1579
+ this.fetch<{ success: true }>(`/spaces/${spaceId}/documents/${docId}/comments/${commentId}`, {
1580
+ method: "DELETE",
1581
+ }),
1582
+
1583
+ /**
1584
+ * Resolve or unresolve a comment thread
1585
+ */
1586
+ resolve: (spaceId: string, docId: string, commentId: string, resolved: boolean) =>
1587
+ this.fetch<{ success: true; data: Comment }>(`/spaces/${spaceId}/documents/${docId}/comments/${commentId}/resolve`, {
1588
+ method: "POST",
1589
+ body: JSON.stringify({ resolved }),
1590
+ }).then(r => r.data),
1591
+ };
1592
+
1593
+ // --- REST API: Sharing ---
1594
+
1595
+ public readonly share = {
1596
+ /**
1597
+ * Get share status for a document
1598
+ */
1599
+ get: async (spaceId: string, docId: string) => {
1600
+ const token = await this.getToken();
1601
+ const res = await fetch(`${this.baseUrl}/api/spaces/${spaceId}/documents/${docId}/share`, {
1602
+ headers: { Authorization: `Bearer ${token}` },
1603
+ });
1604
+ if (!res.ok) throw new Error("Failed to get share status");
1605
+ return res.json() as Promise<{ success: boolean; data: { enabled: boolean; token?: string; access?: string; url?: string } }>;
1606
+ },
1607
+
1608
+ /**
1609
+ * Enable sharing for a document
1610
+ */
1611
+ enable: async (spaceId: string, docId: string) => {
1612
+ const token = await this.getToken();
1613
+ const res = await fetch(`${this.baseUrl}/api/spaces/${spaceId}/documents/${docId}/share`, {
1614
+ method: "POST",
1615
+ headers: { Authorization: `Bearer ${token}` },
1616
+ });
1617
+ if (!res.ok) throw new Error("Failed to enable sharing");
1618
+ return res.json() as Promise<{ success: boolean; data: { token: string; access: string; url: string } }>;
1619
+ },
1620
+
1621
+ /**
1622
+ * Disable sharing for a document
1623
+ */
1624
+ disable: async (spaceId: string, docId: string) => {
1625
+ const token = await this.getToken();
1626
+ const res = await fetch(`${this.baseUrl}/api/spaces/${spaceId}/documents/${docId}/share`, {
1627
+ method: "DELETE",
1628
+ headers: { Authorization: `Bearer ${token}` },
1629
+ });
1630
+ if (!res.ok) throw new Error("Failed to disable sharing");
1631
+ return res.json() as Promise<{ success: boolean }>;
1632
+ }
1633
+ };
1634
+
1635
+ /**
1636
+ * Process the upload queue
1637
+ */
1638
+ private async processUploadQueue(): Promise<void> {
1639
+ if (!this.uploadQueue) return;
1640
+
1641
+ await this.uploadQueue.processQueue(async (upload: PendingUpload) => {
1642
+ console.log('[CollabClient] Processing queued upload:', upload.filename);
1643
+
1644
+ // Re-create File from Blob
1645
+ const file = new File([upload.file], upload.filename, { type: upload.mimeType });
1646
+
1647
+ // Upload to server
1648
+ const formData = new FormData();
1649
+ formData.append("file", file);
1650
+ const qs = upload.documentId ? `?documentId=${upload.documentId}` : "";
1651
+
1652
+ const result = await this.fetch<{ success: true; data: FileUpload }>(
1653
+ `/spaces/${upload.spaceId}/upload${qs}`,
1654
+ {
1655
+ method: "POST",
1656
+ body: formData,
1657
+ }
1658
+ );
1659
+
1660
+ return result.data;
1661
+ });
1662
+ }
1663
+
1664
+ /**
1665
+ * Get pending uploads count
1666
+ */
1667
+ public async getPendingUploadsCount(): Promise<number> {
1668
+ if (!this.uploadQueue) return 0;
1669
+ return this.uploadQueue.count();
1670
+ }
1671
+
1672
+ /**
1673
+ * Get all pending uploads
1674
+ */
1675
+ public async getPendingUploads(): Promise<PendingUpload[]> {
1676
+ if (!this.uploadQueue) return [];
1677
+ return this.uploadQueue.getAll();
1678
+ }
1679
+
1680
+ /**
1681
+ * Subscribe to upload queue events
1682
+ */
1683
+ public onUploadQueue(event: 'queue-changed' | 'upload-complete' | 'upload-failed', callback: (data: any) => void): () => void {
1684
+ if (!this.uploadQueue) return () => { };
1685
+ return this.uploadQueue.on(event, callback);
1686
+ }
1687
+
1688
+ /**
1689
+ * Get storage quota information
1690
+ */
1691
+ public async getStorageInfo(): Promise<{
1692
+ usage: number;
1693
+ quota: number;
1694
+ percentUsed: number;
1695
+ }> {
1696
+ if (!navigator.storage || !navigator.storage.estimate) {
1697
+ return { usage: 0, quota: 0, percentUsed: 0 };
1698
+ }
1699
+
1700
+ try {
1701
+ const estimate = await navigator.storage.estimate();
1702
+ const usage = estimate.usage || 0;
1703
+ const quota = estimate.quota || 0;
1704
+ const percentUsed = quota > 0 ? (usage / quota) * 100 : 0;
1705
+
1706
+ return { usage, quota, percentUsed };
1707
+ } catch (error) {
1708
+ console.error('[CollabClient] Failed to get storage info:', error);
1709
+ return { usage: 0, quota: 0, percentUsed: 0 };
1710
+ }
1711
+ }
1712
+
1713
+ /**
1714
+ * Clear all local data (IndexedDB and caches)
1715
+ */
1716
+ public async clearAllLocalData(): Promise<void> {
1717
+ console.log('[CollabClient] Clearing all local data...');
1718
+
1719
+ // Clear upload queue
1720
+ if (this.uploadQueue) {
1721
+ await this.uploadQueue.clear();
1722
+ }
1723
+
1724
+ // Clear all IndexedDB databases
1725
+ if (typeof indexedDB !== 'undefined') {
1726
+ try {
1727
+ // Get all database names
1728
+ const databases = await indexedDB.databases();
1729
+ for (const db of databases) {
1730
+ if (db.name) {
1731
+ console.log('[CollabClient] Deleting database:', db.name);
1732
+ await new Promise<void>((resolve, reject) => {
1733
+ const request = indexedDB.deleteDatabase(db.name!);
1734
+ request.onsuccess = () => resolve();
1735
+ request.onerror = () => reject(request.error);
1736
+ });
1737
+ }
1738
+ }
1739
+ } catch (error) {
1740
+ console.error('[CollabClient] Failed to clear IndexedDB:', error);
1741
+ }
1742
+ }
1743
+
1744
+ // Clear caches
1745
+ if (typeof caches !== 'undefined') {
1746
+ try {
1747
+ const cacheNames = await caches.keys();
1748
+ for (const name of cacheNames) {
1749
+ console.log('[CollabClient] Deleting cache:', name);
1750
+ await caches.delete(name);
1751
+ }
1752
+ } catch (error) {
1753
+ console.error('[CollabClient] Failed to clear caches:', error);
1754
+ }
1755
+ }
1756
+
1757
+ console.log('[CollabClient] Local data cleared');
1758
+ }
1759
+
1760
+ /**
1761
+ * Clear specific document cache
1762
+ */
1763
+ public async clearDocumentCache(spaceId: string, docId: string): Promise<void> {
1764
+ const dbName = this.offlineOptions.persistenceDbName || 'y-collab';
1765
+ const documentName = `space:${spaceId}:doc:${docId}`;
1766
+
1767
+ try {
1768
+ // Open the database and clear the specific document
1769
+ const db = await new Promise<IDBDatabase>((resolve, reject) => {
1770
+ const request = indexedDB.open(dbName);
1771
+ request.onsuccess = () => resolve(request.result);
1772
+ request.onerror = () => reject(request.error);
1773
+ });
1774
+
1775
+ // Check if the store exists
1776
+ if (!db.objectStoreNames.contains(documentName)) {
1777
+ db.close();
1778
+ return;
1779
+ }
1780
+
1781
+ // Clear the document store
1782
+ await new Promise<void>((resolve, reject) => {
1783
+ const transaction = db.transaction([documentName], 'readwrite');
1784
+ const store = transaction.objectStore(documentName);
1785
+ const request = store.clear();
1786
+ request.onsuccess = () => resolve();
1787
+ request.onerror = () => reject(request.error);
1788
+ });
1789
+
1790
+ db.close();
1791
+ console.log('[CollabClient] Document cache cleared:', documentName);
1792
+ } catch (error) {
1793
+ console.error('[CollabClient] Failed to clear document cache:', error);
1794
+ }
1795
+ }
1796
+
1797
+ /**
1798
+ * Perform cleanup when storage quota is exceeded
1799
+ */
1800
+ private async performCleanup(): Promise<void> {
1801
+ console.log('[CollabClient] Performing storage cleanup...');
1802
+
1803
+ const { percentUsed } = await this.getStorageInfo();
1804
+ const threshold = (this.offlineOptions.cleanupThreshold || 0.9) * 100;
1805
+
1806
+ if (percentUsed < threshold) {
1807
+ console.log('[CollabClient] Storage usage below threshold, no cleanup needed');
1808
+ return;
1809
+ }
1810
+
1811
+ // Strategy: Clear image cache first (non-critical data)
1812
+ if (typeof caches !== 'undefined') {
1813
+ try {
1814
+ const cacheNames = await caches.keys();
1815
+ for (const name of cacheNames) {
1816
+ if (name.includes('image')) {
1817
+ console.log('[CollabClient] Clearing image cache:', name);
1818
+ await caches.delete(name);
1819
+ }
1820
+ }
1821
+ } catch (error) {
1822
+ console.error('[CollabClient] Failed to clear image caches:', error);
1823
+ }
1824
+ }
1825
+
1826
+ // Check again
1827
+ const { percentUsed: newPercentUsed } = await this.getStorageInfo();
1828
+ if (newPercentUsed < threshold) {
1829
+ console.log('[CollabClient] Cleanup complete, storage usage:', newPercentUsed, '%');
1830
+ return;
1831
+ }
1832
+
1833
+ // If still over threshold, implement LRU eviction for documents
1834
+ // This is a placeholder - would need to track document access times
1835
+ console.warn('[CollabClient] Storage still over threshold after cleanup');
1836
+ }
1837
+
1838
+ /**
1839
+ * Check storage quota and perform cleanup if needed
1840
+ */
1841
+ public async checkStorageQuota(): Promise<void> {
1842
+ if (!this.offlineOptions.autoCleanup) {
1843
+ return;
1844
+ }
1845
+
1846
+ const { percentUsed } = await this.getStorageInfo();
1847
+ const threshold = (this.offlineOptions.cleanupThreshold || 0.9) * 100;
1848
+
1849
+ if (percentUsed >= threshold) {
1850
+ console.warn('[CollabClient] Storage quota exceeded:', percentUsed, '%');
1851
+ await this.performCleanup();
1852
+ }
1853
+ }
1854
+
1855
+ /**
1856
+ * Get document content using a share token (public access)
1857
+ */
1858
+ public async getPublicContent(spaceId: string, docId: string, shareToken: string) {
1859
+ const res = await fetch(`${this.baseUrl}/api/spaces/${spaceId}/documents/${docId}/content`, {
1860
+ headers: {
1861
+ "Share-Token": shareToken
1862
+ },
1863
+ });
1864
+
1865
+ if (!res.ok) {
1866
+ throw new Error(`Failed to load public document: ${res.status}`);
1867
+ }
1868
+
1869
+ return res.json() as Promise<{ id: string; spaceId: string; content: Record<string, unknown> }>;
1870
+ }
1871
+ }