@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.
- package/README.md +60 -0
- package/dist/collab-provider.d.ts +561 -0
- package/dist/collab-provider.d.ts.map +1 -0
- package/dist/collab-provider.js +1389 -0
- package/dist/collab-provider.js.map +1 -0
- package/dist/extensions/collaboration-cursor.d.ts +87 -0
- package/dist/extensions/collaboration-cursor.d.ts.map +1 -0
- package/dist/extensions/collaboration-cursor.js +82 -0
- package/dist/extensions/collaboration-cursor.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/services/sw-manager.d.ts +62 -0
- package/dist/services/sw-manager.d.ts.map +1 -0
- package/dist/services/sw-manager.js +207 -0
- package/dist/services/sw-manager.js.map +1 -0
- package/dist/test-deps.d.ts +15 -0
- package/dist/test-deps.d.ts.map +1 -0
- package/dist/test-deps.js +21 -0
- package/dist/test-deps.js.map +1 -0
- package/dist/upload-queue.d.ts +82 -0
- package/dist/upload-queue.d.ts.map +1 -0
- package/dist/upload-queue.js +273 -0
- package/dist/upload-queue.js.map +1 -0
- package/dist/useCollab.d.ts +216 -0
- package/dist/useCollab.d.ts.map +1 -0
- package/dist/useCollab.js +583 -0
- package/dist/useCollab.js.map +1 -0
- package/package.json +93 -0
- package/src/collab-provider.ts +1871 -0
- package/src/extensions/collaboration-cursor.ts +188 -0
- package/src/index.ts +6 -0
- package/src/services/sw-manager.ts +255 -0
- package/src/test-deps.ts +34 -0
- package/src/upload-queue.ts +337 -0
|
@@ -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
|