Skip to content

Commit

Permalink
✨(frontend) add long polling falback system
Browse files Browse the repository at this point in the history
When the websocket cannot connect, after a certain
amount of retry, the system will fallback to long
polling to keep the document sync.
If the websocket is connected, the system will
automatically switch back to websocket and stop
long polling.
  • Loading branch information
AntoLC committed Dec 24, 2024
1 parent e5b9151 commit d7b1c8c
Show file tree
Hide file tree
Showing 9 changed files with 235 additions and 74 deletions.
107 changes: 107 additions & 0 deletions src/frontend/apps/e2e/__tests__/app-impress/doc-collaboration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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) => {
console.log('Aborting ws connection');
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);
});
});
66 changes: 0 additions & 66 deletions src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './syncDocPolling';
export * from './useCreateDoc';
export * from './useDoc';
export * from './useDocOptions';
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SyncDocPollingResponse> => {
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<SyncDocPollingResponse>;
};
Original file line number Diff line number Diff line change
@@ -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<NodeJS.Timeout>();

useEffect(() => {
if (!room || !collaborationUrl?.wsUrl || provider) {
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UseCollaborationStore>((set, get) => ({
Expand All @@ -33,6 +39,26 @@ export const useProviderStore = create<UseCollaborationStore>((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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down

0 comments on commit d7b1c8c

Please sign in to comment.