diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-collaboration.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-collaboration.spec.ts new file mode 100644 index 000000000..8e32bcff0 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-collaboration.spec.ts @@ -0,0 +1,106 @@ +import { expect, test } from '@playwright/test'; + +import { createDoc } from './common'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe('Doc Collaboration', () => { + /** + * We check: + * - connection to the collaborative server + * - signal of the backend to the collaborative server (connection should close) + * - reconnection to the collaborative server + */ + test('checks the connection with collaborative server', async ({ + page, + browserName, + }) => { + let webSocketPromise = page.waitForEvent('websocket', (webSocket) => { + return webSocket + .url() + .includes('ws://localhost:8083/collaboration/ws/?room='); + }); + + const randomDoc = await createDoc(page, 'doc-editor', browserName, 1); + await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible(); + + let webSocket = await webSocketPromise; + expect(webSocket.url()).toContain( + 'ws://localhost:8083/collaboration/ws/?room=', + ); + + // Is connected + let framesentPromise = webSocket.waitForEvent('framesent'); + + await page.locator('.ProseMirror.bn-editor').click(); + await page.locator('.ProseMirror.bn-editor').fill('Hello World'); + + let framesent = await framesentPromise; + expect(framesent.payload).not.toBeNull(); + + await page.getByRole('button', { name: 'Share' }).click(); + + const selectVisibility = page.getByRole('combobox', { + name: 'Visibility', + }); + + // When the visibility is changed, the ws should closed the connection (backend signal) + const wsClosePromise = webSocket.waitForEvent('close'); + + await selectVisibility.click(); + await page + .getByRole('option', { + name: 'Authenticated', + }) + .click(); + + // Assert that the doc reconnects to the ws + const wsClose = await wsClosePromise; + expect(wsClose.isClosed()).toBeTruthy(); + + // Checkt the ws is connected again + webSocketPromise = page.waitForEvent('websocket', (webSocket) => { + return webSocket + .url() + .includes('ws://localhost:8083/collaboration/ws/?room='); + }); + + webSocket = await webSocketPromise; + framesentPromise = webSocket.waitForEvent('framesent'); + framesent = await framesentPromise; + expect(framesent.payload).not.toBeNull(); + }); + + test('checks the connection switch to polling after websocket failure', async ({ + page, + browserName, + }) => { + const responsePromise = page.waitForResponse( + (response) => + response.url().includes('/poll/') && response.status() === 200, + ); + + await page.routeWebSocket( + 'ws://localhost:8083/collaboration/ws/**', + async (ws) => { + await ws.close(); + }, + ); + + await page.reload(); + + const randomDoc = await createDoc(page, 'doc-polling', browserName, 1); + await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible(); + + const response = await responsePromise; + const responseJson = (await response.json()) as { + connectionsCount: number; + yDoc64?: string; + }; + + expect(responseJson.yDoc64).toBeDefined(); + expect(responseJson.connectionsCount).toBe(0); + }); +}); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index af5b82204..7fcf627f9 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -81,72 +81,6 @@ test.describe('Doc Editor', () => { ).toBeVisible(); }); - /** - * We check: - * - connection to the collaborative server - * - signal of the backend to the collaborative server (connection should close) - * - reconnection to the collaborative server - */ - test('checks the connection with collaborative server', async ({ - page, - browserName, - }) => { - let webSocketPromise = page.waitForEvent('websocket', (webSocket) => { - return webSocket - .url() - .includes('ws://localhost:8083/collaboration/ws/?room='); - }); - - const randomDoc = await createDoc(page, 'doc-editor', browserName, 1); - await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible(); - - let webSocket = await webSocketPromise; - expect(webSocket.url()).toContain( - 'ws://localhost:8083/collaboration/ws/?room=', - ); - - // Is connected - let framesentPromise = webSocket.waitForEvent('framesent'); - - await page.locator('.ProseMirror.bn-editor').click(); - await page.locator('.ProseMirror.bn-editor').fill('Hello World'); - - let framesent = await framesentPromise; - expect(framesent.payload).not.toBeNull(); - - await page.getByRole('button', { name: 'Share' }).click(); - - const selectVisibility = page.getByRole('combobox', { - name: 'Visibility', - }); - - // When the visibility is changed, the ws should closed the connection (backend signal) - const wsClosePromise = webSocket.waitForEvent('close'); - - await selectVisibility.click(); - await page - .getByRole('option', { - name: 'Authenticated', - }) - .click(); - - // Assert that the doc reconnects to the ws - const wsClose = await wsClosePromise; - expect(wsClose.isClosed()).toBeTruthy(); - - // Checkt the ws is connected again - webSocketPromise = page.waitForEvent('websocket', (webSocket) => { - return webSocket - .url() - .includes('ws://localhost:8083/collaboration/ws/?room='); - }); - - webSocket = await webSocketPromise; - framesentPromise = webSocket.waitForEvent('framesent'); - framesent = await framesentPromise; - expect(framesent.payload).not.toBeNull(); - }); - test('markdown button converts from markdown to the editor syntax json', async ({ page, browserName, diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx index ca1ed0054..ae00dac95 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx @@ -2,12 +2,10 @@ import { useRouter } from 'next/router'; import { useCallback, useEffect, useRef, useState } from 'react'; import * as Y from 'yjs'; -import { useUpdateDoc } from '@/features/docs/doc-management/'; +import { toBase64, useUpdateDoc } from '@/features/docs/doc-management/'; import { KEY_LIST_DOC_VERSIONS } from '@/features/docs/doc-versioning'; import { isFirefox } from '@/utils/userAgent'; -import { toBase64 } from '../utils'; - const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => { const { mutate: updateDoc } = useUpdateDoc({ listInvalideQueries: [KEY_LIST_DOC_VERSIONS], diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/utils.ts index a3d311180..325a2304e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/utils.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/utils.ts @@ -22,6 +22,3 @@ function hslToHex(h: number, s: number, l: number) { }; return `#${f(0)}${f(8)}${f(4)}`; } - -export const toBase64 = (str: Uint8Array) => - Buffer.from(str).toString('base64'); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts b/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts index 65c28fbd4..b4bfc675a 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts @@ -1,3 +1,4 @@ +export * from './syncDocPolling'; export * from './useCreateDoc'; export * from './useDoc'; export * from './useDocOptions'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/syncDocPolling.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/syncDocPolling.tsx new file mode 100644 index 000000000..d8d12d287 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/syncDocPolling.tsx @@ -0,0 +1,34 @@ +import { APIError, errorCauses } from '@/api'; + +import { Base64 } from '../types'; + +interface SyncDocPollingParams { + pollUrl: string; + yDoc64: Base64; +} + +interface SyncDocPollingResponse { + yDoc64?: Base64; +} + +export const syncDocPolling = async ({ + pollUrl, + yDoc64, +}: SyncDocPollingParams): Promise => { + const response = await fetch(pollUrl, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + yDoc64, + }), + }); + + if (!response.ok) { + throw new APIError('Failed to sync the doc', await errorCauses(response)); + } + + return response.json() as Promise; +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useCollaboration.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useCollaboration.tsx index 8f32f4100..3b8639fa8 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useCollaboration.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useCollaboration.tsx @@ -1,15 +1,21 @@ -import { useEffect } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import * as Y from 'yjs'; import { useCollaborationUrl } from '@/core/config'; import { useBroadcastStore } from '@/stores'; +import { syncDocPolling } from '../api/syncDocPolling'; import { useProviderStore } from '../stores/useProviderStore'; import { Base64 } from '../types'; +import { base64ToYDoc, toBase64 } from '../utils'; export const useCollaboration = (room?: string, initialContent?: Base64) => { const collaborationUrl = useCollaborationUrl(room); const { setBroadcastProvider } = useBroadcastStore(); - const { provider, createProvider, destroyProvider } = useProviderStore(); + const { provider, createProvider, destroyProvider, isProviderFailure } = + useProviderStore(); + const [pollingInterval] = useState(1500); + const intervalRef = useRef(); useEffect(() => { if (!room || !collaborationUrl?.wsUrl || provider) { @@ -31,6 +37,61 @@ export const useCollaboration = (room?: string, initialContent?: Base64) => { setBroadcastProvider, ]); + /** + * Polling to sync the document + * This is a fallback mechanism in case the WebSocket connection fails + */ + useEffect(() => { + const clearCurrentInterval = () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = undefined; + } + }; + + if (!isProviderFailure && intervalRef.current) { + clearCurrentInterval(); + } + + if ( + !isProviderFailure || + !collaborationUrl?.pollUrl || + intervalRef.current || + !provider?.document + ) { + return; + } + + intervalRef.current = setInterval(() => { + syncDocPolling({ + pollUrl: collaborationUrl.pollUrl, + yDoc64: toBase64(Y.encodeStateAsUpdate(provider.document)), + }) + .then((response) => { + const { yDoc64 } = response; + + if (!yDoc64) { + return; + } + + const yDoc = base64ToYDoc(yDoc64); + Y.applyUpdate(provider.document, Y.encodeStateAsUpdate(yDoc)); + }) + .catch((error) => { + console.error('Polling failed:', error); + }); + }, pollingInterval); + + return () => { + clearCurrentInterval(); + }; + }, [ + collaborationUrl?.pollUrl, + isProviderFailure, + pollingInterval, + provider?.document, + ]); + useEffect(() => { return () => { destroyProvider(); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx index a638045a1..58ca76f3b 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx @@ -11,11 +11,17 @@ export interface UseCollaborationStore { initialDoc?: Base64, ) => HocuspocusProvider; destroyProvider: () => void; + failureCount: number; + maxFailureCount: number; provider: HocuspocusProvider | undefined; + isProviderFailure: boolean; } const defaultValues = { + failureCount: 0, + maxFailureCount: 4, provider: undefined, + isProviderFailure: false, }; export const useProviderStore = create((set, get) => ({ @@ -33,6 +39,26 @@ export const useProviderStore = create((set, get) => ({ url: wsUrl, name: storeId, document: doc, + onConnect: () => { + set({ + failureCount: 0, + isProviderFailure: false, + }); + }, + onClose: () => { + set({ + failureCount: get().failureCount + 1, + }); + + if ( + !get().isProviderFailure && + get().failureCount > get().maxFailureCount + ) { + set({ + isProviderFailure: true, + }); + } + }, }); set({ diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts index 2c229128e..2707e7468 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts @@ -12,6 +12,9 @@ export const currentDocRole = (abilities: Doc['abilities']): Role => { : Role.READER; }; +export const toBase64 = (str: Uint8Array) => + Buffer.from(str).toString('base64'); + export const base64ToYDoc = (base64: string) => { const uint8Array = Buffer.from(base64, 'base64'); const ydoc = new Y.Doc();