Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨Collaboration long polling fallback #517

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/docker-hub.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
push:
branches:
- 'main'
- 'feature/collab-long-polling'
tags:
- 'v*'
pull_request:
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@ and this project adheres to

## [Unreleased]

## Added

- ✨Collaboration long polling fallback #517

## Changed

- 📝(doc) minor README.md formatting and wording enhancements


## [2.2.0] - 2025-02-10

## Added
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
proxy_cache_path /tmp/auth_cache levels=1:2 keys_zone=auth_cache:10m inactive=60s max_size=100m;
18 changes: 15 additions & 3 deletions docker/files/etc/nginx/conf.d/default.conf
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@

server {
listen 8083;
server_name localhost;
charset utf-8;

# Proxy auth for collaboration server
location /collaboration/ws/ {
if ($request_method = OPTIONS) {
add_header 'Access-Control-Allow-Origin' 'http://localhost:3000';
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type';
return 204;
}

# Collaboration Auth request configuration
auth_request /collaboration-auth;
auth_request_set $authHeader $upstream_http_authorization;
Expand Down Expand Up @@ -34,6 +41,10 @@ server {
}

location /collaboration-auth {
proxy_cache auth_cache;
proxy_cache_key "$http_authorization";
proxy_cache_valid 200 30s;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add something more specific to avoid sharing the same cache key later with an other location

Suggested change
proxy_cache_key "$http_authorization";
proxy_cache_key "$http_authorization$request_uri";


proxy_pass http://app-dev:8000/api/v1.0/documents/collaboration-auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
Expand All @@ -43,10 +54,11 @@ server {
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Accept "application/json";
proxy_set_header X-Original-Method $request_method;
}

location /collaboration/api/ {
location /collaboration/api/ {
# Collaboration server
proxy_pass http://y-provider:4444;
proxy_set_header Host $host;
Expand Down Expand Up @@ -76,7 +88,7 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;

# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { expect, test } from '@playwright/test';

import { createDoc, verifyDocName } 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 [title] = await createDoc(page, 'doc-editor', browserName, 1);
await verifyDocName(page, title);

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.getByLabel('Visibility', { exact: true });

// When the visibility is changed, the ws should closed the connection (backend signal)
const wsClosePromise = webSocket.waitForEvent('close');

await selectVisibility.click();
await page
.getByRole('button', {
name: 'Connected',
})
.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();

await createDoc(page, 'doc-polling', browserName, 1);

const response = await responsePromise;
expect(response.ok()).toBeTruthy();
});
});
64 changes: 0 additions & 64 deletions src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,70 +88,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 verifyDocName(page, randomDoc[0]);

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.getByLabel('Visibility', { exact: true });

// When the visibility is changed, the ws should closed the connection (backend signal)
const wsClosePromise = webSocket.waitForEvent('close');

await selectVisibility.click();
await page
.getByRole('button', {
name: 'Connected',
})
.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
1 change: 1 addition & 0 deletions src/frontend/apps/impress/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"@types/node": "*",
"@types/react": "18.3.12",
"@types/react-dom": "*",
"@types/ws": "8.5.13",
"cross-env": "7.0.3",
"dotenv": "16.4.7",
"eslint-config-impress": "*",
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
@@ -0,0 +1,67 @@
import { APIError, errorCauses } from '@/api';

interface PollOutgoingMessageParams {
pollUrl: string;
message64: string;
}
interface PollOutgoingMessageResponse {
updated?: boolean;
}

export const pollOutgoingMessageRequest = async ({
pollUrl,
message64,
}: PollOutgoingMessageParams): Promise<PollOutgoingMessageResponse> => {
const response = await fetch(pollUrl, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message64,
}),
});

if (!response.ok) {
throw new APIError(
`Post poll message request failed`,
await errorCauses(response),
);
}

return response.json() as Promise<PollOutgoingMessageResponse>;
};

interface PollSyncParams {
pollUrl: string;
localDoc64: string;
}
interface PollSyncResponse {
syncDoc64?: string;
}

export const postPollSyncRequest = async ({
pollUrl,
localDoc64,
}: PollSyncParams): Promise<PollSyncResponse> => {
const response = await fetch(pollUrl, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
localDoc64,
}),
});

if (!response.ok) {
throw new APIError(
`Sync request failed: ${response.status} ${response.statusText}`,
await errorCauses(response),
);
}

return response.json() as Promise<PollSyncResponse>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,29 @@ import { useBroadcastStore } from '@/stores';
import { useProviderStore } from '../stores/useProviderStore';
import { Base64 } from '../types';

export const useCollaboration = (room?: string, initialContent?: Base64) => {
export const useCollaboration = (
room?: string,
initialContent?: Base64,
canEdit?: boolean,
) => {
const collaborationUrl = useCollaborationUrl(room);
const { setBroadcastProvider } = useBroadcastStore();
const { provider, createProvider, destroyProvider } = useProviderStore();

/**
* Initialize the provider
*/
useEffect(() => {
if (!room || !collaborationUrl || provider) {
if (!room || !collaborationUrl || provider || canEdit === undefined) {
return;
}

const newProvider = createProvider(collaborationUrl, room, initialContent);
const newProvider = createProvider(
collaborationUrl,
room,
canEdit,
initialContent,
);
setBroadcastProvider(newProvider);
}, [
provider,
Expand All @@ -25,6 +37,7 @@ export const useCollaboration = (room?: string, initialContent?: Base64) => {
initialContent,
createProvider,
setBroadcastProvider,
canEdit,
]);

/**
Expand Down
Loading
Loading