Skip to content

Commit d7b1c8c

Browse files
committed
✨(frontend) add long polling falback system
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.
1 parent e5b9151 commit d7b1c8c

File tree

9 files changed

+235
-74
lines changed

9 files changed

+235
-74
lines changed
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
import { createDoc } from './common';
4+
5+
test.beforeEach(async ({ page }) => {
6+
await page.goto('/');
7+
});
8+
9+
test.describe('Doc Collaboration', () => {
10+
/**
11+
* We check:
12+
* - connection to the collaborative server
13+
* - signal of the backend to the collaborative server (connection should close)
14+
* - reconnection to the collaborative server
15+
*/
16+
test('checks the connection with collaborative server', async ({
17+
page,
18+
browserName,
19+
}) => {
20+
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
21+
return webSocket
22+
.url()
23+
.includes('ws://localhost:8083/collaboration/ws/?room=');
24+
});
25+
26+
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
27+
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
28+
29+
let webSocket = await webSocketPromise;
30+
expect(webSocket.url()).toContain(
31+
'ws://localhost:8083/collaboration/ws/?room=',
32+
);
33+
34+
// Is connected
35+
let framesentPromise = webSocket.waitForEvent('framesent');
36+
37+
await page.locator('.ProseMirror.bn-editor').click();
38+
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
39+
40+
let framesent = await framesentPromise;
41+
expect(framesent.payload).not.toBeNull();
42+
43+
await page.getByRole('button', { name: 'Share' }).click();
44+
45+
const selectVisibility = page.getByRole('combobox', {
46+
name: 'Visibility',
47+
});
48+
49+
// When the visibility is changed, the ws should closed the connection (backend signal)
50+
const wsClosePromise = webSocket.waitForEvent('close');
51+
52+
await selectVisibility.click();
53+
await page
54+
.getByRole('option', {
55+
name: 'Authenticated',
56+
})
57+
.click();
58+
59+
// Assert that the doc reconnects to the ws
60+
const wsClose = await wsClosePromise;
61+
expect(wsClose.isClosed()).toBeTruthy();
62+
63+
// Checkt the ws is connected again
64+
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
65+
return webSocket
66+
.url()
67+
.includes('ws://localhost:8083/collaboration/ws/?room=');
68+
});
69+
70+
webSocket = await webSocketPromise;
71+
framesentPromise = webSocket.waitForEvent('framesent');
72+
framesent = await framesentPromise;
73+
expect(framesent.payload).not.toBeNull();
74+
});
75+
76+
test('checks the connection switch to polling after websocket failure', async ({
77+
page,
78+
browserName,
79+
}) => {
80+
const responsePromise = page.waitForResponse(
81+
(response) =>
82+
response.url().includes('/poll/') && response.status() === 200,
83+
);
84+
85+
await page.routeWebSocket(
86+
'ws://localhost:8083/collaboration/ws/**',
87+
async (ws) => {
88+
console.log('Aborting ws connection');
89+
await ws.close();
90+
},
91+
);
92+
93+
await page.reload();
94+
95+
const randomDoc = await createDoc(page, 'doc-polling', browserName, 1);
96+
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
97+
98+
const response = await responsePromise;
99+
const responseJson = (await response.json()) as {
100+
connectionsCount: number;
101+
yDoc64?: string;
102+
};
103+
104+
expect(responseJson.yDoc64).toBeDefined();
105+
expect(responseJson.connectionsCount).toBe(0);
106+
});
107+
});

src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts

Lines changed: 0 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -81,72 +81,6 @@ test.describe('Doc Editor', () => {
8181
).toBeVisible();
8282
});
8383

84-
/**
85-
* We check:
86-
* - connection to the collaborative server
87-
* - signal of the backend to the collaborative server (connection should close)
88-
* - reconnection to the collaborative server
89-
*/
90-
test('checks the connection with collaborative server', async ({
91-
page,
92-
browserName,
93-
}) => {
94-
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
95-
return webSocket
96-
.url()
97-
.includes('ws://localhost:8083/collaboration/ws/?room=');
98-
});
99-
100-
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
101-
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
102-
103-
let webSocket = await webSocketPromise;
104-
expect(webSocket.url()).toContain(
105-
'ws://localhost:8083/collaboration/ws/?room=',
106-
);
107-
108-
// Is connected
109-
let framesentPromise = webSocket.waitForEvent('framesent');
110-
111-
await page.locator('.ProseMirror.bn-editor').click();
112-
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
113-
114-
let framesent = await framesentPromise;
115-
expect(framesent.payload).not.toBeNull();
116-
117-
await page.getByRole('button', { name: 'Share' }).click();
118-
119-
const selectVisibility = page.getByRole('combobox', {
120-
name: 'Visibility',
121-
});
122-
123-
// When the visibility is changed, the ws should closed the connection (backend signal)
124-
const wsClosePromise = webSocket.waitForEvent('close');
125-
126-
await selectVisibility.click();
127-
await page
128-
.getByRole('option', {
129-
name: 'Authenticated',
130-
})
131-
.click();
132-
133-
// Assert that the doc reconnects to the ws
134-
const wsClose = await wsClosePromise;
135-
expect(wsClose.isClosed()).toBeTruthy();
136-
137-
// Checkt the ws is connected again
138-
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
139-
return webSocket
140-
.url()
141-
.includes('ws://localhost:8083/collaboration/ws/?room=');
142-
});
143-
144-
webSocket = await webSocketPromise;
145-
framesentPromise = webSocket.waitForEvent('framesent');
146-
framesent = await framesentPromise;
147-
expect(framesent.payload).not.toBeNull();
148-
});
149-
15084
test('markdown button converts from markdown to the editor syntax json', async ({
15185
page,
15286
browserName,

src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,10 @@ import { useRouter } from 'next/router';
22
import { useCallback, useEffect, useRef, useState } from 'react';
33
import * as Y from 'yjs';
44

5-
import { useUpdateDoc } from '@/features/docs/doc-management/';
5+
import { toBase64, useUpdateDoc } from '@/features/docs/doc-management/';
66
import { KEY_LIST_DOC_VERSIONS } from '@/features/docs/doc-versioning';
77
import { isFirefox } from '@/utils/userAgent';
88

9-
import { toBase64 } from '../utils';
10-
119
const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
1210
const { mutate: updateDoc } = useUpdateDoc({
1311
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],

src/frontend/apps/impress/src/features/docs/doc-editor/utils.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,3 @@ function hslToHex(h: number, s: number, l: number) {
2222
};
2323
return `#${f(0)}${f(8)}${f(4)}`;
2424
}
25-
26-
export const toBase64 = (str: Uint8Array) =>
27-
Buffer.from(str).toString('base64');

src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './syncDocPolling';
12
export * from './useCreateDoc';
23
export * from './useDoc';
34
export * from './useDocOptions';
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { APIError, errorCauses } from '@/api';
2+
3+
import { Base64 } from '../types';
4+
5+
interface SyncDocPollingParams {
6+
pollUrl: string;
7+
yDoc64: Base64;
8+
}
9+
10+
interface SyncDocPollingResponse {
11+
yDoc64?: Base64;
12+
}
13+
14+
export const syncDocPolling = async ({
15+
pollUrl,
16+
yDoc64,
17+
}: SyncDocPollingParams): Promise<SyncDocPollingResponse> => {
18+
const response = await fetch(pollUrl, {
19+
method: 'POST',
20+
credentials: 'include',
21+
headers: {
22+
'Content-Type': 'application/json',
23+
},
24+
body: JSON.stringify({
25+
yDoc64,
26+
}),
27+
});
28+
29+
if (!response.ok) {
30+
throw new APIError('Failed to sync the doc', await errorCauses(response));
31+
}
32+
33+
return response.json() as Promise<SyncDocPollingResponse>;
34+
};

src/frontend/apps/impress/src/features/docs/doc-management/hooks/useCollaboration.tsx

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
1-
import { useEffect } from 'react';
1+
import { useEffect, useRef, useState } from 'react';
2+
import * as Y from 'yjs';
23

34
import { useCollaborationUrl } from '@/core/config';
45
import { useBroadcastStore } from '@/stores';
56

7+
import { syncDocPolling } from '../api/syncDocPolling';
68
import { useProviderStore } from '../stores/useProviderStore';
79
import { Base64 } from '../types';
10+
import { base64ToYDoc, toBase64 } from '../utils';
811

912
export const useCollaboration = (room?: string, initialContent?: Base64) => {
1013
const collaborationUrl = useCollaborationUrl(room);
1114
const { setBroadcastProvider } = useBroadcastStore();
12-
const { provider, createProvider, destroyProvider } = useProviderStore();
15+
const { provider, createProvider, destroyProvider, isProviderFailure } =
16+
useProviderStore();
17+
const [pollingInterval] = useState(1500);
18+
const intervalRef = useRef<NodeJS.Timeout>();
1319

1420
useEffect(() => {
1521
if (!room || !collaborationUrl?.wsUrl || provider) {
@@ -31,6 +37,61 @@ export const useCollaboration = (room?: string, initialContent?: Base64) => {
3137
setBroadcastProvider,
3238
]);
3339

40+
/**
41+
* Polling to sync the document
42+
* This is a fallback mechanism in case the WebSocket connection fails
43+
*/
44+
useEffect(() => {
45+
const clearCurrentInterval = () => {
46+
if (intervalRef.current) {
47+
clearInterval(intervalRef.current);
48+
intervalRef.current = undefined;
49+
}
50+
};
51+
52+
if (!isProviderFailure && intervalRef.current) {
53+
clearCurrentInterval();
54+
}
55+
56+
if (
57+
!isProviderFailure ||
58+
!collaborationUrl?.pollUrl ||
59+
intervalRef.current ||
60+
!provider?.document
61+
) {
62+
return;
63+
}
64+
65+
intervalRef.current = setInterval(() => {
66+
syncDocPolling({
67+
pollUrl: collaborationUrl.pollUrl,
68+
yDoc64: toBase64(Y.encodeStateAsUpdate(provider.document)),
69+
})
70+
.then((response) => {
71+
const { yDoc64 } = response;
72+
73+
if (!yDoc64) {
74+
return;
75+
}
76+
77+
const yDoc = base64ToYDoc(yDoc64);
78+
Y.applyUpdate(provider.document, Y.encodeStateAsUpdate(yDoc));
79+
})
80+
.catch((error) => {
81+
console.error('Polling failed:', error);
82+
});
83+
}, pollingInterval);
84+
85+
return () => {
86+
clearCurrentInterval();
87+
};
88+
}, [
89+
collaborationUrl?.pollUrl,
90+
isProviderFailure,
91+
pollingInterval,
92+
provider?.document,
93+
]);
94+
3495
useEffect(() => {
3596
return () => {
3697
destroyProvider();

src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,17 @@ export interface UseCollaborationStore {
1111
initialDoc?: Base64,
1212
) => HocuspocusProvider;
1313
destroyProvider: () => void;
14+
failureCount: number;
15+
maxFailureCount: number;
1416
provider: HocuspocusProvider | undefined;
17+
isProviderFailure: boolean;
1518
}
1619

1720
const defaultValues = {
21+
failureCount: 0,
22+
maxFailureCount: 4,
1823
provider: undefined,
24+
isProviderFailure: false,
1925
};
2026

2127
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
@@ -33,6 +39,26 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
3339
url: wsUrl,
3440
name: storeId,
3541
document: doc,
42+
onConnect: () => {
43+
set({
44+
failureCount: 0,
45+
isProviderFailure: false,
46+
});
47+
},
48+
onClose: () => {
49+
set({
50+
failureCount: get().failureCount + 1,
51+
});
52+
53+
if (
54+
!get().isProviderFailure &&
55+
get().failureCount > get().maxFailureCount
56+
) {
57+
set({
58+
isProviderFailure: true,
59+
});
60+
}
61+
},
3662
});
3763

3864
set({

src/frontend/apps/impress/src/features/docs/doc-management/utils.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ export const currentDocRole = (abilities: Doc['abilities']): Role => {
1212
: Role.READER;
1313
};
1414

15+
export const toBase64 = (str: Uint8Array) =>
16+
Buffer.from(str).toString('base64');
17+
1518
export const base64ToYDoc = (base64: string) => {
1619
const uint8Array = Buffer.from(base64, 'base64');
1720
const ydoc = new Y.Doc();

0 commit comments

Comments
 (0)