♻️(collaboration) switch collaboration server from hocuspocus to yhub

Signed-off-by: Kevin Jahns <kevin.jahns@protonmail.com>
This commit is contained in:
Kevin Jahns
2026-09-04 15:27:25 +02:00
committed by Anthony LC
parent 1debd291d8
commit 77c8b2f138
61 changed files with 1303 additions and 2526 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
PORT=3000
BASE_URL=http://localhost:3000
BASE_API_URL=http://localhost:8071/api/v1.0
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
COLLABORATION_WS_URL=ws://localhost:3002/ws/docs
MEDIA_BASE_URL=http://localhost:8083
CUSTOM_SIGN_IN=false
IS_INSTANCE=false
+1 -1
View File
@@ -1,7 +1,7 @@
PORT=3000
BASE_URL=http://localhost:3000
BASE_API_URL=http://localhost:8071/api/v1.0
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
COLLABORATION_WS_URL=ws://localhost:3002/ws/docs
MEDIA_BASE_URL=http://localhost:8083
IS_INSTANCE=false
CUSTOM_SIGN_IN=false
@@ -82,9 +82,9 @@ test.describe('Config', () => {
.click();
const webSocket = await page.waitForEvent('websocket', (webSocket) => {
return webSocket.url().includes(`${process.env.COLLABORATION_WS_URL}`);
return webSocket.url().includes(`${process.env.COLLABORATION_WS_URL}/`);
});
expect(webSocket.url()).toContain(`${process.env.COLLABORATION_WS_URL}`);
expect(webSocket.url()).toContain(`${process.env.COLLABORATION_WS_URL}/`);
});
test('it checks FRONTEND_CSS_URL config', async ({ page }) => {
@@ -15,14 +15,10 @@ 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 }) => {
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket.url().includes(`${process.env.COLLABORATION_WS_URL}/`);
});
await page
@@ -32,42 +28,21 @@ test.describe('Doc Collaboration', () => {
})
.click();
let webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
`${process.env.COLLABORATION_WS_URL}?room=`,
);
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(`${process.env.COLLABORATION_WS_URL}/`);
// Is connected
let framesentPromise = webSocket.waitForEvent('framesent');
const framesentPromise = webSocket.waitForEvent('framesent');
await writeInEditor({ page, text: 'Hello World' });
let framesent = await framesentPromise;
const framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
// When the visibility is changed, the ws should close the connection (backend signal)
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
// Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise;
expect(wsClose.isClosed()).toBeTruthy();
// Check the ws is connected again
webSocket = await page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
});
framesentPromise = webSocket.waitForEvent('framesent');
framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
// TODO(yhub): re-add the close/reconnect check (the backend closed the
// connection when the doc visibility changed) once yhub exposes a kick
// API - `reset_connections` is currently a no-op so the server never
// closes the connection.
});
test('it cannot edit if viewer but see and can get resources', async ({
@@ -136,20 +111,24 @@ test.describe('Doc Collaboration', () => {
await cleanup();
});
test('it checks block editing when not connected to collab server', async ({
// TODO(yhub): re-enable when yhub exposes a connection-info API - the test
// asserts `can_edit=false` while another user is connected to the
// collaborative server, but `get_document_connection_info` is currently
// stubbed to report no connections.
test.skip('it checks block editing when not connected to collab server', async ({
page,
browserName,
}) => {
test.slow();
/**
* The good port is 4444, but we want to simulate a not connected
* The good port is 3002, but we want to simulate a not connected
* collaborative server.
* So we use a port that is not used by the collaborative server.
* The server will not be able to connect to the collaborative server.
*/
await overrideConfig(page, {
COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/',
COLLABORATION_WS_URL: 'ws://localhost:5555/ws/docs',
COLLABORATION_WS_NOT_CONNECTED_READ_ONLY: true,
});
@@ -211,18 +190,14 @@ test.describe('Doc Collaboration', () => {
const webSocketPromise = otherPage.waitForEvent(
'websocket',
(webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
return webSocket.url().includes(`${process.env.COLLABORATION_WS_URL}/`);
},
);
await otherPage.goto(urlChildDoc);
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
`${process.env.COLLABORATION_WS_URL}?room=`,
);
expect(webSocket.url()).toContain(`${process.env.COLLABORATION_WS_URL}/`);
await verifyDocName(otherPage, childTitle);
@@ -288,9 +263,7 @@ test.describe('Doc Collaboration', () => {
await page.goto('/');
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
return webSocket.url().includes(`${process.env.COLLABORATION_WS_URL}/`);
});
await page
@@ -301,9 +274,7 @@ test.describe('Doc Collaboration', () => {
.click();
let webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
`${process.env.COLLABORATION_WS_URL}?room=`,
);
expect(webSocket.url()).toContain(`${process.env.COLLABORATION_WS_URL}/`);
// Is connected
let framesentPromise = webSocket.waitForEvent('framesent');
@@ -332,9 +303,7 @@ test.describe('Doc Collaboration', () => {
// Check the ws is connected again
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
return webSocket.url().includes(`${process.env.COLLABORATION_WS_URL}/`);
});
// Simulate the tab becoming visible again
+1 -1
View File
@@ -44,7 +44,6 @@
"@fontsource-variable/material-symbols-outlined": "5.3.3",
"@fontsource/material-icons": "5.3.0",
"@gouvfr-lasuite/ui-components": "1.1.0",
"@hocuspocus/provider": "3.4.4",
"@lottiefiles/dotlottie-react": "^0.19.6",
"@mantine/core": "9.5.2",
"@mantine/hooks": "9.5.2",
@@ -84,6 +83,7 @@
"uuid": "14.0.2",
"y-prosemirror": "1.3.7",
"y-protocols": "1.0.7",
"y-websocket": "3.0.0",
"yjs": "*",
"zod": "4.4.3",
"zustand": "5.0.15"
@@ -7,11 +7,12 @@ export const useCollaborationUrl = (room?: string) => {
return;
}
const base =
// The room is appended to the base URL by the provider (y-websocket)
return (
conf?.COLLABORATION_WS_URL ||
(typeof window !== 'undefined'
? `wss://${window.location.host}/collaboration/ws/`
: '');
return `${base}?room=${room}`;
? // TODO(yhub): no prod ingress route yet
`wss://${window.location.host}/ws/docs`
: '')
);
};
@@ -30,13 +30,13 @@ export function useComments(
canComment,
config?.REACTIONS_MAX_PER_COMMENT ?? 0,
),
provider?.document,
provider?.doc,
);
}, [
docId,
canComment,
provider?.awareness,
provider?.document,
provider?.doc,
user?.full_name,
config?.REACTIONS_MAX_PER_COMMENT,
]);
@@ -28,8 +28,8 @@ vi.mock('../../doc-management', async () => {
useIsCollaborativeEditable: () => ({ isEditable: true, isLoading: false }),
useProviderStore: () => ({
provider: {
configuration: { name: 'test-doc-id' },
document: {
roomname: 'test-doc-id',
doc: {
getXmlFragment: () => null,
},
},
@@ -26,12 +26,11 @@ import {
ThreadsSidebar,
useCreateBlockNote,
} from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { FindAndReplace } from '@tiptap/extension-find-and-replace';
import { useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type { Awareness } from 'y-protocols/awareness';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
@@ -102,7 +101,7 @@ export const blockNoteSchema = (withMultiColumn?.(baseBlockNoteSchema) ||
interface BlockNoteEditorProps {
doc: Doc;
provider: HocuspocusProvider;
provider: WebsocketProvider;
}
export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
@@ -110,7 +109,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { setEditor } = useEditorStore();
const { themeTokens } = useCunninghamTheme();
const refEditorContainer = useRef<HTMLDivElement>(null);
useSaveDoc(doc.id, provider.document);
useSaveDoc(doc.id, provider.doc);
const { i18n, t } = useTranslation();
const langLocalesBN =
@@ -172,8 +171,8 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const editor: DocsBlockNoteEditor = useCreateBlockNote(
withCollaboration({
collaboration: {
provider: provider as { awareness?: Awareness | undefined },
fragment: provider.document.getXmlFragment('document-store'),
provider,
fragment: provider.doc.getXmlFragment('document-store'),
user: {
name: cursorName,
color: randomColor(),
@@ -142,16 +142,10 @@ interface DocCoreEditorProps {
export const DocCoreEditor = ({ doc, readOnly }: DocCoreEditorProps) => {
const { provider, isReady } = useProviderStore();
const isProviderReady = isReady && provider;
const showContent = !!(
isProviderReady && provider?.configuration.name === doc.id
);
const showContent = !!(isProviderReady && provider?.roomname === doc.id);
const { skeletonVisible, isFadingOut } = useSkeletonFadeOut(showContent);
if (
skeletonVisible ||
!isProviderReady ||
provider?.configuration.name !== doc.id
) {
if (skeletonVisible || !isProviderReady || provider?.roomname !== doc.id) {
return (
<SkeletonEditorCore
isFadingOut={isFadingOut}
@@ -165,7 +159,7 @@ export const DocCoreEditor = ({ doc, readOnly }: DocCoreEditorProps) => {
if (readOnly) {
return (
<BlockNoteReader
initialContent={provider.document.getXmlFragment('document-store')}
initialContent={provider.doc.getXmlFragment('document-store')}
docId={doc.id}
/>
);
@@ -56,6 +56,9 @@ export const useCollaboration = (room: string) => {
* When the provider detects a lost connection, we invalidate the document query to trigger a refetch.
* Because it can be because the user has access to the document that are modified
* (e.g., permissions changed, document deleted, user removed)
* TODO(yhub): this invalidation used to ride on the server-side kick
* (reset-connections); without a kick API a permission change no longer
* triggers a refetch until the connection drops for another reason.
*/
useEffect(() => {
if (hasLostConnection && room) {
@@ -71,7 +74,7 @@ export const useCollaboration = (room: string) => {
* when the document visibility changes.
*/
useEffect(() => {
if (!room || broadcastProvider?.document?.guid !== room) {
if (!room || broadcastProvider?.doc.guid !== room) {
return;
}
@@ -80,7 +83,7 @@ export const useCollaboration = (room: string) => {
queryKey: [KEY_DOC, { id: room }],
});
});
}, [addTask, room, queryClient, broadcastProvider?.document?.guid]);
}, [addTask, room, queryClient, broadcastProvider?.doc.guid]);
/**
* Set the provider when the collaboration URL and the document content are available.
@@ -1,5 +1,6 @@
import { useRouter } from 'next/router';
import { useCallback, useEffect, useRef, useState } from 'react';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
import {
@@ -52,22 +53,19 @@ export const useSaveDoc = (docId: string, yDoc: Y.Doc) => {
) => {
/**
* When the AI edit the doc transaction.local is false,
* so we check if the origin constructor to know where
* so we check the transaction origin to know where
* the transaction comes from.
* "PluginKey" constructor comes from the current user, but transaction.local is more reliable
* "HocuspocusProvider" constructor comes from other users from the collaboration server,
* it seems quite reliable too.
* The AI constructor name seems to not be reliable enough, but by deduction if it's not local
* "PluginKey" origin comes from the current user, but transaction.local is more reliable
* Updates from other users are applied by the collaboration server with
* the provider instance as origin, it seems quite reliable too.
* The AI origin seems to not be reliable enough, but by deduction if it's not local
* and not from other users, it has to be from the AI.
*
* TODO: see if we can get the local changes from the AI
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const transactionOrigin = transaction?.origin?.constructor?.name;
const PROVIDER_ORIGIN_CONSTRUCTOR = 'HocuspocusProvider';
const isAIChange =
!transaction.local && transactionOrigin !== PROVIDER_ORIGIN_CONSTRUCTOR;
!transaction.local &&
!(transaction.origin instanceof WebsocketProvider);
/**
* notifySubscribers generate a transaction that can be
@@ -67,14 +67,12 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
mutationFn: async (variables) => {
// Save the document if we can first, to ensure the latest state is duplicated
const canSave =
variables.canSave &&
provider &&
provider.document.guid === variables.docId;
variables.canSave && provider && provider.doc.guid === variables.docId;
if (canSave) {
await updateDocContent({
id: variables.docId,
content: toBase64(Y.encodeStateAsUpdate(provider.document)),
content: toBase64(Y.encodeStateAsUpdate(provider.doc)),
});
}
@@ -6,11 +6,13 @@ import {
import { APIError, errorCauses, fetchAPI } from '@/api';
import { useProviderStore } from '../stores';
import { Doc } from '../types';
export interface UpdateDocParams {
id: Doc['id'];
title?: string;
websocket?: boolean;
}
export const updateDoc = async ({
@@ -38,7 +40,16 @@ type UseUpdateDoc = UseMutationOptions<Doc, APIError, UpdateDocParams> & {
export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
const queryClient = useQueryClient();
return useMutation<Doc, APIError, UpdateDocParams>({
mutationFn: updateDoc,
/**
* Tell the backend when we hold a live collaboration connection,
* otherwise its no-websocket cache lock blocks the update while
* another user is connected.
*/
mutationFn: (params) =>
updateDoc({
...(useProviderStore.getState().isSynced ? { websocket: true } : {}),
...params,
}),
...queryConfig,
onSuccess: (data, variables, onMutateResult, context) => {
queryConfig?.listInvalidQueries?.forEach((queryKey) => {
@@ -1,5 +1,4 @@
import { CloseEvent } from '@hocuspocus/common';
import { HocuspocusProvider, WebSocketStatus } from '@hocuspocus/provider';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
import { create } from 'zustand';
@@ -10,12 +9,12 @@ export interface UseCollaborationStore {
providerUrl: string,
storeId: string,
initialDoc?: Base64,
) => HocuspocusProvider;
) => WebsocketProvider;
destroyProvider: () => void;
setReady: (value: boolean) => void;
pauseForInactivity: () => void;
resumeFromInactivity: () => void;
provider: HocuspocusProvider | undefined;
provider: WebsocketProvider | undefined;
isConnected: boolean;
isReady: boolean;
isSynced: boolean;
@@ -33,18 +32,14 @@ const defaultValues = {
isPausedForInactivity: false,
};
type ExtendedCloseEvent = CloseEvent & { wasClean: boolean };
/**
* When a massive simultaneous disconnection occurs (e.g. infra restart), all
* clients would reconnect and invalidate their queries at exactly the same
* time, causing a possible DB spike. Adding random jitter spreads these events over a
* time window so the load is absorbed gradually.
*/
const RECONNECT_BASE_DELAY_MS = 1000;
const RECONNECT_JITTER_MAX_MS = 3000;
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined;
let lostConnectionTimeout: ReturnType<typeof setTimeout> | undefined;
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
@@ -58,104 +53,54 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
}
const provider = new HocuspocusProvider({
url: wsUrl,
name: storeId,
document: doc,
onDisconnect(data) {
// Skip reconnect when the disconnect was triggered by inactivity:
// reconnection only happens once the user becomes active again.
if (get().isPausedForInactivity) {
return;
}
// Attempt to reconnect if the disconnection was clean (initiated by the client or server)
if ((data.event as ExtendedCloseEvent).wasClean) {
if (data.event.reason === 'No cookies' && data.event.code === 4001) {
console.error(
'Disconnection due to missing cookies. Not attempting to reconnect.',
);
void provider.disconnect();
set({
isReady: true,
isConnected: false,
});
return;
}
clearTimeout(reconnectTimeout);
// Jitter spreading for reconnection attempts
// Math.random() generates a random delay to avoid all clients
// reconnecting at the same time
reconnectTimeout = setTimeout(
() => void provider.connect(),
RECONNECT_BASE_DELAY_MS + Math.random() * RECONNECT_JITTER_MAX_MS,
);
}
},
onAuthenticationFailed() {
set({ isReady: true, isConnected: false });
},
onAuthenticated() {
set({ isReady: true, isConnected: true });
},
onStatus: ({ status }) => {
const isConnected = status === WebSocketStatus.Connected;
const wasConnected = get().isConnected;
if (isConnected) {
clearTimeout(lostConnectionTimeout);
}
// If we were previously connected and now we're not,
// we might have lost the connection
else if (wasConnected && !get().isPausedForInactivity) {
clearTimeout(lostConnectionTimeout);
// Jitter spreading for reconnection attempts
// Math.random() generates a random delay to avoid all clients
// reconnecting at the same time
lostConnectionTimeout = setTimeout(
() => set({ hasLostConnection: true }),
Math.random() * RECONNECT_JITTER_MAX_MS,
);
}
set((state) => {
/**
* status === WebSocketStatus.Connected does not mean we are totally connected
* because authentication can still be in progress and failed
* So we only update isConnected when we lose the connection
*/
const connected =
status !== WebSocketStatus.Connected
? {
isConnected: false,
}
: undefined;
return {
...connected,
isReady: state.isReady || status === WebSocketStatus.Disconnected,
};
});
},
onSynced: ({ state }) => {
set({ isSynced: state, isReady: true });
},
onClose(data) {
/**
* Handle the "Reset Connection" event from the server
* This is triggered when the server wants to reset the connection
* for clients in the room.
* A disconnect is made automatically but it takes time to be triggered,
* so we force the disconnection here.
*/
if (data.event.code === 1000) {
provider.disconnect();
}
},
const provider = new WebsocketProvider(wsUrl, storeId, doc, {
// BroadcastChannel would bypass server auth
disableBc: true,
// The default 2.5s backoff would hammer the backend with auth fetches
// on permanently-failing sockets
maxBackoffTime: 30000,
// Guarantees inbound traffic for y-websocket's 30s no-traffic watchdog
resyncInterval: 20000,
});
provider.on('status', ({ status }) => {
// 'connecting' must be ignored: it fires on every backoff retry.
// 'disconnected' is handled via 'connection-close' (it never fires
// for sockets that failed to open).
if (status === 'connected') {
clearTimeout(lostConnectionTimeout);
// An open socket means we are authenticated (auth happens at upgrade)
set({ isConnected: true, isReady: true });
}
});
provider.on('sync', (isSynced: boolean) => {
set({ isSynced, isReady: true });
});
// Fires on every close AND every failed connection attempt
// (an auth failure surfaces as an upgrade-level 401, close code 1006).
provider.on('connection-close', () => {
// Skip when the disconnect was triggered by inactivity:
// reconnection only happens once the user becomes active again.
if (get().isPausedForInactivity) {
return;
}
// The editor renders from the last snapshot while y-websocket retries
set({ isConnected: false, isReady: true });
clearTimeout(lostConnectionTimeout);
// Jitter spreading: Math.random() generates a random delay to avoid
// all clients invalidating their queries at the same time
lostConnectionTimeout = setTimeout(
() => set({ hasLostConnection: true }),
Math.random() * RECONNECT_JITTER_MAX_MS,
);
});
// TODO(yhub): re-add kick handling when yhub exposes a kick API (was onClose code 1000).
set({
provider,
});
@@ -163,12 +108,19 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
return provider;
},
destroyProvider: () => {
clearTimeout(reconnectTimeout);
clearTimeout(lostConnectionTimeout);
const provider = get().provider;
if (provider) {
/**
* destroy() emits 'connection-close' synchronously before removing
* listeners, which re-arms lostConnectionTimeout: it must be cleared
* after, or a stale "connection lost" banner flashes on the next doc.
*/
provider.destroy();
// y-websocket never destroys the awareness: its interval would leak
provider.awareness.destroy();
provider.doc.destroy();
}
clearTimeout(lostConnectionTimeout);
set(defaultValues);
},
@@ -177,7 +129,6 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
if (get().isPausedForInactivity) {
return;
}
clearTimeout(reconnectTimeout);
clearTimeout(lostConnectionTimeout);
set({ isPausedForInactivity: true, hasLostConnection: false });
get().provider?.disconnect();
@@ -188,7 +139,7 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
}
clearTimeout(lostConnectionTimeout);
set({ isPausedForInactivity: false });
void get().provider?.connect();
get().provider?.connect();
},
resetLostConnection: () => set({ hasLostConnection: false }),
}));
@@ -58,11 +58,7 @@ export const ModalConfirmationVersion = ({
return;
}
revertUpdate(
provider.document,
provider.document,
base64ToYDoc(version.content),
);
revertUpdate(provider.doc, provider.doc, base64ToYDoc(version.content));
threadStore?.refreshThreads();
@@ -19,8 +19,7 @@ export const RightPanel = () => {
const { setIsPanelOpen, isPanelOpen, activePanel } = useRightPanelStore();
const { isMobile } = useResponsiveStore();
const { provider, isReady } = useProviderStore();
const isProviderReady =
isReady && provider && provider?.configuration.name === doc?.id;
const isProviderReady = isReady && provider && provider?.roomname === doc?.id;
const { restoreFocus } = useFocusStore();
/** Side panel must be explicitly opened for each document. */
@@ -1,4 +1,4 @@
import { HocuspocusProvider } from '@hocuspocus/provider';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
import { create } from 'zustand';
@@ -6,10 +6,10 @@ interface BroadcastState {
addTask: (taskLabel: string, action: () => void) => void;
broadcast: (taskLabel: string) => void;
cleanupBroadcast: () => void;
getBroadcastProvider: () => HocuspocusProvider | undefined;
handleProviderSync: () => void;
provider?: HocuspocusProvider;
setBroadcastProvider: (provider: HocuspocusProvider) => void;
getBroadcastProvider: () => WebsocketProvider | undefined;
handleProviderSync: (isSynced: boolean) => void;
provider?: WebsocketProvider;
setBroadcastProvider: (provider: WebsocketProvider) => void;
setTask: (
taskLabel: string,
task: Y.Array<string>,
@@ -34,13 +34,18 @@ export const useBroadcastStore = create<BroadcastState>((set, get) => ({
// Clean up old provider listeners
const oldProvider = get().provider;
if (oldProvider) {
oldProvider.off('synced', get().handleProviderSync);
oldProvider.off('sync', get().handleProviderSync);
}
provider.on('synced', get().handleProviderSync);
provider.on('sync', get().handleProviderSync);
set({ provider });
},
handleProviderSync: () => {
handleProviderSync: (isSynced) => {
// 'sync' fires on both edges; only re-register the tasks once synced
if (!isSynced) {
return;
}
const tasks = get().tasks;
Object.entries(tasks).forEach(([taskLabel, { action }]) => {
get().addTask(taskLabel, action);
@@ -61,10 +66,16 @@ export const useBroadcastStore = create<BroadcastState>((set, get) => ({
return;
}
const task = provider.document.getArray<string>(taskLabel);
const task = provider.doc.getArray<string>(taskLabel);
get().setTask(taskLabel, task, action);
},
setTask: (taskLabel: string, task: Y.Array<string>, action: () => void) => {
// Unobserve the previous observer to avoid leaking one per re-registration
const previousTask = get().tasks[taskLabel];
if (previousTask) {
previousTask.task.unobserve(previousTask.observer);
}
let isInitializing = true;
const observer = (
_event: Y.YArrayEvent<string>,
@@ -102,7 +113,7 @@ export const useBroadcastStore = create<BroadcastState>((set, get) => ({
cleanupBroadcast: () => {
const provider = get().provider;
if (provider) {
provider.off('synced', get().handleProviderSync);
provider.off('sync', get().handleProviderSync);
}
// Unobserve all document-specific tasks
+1
View File
@@ -49,6 +49,7 @@
"sharp": "0.35.3",
"typescript": "6.0.3",
"wrap-ansi": "10.0.1",
"y-protocols": "1.0.7",
"yjs": "13.6.32"
},
"packageManager": "yarn@1.22.22"
@@ -1,66 +0,0 @@
import axios from 'axios';
import { describe, expect, test, vi } from 'vitest';
vi.mock('../src/env', () => ({
COLLABORATION_BACKEND_BASE_URL: 'http://app-dev:8000',
Y_PROVIDER_API_KEY: 'test-yprovider-key',
}));
describe('CollaborationBackend', () => {
test('fetchDocument sends X-Y-Provider-Key header', async () => {
const axiosGetSpy = vi.spyOn(axios, 'get').mockResolvedValue({
status: 200,
data: {
id: 'test-doc-id',
abilities: { retrieve: true, update: true },
},
});
const { fetchDocument } = await import('@/api/collaborationBackend');
const documentId = 'test-document-123';
await fetchDocument({ name: documentId }, { cookie: 'test-cookie' });
expect(axiosGetSpy).toHaveBeenCalledWith(
`http://app-dev:8000/api/v1.0/documents/${documentId}/`,
expect.objectContaining({
headers: expect.objectContaining({
'X-Y-Provider-Key': 'test-yprovider-key',
cookie: 'test-cookie',
}),
}),
);
axiosGetSpy.mockRestore();
});
test('fetchCurrentUser sends X-Y-Provider-Key header', async () => {
const axiosGetSpy = vi.spyOn(axios, 'get').mockResolvedValue({
status: 200,
data: {
id: 'test-user-id',
email: 'test@example.com',
},
});
const { fetchCurrentUser } = await import('@/api/collaborationBackend');
await fetchCurrentUser({
cookie: 'test-cookie',
origin: 'http://localhost:3000',
});
expect(axiosGetSpy).toHaveBeenCalledWith(
'http://app-dev:8000/api/v1.0/users/me/',
expect.objectContaining({
headers: expect.objectContaining({
'X-Y-Provider-Key': 'test-yprovider-key',
cookie: 'test-cookie',
origin: 'http://localhost:3000',
}),
}),
);
axiosGetSpy.mockRestore();
});
});
@@ -1,63 +0,0 @@
import request from 'supertest';
import { describe, expect, test, vi } from 'vitest';
vi.mock('../src/env', async (importOriginal) => {
return {
...(await importOriginal()),
PORT: 5555,
COLLABORATION_SERVER_ORIGIN: 'http://localhost:3000',
COLLABORATION_SERVER_SECRET: 'test-secret-api-key',
};
});
console.error = vi.fn();
import { COLLABORATION_SERVER_ORIGIN as origin } from '@/env';
import { hocuspocusServer, initApp } from '@/servers';
describe('Server Tests', () => {
test('POST /collaboration/api/reset-connections?room=[ROOM_ID] with incorrect API key should return 403', async () => {
const app = initApp();
const response = await request(app)
.post('/collaboration/api/reset-connections/?room=test-room')
.set('Origin', origin)
.set('Authorization', 'wrong-api-key');
expect(response.status).toBe(401);
expect(response.body).toStrictEqual({
error: 'Unauthorized: Invalid API Key',
});
});
test('POST /collaboration/api/reset-connections?room=[ROOM_ID] failed if room not indicated', async () => {
const app = initApp();
const response = await request(app)
.post('/collaboration/api/reset-connections/')
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key')
.send({ document_id: 'test-document' });
expect(response.status).toBe(400);
expect(response.body).toStrictEqual({ error: 'Room name not provided' });
});
test('POST /collaboration/api/reset-connections?room=[ROOM_ID] with correct API key should reset connections', async () => {
const closeConnectionsMock = vi
.spyOn(hocuspocusServer.hocuspocus, 'closeConnections')
.mockResolvedValue();
const app = initApp();
const response = await request(app)
.post('/collaboration/api/reset-connections?room=test-room')
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key');
expect(response.status).toBe(200);
expect(response.body).toStrictEqual({ message: 'Connections reset' });
expect(closeConnectionsMock).toHaveBeenCalledOnce();
});
});
@@ -1,275 +0,0 @@
import request from 'supertest';
import { v4 as uuid } from 'uuid';
import { describe, expect, test, vi } from 'vitest';
vi.mock('../src/env', async (importOriginal) => {
return {
...(await importOriginal()),
PORT: 5556,
COLLABORATION_SERVER_ORIGIN: 'http://localhost:3000',
COLLABORATION_SERVER_SECRET: 'test-secret-api-key',
};
});
console.error = vi.fn();
import { COLLABORATION_SERVER_ORIGIN as origin } from '@/env';
import { hocuspocusServer, initApp } from '@/servers';
const apiEndpoint = '/collaboration/api/get-connections/';
describe('Server Tests', () => {
test('POST /collaboration/api/get-connections?room=[ROOM_ID] with incorrect API key should return 403', async () => {
const app = initApp();
const response = await request(app)
.get(`${apiEndpoint}?room=test-room`)
.set('Origin', origin)
.set('Authorization', 'wrong-api-key');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Unauthorized: Invalid API Key');
});
test('POST /collaboration/api/get-connections?room=[ROOM_ID] failed if room not indicated', async () => {
const app = initApp();
const response = await request(app)
.get(`${apiEndpoint}`)
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key')
.send({ document_id: 'test-document' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Room name not provided');
});
test('POST /collaboration/api/get-connections?room=[ROOM_ID] failed if session key not indicated', async () => {
const app = initApp();
const response = await request(app)
.get(`${apiEndpoint}?room=test-room`)
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key')
.send({ document_id: 'test-document' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Session key not provided');
});
test('POST /collaboration/api/get-connections?room=[ROOM_ID] return a 404 if room not found', async () => {
const app = initApp();
const response = await request(app)
.get(`${apiEndpoint}?room=test-room&sessionKey=test-session-key`)
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Room not found');
});
test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key existing', async () => {
const document = await hocuspocusServer.hocuspocus.createDocument(
'test-room',
{},
uuid(),
{ isAuthenticated: true, readOnly: false },
{},
);
document.addConnection({
webSocket: 1,
context: { sessionKey: 'test-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 2,
context: { sessionKey: 'other-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 3,
context: { sessionKey: 'last-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 4,
context: { sessionKey: 'session-read-only' },
document: document,
pongReceived: false,
readOnly: true,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
const app = initApp();
const response = await request(app)
.get(`${apiEndpoint}?room=test-room&sessionKey=test-session-key`)
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key');
expect(response.status).toBe(200);
expect(response.body).toEqual({
count: 3,
exists: true,
});
});
test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key not existing', async () => {
const document = await hocuspocusServer.hocuspocus.createDocument(
'test-room',
{},
uuid(),
{ isAuthenticated: true, readOnly: false },
{},
);
document.addConnection({
webSocket: 1,
context: { sessionKey: 'test-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 2,
context: { sessionKey: 'other-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 3,
context: { sessionKey: 'last-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 4,
context: { sessionKey: 'session-read-only' },
document: document,
pongReceived: false,
readOnly: true,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
const app = initApp();
const response = await request(app)
.get(`${apiEndpoint}?room=test-room&sessionKey=non-existing-session-key`)
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key');
expect(response.status).toBe(200);
expect(response.body).toEqual({
count: 3,
exists: false,
});
});
test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key not existing, read only connection', async () => {
const document = await hocuspocusServer.hocuspocus.createDocument(
'test-room',
{},
uuid(),
{ isAuthenticated: true, readOnly: false },
{},
);
document.addConnection({
webSocket: 1,
context: { sessionKey: 'test-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 2,
context: { sessionKey: 'other-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 3,
context: { sessionKey: 'last-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 4,
context: { sessionKey: 'session-read-only' },
document: document,
pongReceived: false,
readOnly: true,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
const app = initApp();
const response = await request(app)
.get(`${apiEndpoint}?room=test-room&sessionKey=session-read-only`)
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key');
expect(response.status).toBe(200);
expect(response.body).toEqual({
count: 3,
exists: false,
});
});
});
@@ -1,388 +0,0 @@
import { Server } from 'node:net';
import {
HocuspocusProvider,
HocuspocusProviderWebsocket,
} from '@hocuspocus/provider';
import { v1 as uuidv1, v4 as uuidv4 } from 'uuid';
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
test,
vi,
} from 'vitest';
import WebSocket from 'ws';
const portWS = 6666;
vi.mock('../src/env', async (importOriginal) => {
return {
...(await importOriginal()),
PORT: 5559,
COLLABORATION_SERVER_ORIGIN: 'http://localhost:3000',
COLLABORATION_SERVER_SECRET: 'test-secret-api-key',
COLLABORATION_BACKEND_BASE_URL: 'http://app-dev:8000',
COLLABORATION_LOGGING: 'true',
};
});
vi.mock('../src/api/collaborationBackend', () => ({
fetchCurrentUser: vi.fn(),
fetchDocument: vi.fn(),
}));
console.error = vi.fn();
console.log = vi.fn();
import * as CollaborationBackend from '@/api/collaborationBackend';
import { COLLABORATION_SERVER_ORIGIN as origin, PORT as port } from '@/env';
import { promiseDone } from '@/helpers';
import { hocuspocusServer, initApp } from '@/servers';
describe('Server Tests', () => {
let server: Server;
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
beforeAll(async () => {
server = initApp().listen(port);
await hocuspocusServer.listen(portWS);
});
afterAll(() => {
void hocuspocusServer.destroy();
server.close();
});
test('WebSocket connection with bad origin should be closed', () => {
const { promise, done } = promiseDone();
const room = uuidv4();
const ws = new WebSocket(`ws://localhost:${port}/?room=${room}`, {
headers: {
Origin: 'http://bad-origin.com',
},
});
ws.onclose = () => {
expect(ws.readyState).toBe(ws.CLOSED);
done();
};
return promise;
});
test('WebSocket connection without cookies header should be closed', () => {
const { promise, done } = promiseDone();
const room = uuidv4();
const ws = new WebSocket(`ws://localhost:${port}/?room=${room}`, {
headers: {
Origin: origin,
},
});
ws.onclose = () => {
expect(ws.readyState).toBe(ws.CLOSED);
done();
};
return promise;
});
test('WebSocket connection not allowed if room not matching provider name', () => {
const { promise, done } = promiseDone();
const room = uuidv4();
const wsHocus = new HocuspocusProviderWebsocket({
url: `ws://localhost:${portWS}/?room=${room}`,
WebSocketPolyfill: WebSocket,
maxAttempts: 1,
});
const providerName = uuidv4();
const provider = new HocuspocusProvider({
websocketProvider: wsHocus,
name: providerName,
onAuthenticationFailed(data) {
expect(console.log).toHaveBeenCalledWith(
expect.any(String),
' --- ',
'Invalid room name - Probable hacking attempt:',
providerName,
room,
);
wsHocus.stopConnectionAttempt();
expect(data.reason).toBe('permission-denied');
wsHocus.webSocket?.close();
wsHocus.disconnect();
provider.destroy();
wsHocus.destroy();
done();
},
});
provider.attach();
return promise;
});
test('WebSocket connection not allowed if room is not a valid uuid v4', () => {
const { promise, done } = promiseDone();
const room = uuidv1();
const wsHocus = new HocuspocusProviderWebsocket({
url: `ws://localhost:${portWS}/?room=${room}`,
WebSocketPolyfill: WebSocket,
maxAttempts: 1,
});
const provider = new HocuspocusProvider({
websocketProvider: wsHocus,
name: room,
onAuthenticationFailed: (data) => {
expect(console.log).toHaveBeenLastCalledWith(
expect.any(String),
' --- ',
'Room name is not a valid uuid:',
room,
);
wsHocus.stopConnectionAttempt();
expect(data.reason).toBe('permission-denied');
wsHocus.webSocket?.close();
wsHocus.disconnect();
provider.destroy();
wsHocus.destroy();
done();
},
});
provider.attach();
return promise;
});
test('WebSocket connection not allowed if room is not a valid uuid', () => {
const { promise, done } = promiseDone();
const room = 'not-a-valid-uuid';
const wsHocus = new HocuspocusProviderWebsocket({
url: `ws://localhost:${portWS}/?room=${room}`,
WebSocketPolyfill: WebSocket,
maxAttempts: 1,
});
const provider = new HocuspocusProvider({
websocketProvider: wsHocus,
name: room,
onAuthenticationFailed: (data) => {
expect(console.log).toHaveBeenLastCalledWith(
expect.any(String),
' --- ',
'Room name is not a valid uuid:',
room,
);
wsHocus.stopConnectionAttempt();
expect(data.reason).toBe('permission-denied');
wsHocus.webSocket?.close();
wsHocus.disconnect();
provider.destroy();
wsHocus.destroy();
done();
},
});
provider.attach();
return promise;
});
test('WebSocket connection fails if user can not access document', () => {
const { promise, done } = promiseDone();
const room = uuidv4();
const fetchDocumentMock = vi
.spyOn(CollaborationBackend, 'fetchDocument')
.mockRejectedValue(new Error('some error'));
const wsHocus = new HocuspocusProviderWebsocket({
url: `ws://localhost:${portWS}/?room=${room}`,
WebSocketPolyfill: WebSocket,
maxAttempts: 1,
});
const provider = new HocuspocusProvider({
websocketProvider: wsHocus,
name: room,
onAuthenticationFailed: (data) => {
expect(console.error).toHaveBeenLastCalledWith(
'[onConnect]',
'Backend error: Unauthorized',
);
wsHocus.stopConnectionAttempt();
expect(data.reason).toBe('permission-denied');
expect(fetchDocumentMock).toHaveBeenCalledExactlyOnceWith(
{ name: room },
expect.any(Object),
);
wsHocus.webSocket?.close();
wsHocus.disconnect();
provider.destroy();
wsHocus.destroy();
done();
},
});
provider.attach();
return promise;
});
test('WebSocket connection fails if user do not have correct retrieve ability', () => {
const { promise, done } = promiseDone();
const room = uuidv4();
const fetchDocumentMock = vi
.spyOn(CollaborationBackend, 'fetchDocument')
.mockResolvedValue({ abilities: { retrieve: false } } as any);
const wsHocus = new HocuspocusProviderWebsocket({
url: `ws://localhost:${portWS}/?room=${room}`,
WebSocketPolyfill: WebSocket,
maxAttempts: 1,
});
const provider = new HocuspocusProvider({
websocketProvider: wsHocus,
name: room,
onAuthenticationFailed: (data) => {
expect(console.log).toHaveBeenLastCalledWith(
expect.any(String),
' --- ',
'onConnect: Unauthorized to retrieve this document',
room,
);
wsHocus.stopConnectionAttempt();
expect(data.reason).toBe('permission-denied');
expect(fetchDocumentMock).toHaveBeenCalledExactlyOnceWith(
{ name: room },
expect.any(Object),
);
wsHocus.webSocket?.close();
wsHocus.disconnect();
provider.destroy();
wsHocus.destroy();
done();
},
});
provider.attach();
return promise;
});
[true, false].forEach((canEdit) => {
test(`WebSocket connection ${canEdit ? 'can' : 'can not'} edit document`, () => {
const { promise, done } = promiseDone();
const fetchDocumentMock = vi
.spyOn(CollaborationBackend, 'fetchDocument')
.mockResolvedValue({
abilities: { retrieve: true, update: canEdit },
} as any);
const room = uuidv4();
const wsHocus = new HocuspocusProviderWebsocket({
url: `ws://localhost:${portWS}/?room=${room}`,
WebSocketPolyfill: WebSocket,
});
const provider = new HocuspocusProvider({
websocketProvider: wsHocus,
name: room,
onConnect: () => {
void hocuspocusServer.hocuspocus
.openDirectConnection(room)
.then((connection) => {
connection.document?.getConnections().forEach((connection) => {
expect(connection.readOnly).toBe(!canEdit);
});
void connection.disconnect();
provider.destroy();
wsHocus.destroy();
expect(fetchDocumentMock).toHaveBeenCalledWith(
{ name: room },
expect.any(Object),
);
done();
});
},
});
provider.attach();
return promise;
});
});
test('Add request header x-user-id if found', () => {
const { promise, done } = promiseDone();
const fetchDocumentMock = vi
.spyOn(CollaborationBackend, 'fetchDocument')
.mockResolvedValue({
abilities: { retrieve: true, update: true },
} as any);
const fetchCurrentUserMock = vi
.spyOn(CollaborationBackend, 'fetchCurrentUser')
.mockResolvedValue({ id: 'test-user-id' } as any);
const room = uuidv4();
const wsHocus = new HocuspocusProviderWebsocket({
url: `ws://localhost:${portWS}/?room=${room}`,
WebSocketPolyfill: WebSocket,
});
const provider = new HocuspocusProvider({
websocketProvider: wsHocus,
name: room,
onConnect: () => {
const document = hocuspocusServer.hocuspocus.documents.get(room);
if (document) {
document.getConnections().forEach((connection) => {
expect(connection.context.userId).toBe('test-user-id');
});
}
provider.destroy();
wsHocus.destroy();
expect(fetchDocumentMock).toHaveBeenCalledWith(
{ name: room },
expect.any(Object),
);
expect(fetchCurrentUserMock).toHaveBeenCalled();
done();
},
});
provider.attach();
return promise;
});
});
+2 -12
View File
@@ -18,27 +18,18 @@
"dependencies": {
"@blocknote/core": "0.54.0",
"@blocknote/server-util": "0.54.0",
"@hocuspocus/server": "3.4.4",
"@sentry/node": "10.70.0",
"@sentry/profiling-node": "10.70.0",
"@tiptap/extensions": "*",
"axios": "1.19.0",
"cors": "2.8.6",
"express": "5.2.1",
"express-ws": "5.0.2",
"uuid": "14.0.2",
"y-prosemirror": "1.3.7",
"y-protocols": "1.0.7",
"yjs": "*"
},
"devDependencies": {
"@hocuspocus/provider": "3.4.4",
"@types/cors": "2.8.19",
"@types/express": "5.0.6",
"@types/express-ws": "3.0.6",
"@types/node": "*",
"@types/supertest": "7.2.1",
"@types/ws": "8.18.1",
"cross-env": "10.1.0",
"eslint-plugin-docs": "*",
"nodemon": "3.1.14",
@@ -46,9 +37,8 @@
"ts-node": "10.9.2",
"tsc-alias": "1.9.2",
"typescript": "*",
"vitest": "4.1.11",
"vitest-mock-extended": "5.1.1",
"ws": "8.21.3"
"vitest": "4.1.10",
"vitest-mock-extended": "5.1.0"
},
"packageManager": "yarn@1.22.22"
}
@@ -1,88 +0,0 @@
import { IncomingHttpHeaders } from 'http';
import axios from 'axios';
import { COLLABORATION_BACKEND_BASE_URL, Y_PROVIDER_API_KEY } from '@/env';
export interface User {
id: string;
email: string;
full_name: string;
short_name: string;
language: string;
}
type Base64 = string;
interface Doc {
id: string;
title?: string;
content?: Base64;
creator: string;
is_favorite: boolean;
link_reach: 'restricted' | 'public' | 'authenticated';
link_role: 'reader' | 'editor';
nb_accesses_ancestors: number;
nb_accesses_direct: number;
created_at: string;
updated_at: string;
abilities: {
accesses_manage: boolean;
accesses_view: boolean;
ai_proxy: boolean;
ai_transform: boolean;
ai_translate: boolean;
attachment_upload: boolean;
children_create: boolean;
children_list: boolean;
collaboration_auth: boolean;
destroy: boolean;
favorite: boolean;
invite_owner: boolean;
link_configuration: boolean;
media_auth: boolean;
move: boolean;
partial_update: boolean;
restore: boolean;
retrieve: boolean;
update: boolean;
versions_destroy: boolean;
versions_list: boolean;
versions_retrieve: boolean;
};
}
async function fetch<T>(
path: string,
requestHeaders: IncomingHttpHeaders,
): Promise<T> {
const response = await axios.get<T>(
`${COLLABORATION_BACKEND_BASE_URL}${path}`,
{
headers: {
cookie: requestHeaders['cookie'],
origin: requestHeaders['origin'],
'X-Y-Provider-Key': Y_PROVIDER_API_KEY,
},
},
);
if (response.status !== 200) {
throw new Error(`Failed to fetch ${path}: ${response.statusText}`);
}
return response.data;
}
export function fetchDocument(
{ name }: { name: string },
requestHeaders: IncomingHttpHeaders,
): Promise<Doc> {
return fetch<Doc>(`/api/v1.0/documents/${name}/`, requestHeaders);
}
export function fetchCurrentUser(
requestHeaders: IncomingHttpHeaders,
): Promise<User> {
return fetch<User>('/api/v1.0/users/me/', requestHeaders);
}
@@ -16,5 +16,3 @@ export const Y_PROVIDER_API_KEY = process.env.Y_PROVIDER_API_KEY_FILE
: process.env.Y_PROVIDER_API_KEY || 'yprovider-api-key';
export const PORT = Number(process.env.PORT || 4444);
export const SENTRY_DSN = process.env.SENTRY_DSN || '';
export const COLLABORATION_BACKEND_BASE_URL =
process.env.COLLABORATION_BACKEND_BASE_URL || 'http://app-dev:8000';
@@ -1,48 +0,0 @@
import { Request, Response } from 'express';
import { hocuspocusServer } from '@/servers';
import { logger } from '@/utils';
type ResetConnectionsRequestQuery = {
room?: string;
};
export const collaborationResetConnectionsHandler = (
req: Request<object, object, object, ResetConnectionsRequestQuery>,
res: Response,
) => {
const room = req.query.room;
const userId = req.headers['x-user-id'];
logger('Resetting connections in room:', room, 'for user:', userId);
if (!room) {
res.status(400).json({ error: 'Room name not provided' });
return;
}
/**
* If no user ID is provided, close all connections in the room
*/
if (!userId) {
hocuspocusServer.hocuspocus.closeConnections(room);
} else {
/**
* Close connections for the user in the room
*/
hocuspocusServer.hocuspocus.documents.forEach((doc) => {
if (doc.name !== room) {
return;
}
doc.getConnections().forEach((connection) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (connection.context.userId === userId) {
connection.close();
}
});
});
}
res.status(200).json({ message: 'Connections reset' });
};
@@ -1,13 +0,0 @@
import { Request } from 'express';
import * as ws from 'ws';
import { hocuspocusServer } from '@/servers/hocuspocusServer';
export const collaborationWSHandler = (ws: ws.WebSocket, req: Request) => {
try {
hocuspocusServer.hocuspocus.handleConnection(ws, req);
} catch (error) {
console.error('Failed to handle WebSocket connection:', error);
ws.close();
}
};
@@ -1,48 +0,0 @@
import { Request, Response } from 'express';
import { hocuspocusServer } from '@/servers';
import { logger } from '@/utils';
type getDocumentConnectionInfoRequestQuery = {
room?: string;
sessionKey?: string;
};
export const getDocumentConnectionInfoHandler = (
req: Request<object, object, object, getDocumentConnectionInfoRequestQuery>,
res: Response,
) => {
const room = req.query.room;
const sessionKey = req.query.sessionKey;
if (!room) {
res.status(400).json({ error: 'Room name not provided' });
return;
}
if (!req.query.sessionKey) {
res.status(400).json({ error: 'Session key not provided' });
return;
}
logger('Getting document connection info for room:', room);
const roomInfo = hocuspocusServer.hocuspocus.documents.get(room);
if (!roomInfo) {
logger('Room not found:', room);
res.status(404).json({ error: 'Room not found' });
return;
}
const connections = roomInfo
.getConnections()
.filter((connection) => connection.readOnly === false);
res.status(200).json({
count: connections.length,
exists: connections.some(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(connection) => connection.context.sessionKey === sessionKey,
),
});
};
@@ -1,4 +1 @@
export * from './collaborationResetConnectionsHandler';
export * from './collaborationWSHandler';
export * from './convertHandler';
export * from './getDocumentConnectionInfoHandler';
@@ -1,6 +1,5 @@
import cors from 'cors';
import { NextFunction, Request, Response } from 'express';
import * as ws from 'ws';
import {
COLLABORATION_SERVER_ORIGIN,
@@ -8,8 +7,6 @@ import {
Y_PROVIDER_API_KEY,
} from '@/env';
import { logger } from './utils';
const VALID_API_KEYS = [COLLABORATION_SERVER_SECRET, Y_PROVIDER_API_KEY];
const allowedOrigins = COLLABORATION_SERVER_ORIGIN.split(',');
@@ -42,28 +39,3 @@ export const httpSecurity = (
next();
};
export const wsSecurity = (
ws: ws.WebSocket,
req: Request,
next: NextFunction,
): void => {
// Origin check
const origin = req.headers['origin'];
if (!origin || !allowedOrigins.includes(origin)) {
ws.close(4001, 'Origin not allowed');
logger('CORS policy violation: Invalid Origin', origin);
return;
}
const cookies = req.headers['cookie'];
if (!cookies) {
ws.close(4001, 'No cookies');
logger('CORS policy violation: No cookies');
logger('UA:', req.headers['user-agent']);
logger('URL:', req.url);
return;
}
next();
};
@@ -1,6 +1,3 @@
export const routes = {
COLLABORATION_WS: '/collaboration/ws/',
COLLABORATION_RESET_CONNECTIONS: '/collaboration/api/reset-connections/',
CONVERT: '/api/convert/',
COLLABORATION_GET_CONNECTIONS: '/collaboration/api/get-connections/',
};
@@ -1,51 +1,22 @@
import * as Sentry from '@sentry/node';
import express from 'express';
import expressWebsockets from 'express-ws';
import { CONVERSION_FILE_MAX_SIZE } from '@/env';
import {
collaborationResetConnectionsHandler,
collaborationWSHandler,
convertHandler,
getDocumentConnectionInfoHandler,
} from '@/handlers';
import { corsMiddleware, httpSecurity, wsSecurity } from '@/middlewares';
import { convertHandler } from '@/handlers';
import { corsMiddleware, httpSecurity } from '@/middlewares';
import { routes } from '@/routes';
import { logger } from '@/utils';
/**
* init the collaboration server.
* init the conversion server.
*
* @returns An object containing the Express app, Hocuspocus server, and HTTP server instance.
* @returns The Express app instance.
*/
export const initApp = () => {
const { app } = expressWebsockets(express());
const app = express();
app.use(corsMiddleware);
/**
* Route to handle WebSocket connections
*/
app.ws(routes.COLLABORATION_WS, wsSecurity, collaborationWSHandler);
/**
* Route to reset connections in a room:
* - If no user ID is provided, close all connections in the room
* - If a user ID is provided, close connections for the user in the room
*/
app.post(
routes.COLLABORATION_RESET_CONNECTIONS,
httpSecurity,
express.json(),
collaborationResetConnectionsHandler,
);
app.get(
routes.COLLABORATION_GET_CONNECTIONS,
httpSecurity,
getDocumentConnectionInfoHandler,
);
/**
* Route to convert Markdown or BlockNote blocks and Yjs content
*/
@@ -1,95 +0,0 @@
import { Server } from '@hocuspocus/server';
import { validate as uuidValidate, version as uuidVersion } from 'uuid';
import { fetchCurrentUser, fetchDocument } from '@/api/collaborationBackend';
import { logger } from '@/utils';
export const hocuspocusServer = new Server({
name: 'docs-collaboration',
timeout: 30000,
quiet: true,
async onConnect({
requestHeaders,
connectionConfig,
documentName,
requestParameters,
context,
request,
}) {
const roomParam = requestParameters.get('room');
if (documentName !== roomParam) {
logger(
'Invalid room name - Probable hacking attempt:',
documentName,
requestParameters.get('room'),
);
logger('UA:', request.headers['user-agent']);
logger('URL:', request.url);
return Promise.reject(new Error('Wrong room name: Unauthorized'));
}
if (!uuidValidate(documentName) || uuidVersion(documentName) !== 4) {
logger('Room name is not a valid uuid:', documentName);
return Promise.reject(new Error('Wrong room name: Unauthorized'));
}
let canEdit;
try {
const document = await fetchDocument(
{ name: documentName },
requestHeaders,
);
if (!document.abilities.retrieve) {
logger(
'onConnect: Unauthorized to retrieve this document',
documentName,
);
return Promise.reject(new Error('Wrong abilities:Unauthorized'));
}
canEdit = document.abilities.update;
} catch (error: unknown) {
if (error instanceof Error) {
logger('onConnect: backend error', error.message);
}
return Promise.reject(new Error('Backend error: Unauthorized'));
}
connectionConfig.readOnly = !canEdit;
const session = requestHeaders['cookie']
?.split('; ')
.find((cookie) => cookie.startsWith('docs_sessionid='));
if (session) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
context.sessionKey = session.split('=')[1];
}
/*
* Unauthenticated users can be allowed to connect
* so we flag only authenticated users
*/
try {
const user = await fetchCurrentUser(requestHeaders);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
context.userId = user.id;
} catch {
/* empty */
}
logger(
'Connection established on room:',
documentName,
'canEdit:',
canEdit,
);
return Promise.resolve();
},
});
@@ -1,2 +1 @@
export * from './appServer';
export * from './hocuspocusServer';
+104 -150
View File
@@ -2222,35 +2222,6 @@
resolved "https://registry.yarnpkg.com/@handlewithcare/prosemirror-suggest-changes/-/prosemirror-suggest-changes-0.1.8.tgz#707d432376718d4618065b22aafbc55b9ce4ea5b"
integrity sha512-ewrJl4a8dTpPJNhqYySE2ZCjTRpXulWlUmFy3sbyJgPnGtN/zx7+8tbQ1OhHfMzZWfdmA8VjP9ecy+KO4HdOpA==
"@hocuspocus/common@^3.4.4":
version "3.4.4"
resolved "https://registry.yarnpkg.com/@hocuspocus/common/-/common-3.4.4.tgz#a888fbd6dff2f0b8947c76b7841bddb89eb4d795"
integrity sha512-RykIJ0tsHHMP4Xk+4UCbc7SO5LgGxGUSTdbh6anJEsaALAyqinf1Nn5HYuMjLPolAmsar1v++m9zufR09NLpXA==
dependencies:
lib0 "^0.2.87"
"@hocuspocus/provider@3.4.4":
version "3.4.4"
resolved "https://registry.yarnpkg.com/@hocuspocus/provider/-/provider-3.4.4.tgz#ab4ff0b55f9faf848ddbc5775956afee440a4e97"
integrity sha512-KbsMAfdYcIJD8eMU/5QnpXcSOvIWAcCNI33FSRSaKCIpYBFtAwkYIwWnZJmPZ8a1BMAtqQc+uvy9+UQf7GHnGQ==
dependencies:
"@hocuspocus/common" "^3.4.4"
"@lifeomic/attempt" "^3.0.2"
lib0 "^0.2.87"
ws "^8.17.1"
"@hocuspocus/server@3.4.4":
version "3.4.4"
resolved "https://registry.yarnpkg.com/@hocuspocus/server/-/server-3.4.4.tgz#b44ad0aea9bdcc32d166e598278a4d5609cf03e9"
integrity sha512-UV+oaONAejOzeYgUygNcgsc8RdZvSokVvAxluZJIisLACpRO/VsseQ5lWKDRwLd7Fn6+rHWDH3hGuQ1fdX1Ycg==
dependencies:
"@hocuspocus/common" "^3.4.4"
async-lock "^1.3.1"
async-mutex "^0.5.0"
kleur "^4.1.4"
lib0 "^0.2.47"
ws "^8.5.0"
"@humanfs/core@^0.19.2":
version "0.19.2"
resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.2.tgz#a8272ca03b2acf492670222b2320b6c421bfde60"
@@ -2876,11 +2847,6 @@
resolved "https://registry.yarnpkg.com/@keyv/serialize/-/serialize-1.1.1.tgz#0c01dd3a3483882af7cf3878d4e71d505c81fc4a"
integrity sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==
"@lifeomic/attempt@^3.0.2":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@lifeomic/attempt/-/attempt-3.1.0.tgz#7fc703559177b81a008b9d263e3d9a001d11d08a"
integrity sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==
"@lottiefiles/dotlottie-react@^0.19.6":
version "0.19.16"
resolved "https://registry.yarnpkg.com/@lottiefiles/dotlottie-react/-/dotlottie-react-0.19.16.tgz#439b7079b5fa74968e7c55cbbf555817736078c7"
@@ -6120,7 +6086,7 @@
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24"
integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==
"@types/express-serve-static-core@*", "@types/express-serve-static-core@^5.0.0":
"@types/express-serve-static-core@^5.0.0":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz#74f47555b3d804b54cb7030e6f9aa0c7485cfc5b"
integrity sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==
@@ -6130,24 +6096,6 @@
"@types/range-parser" "*"
"@types/send" "*"
"@types/express-ws@3.0.6":
version "3.0.6"
resolved "https://registry.yarnpkg.com/@types/express-ws/-/express-ws-3.0.6.tgz#b38cee8f84db1c9aaf11a53964db07d58c90909c"
integrity sha512-6ZDt+tMEQgM4RC1sMX1fIO7kHQkfUDlWfxoPddXUeeDjmc+Yt/fCzqXfp8rFahNr5eIxdomrWphLEWDkB2q3UQ==
dependencies:
"@types/express" "*"
"@types/express-serve-static-core" "*"
"@types/ws" "*"
"@types/express@*":
version "5.0.3"
resolved "https://registry.yarnpkg.com/@types/express/-/express-5.0.3.tgz#6c4bc6acddc2e2a587142e1d8be0bce20757e956"
integrity sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==
dependencies:
"@types/body-parser" "*"
"@types/express-serve-static-core" "^5.0.0"
"@types/serve-static" "*"
"@types/express@5.0.6":
version "5.0.6"
resolved "https://registry.yarnpkg.com/@types/express/-/express-5.0.6.tgz#2d724b2c990dcb8c8444063f3580a903f6d500cc"
@@ -6245,11 +6193,6 @@
resolved "https://registry.yarnpkg.com/@types/methods/-/methods-1.1.4.tgz#d3b7ac30ac47c91054ea951ce9eed07b1051e547"
integrity sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==
"@types/mime@^1":
version "1.3.5"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690"
integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==
"@types/minimatch@^3.0.3":
version "3.0.5"
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40"
@@ -6325,23 +6268,6 @@
dependencies:
"@types/node" "*"
"@types/send@<1":
version "0.17.5"
resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.5.tgz#d991d4f2b16f2b1ef497131f00a9114290791e74"
integrity sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==
dependencies:
"@types/mime" "^1"
"@types/node" "*"
"@types/serve-static@*":
version "1.15.9"
resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.9.tgz#f9b08ab7dd8bbb076f06f5f983b683654fe0a025"
integrity sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==
dependencies:
"@types/http-errors" "*"
"@types/node" "*"
"@types/send" "<1"
"@types/serve-static@^2":
version "2.2.0"
resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-2.2.0.tgz#d4a447503ead0d1671132d1ab6bd58b805d8de6a"
@@ -6398,13 +6324,6 @@
resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc"
integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==
"@types/ws@*", "@types/ws@8.18.1":
version "8.18.1"
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9"
integrity sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==
dependencies:
"@types/node" "*"
"@types/yargs-parser@*":
version "21.0.3"
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15"
@@ -6934,6 +6853,18 @@
"@typescript-eslint/scope-manager" "^8.58.0"
"@typescript-eslint/utils" "^8.58.0"
"@vitest/expect@4.1.10":
version "4.1.10"
resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.1.10.tgz#799c06fc44bb0cf7e2784137b627c5cc173285d4"
integrity sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==
dependencies:
"@standard-schema/spec" "^1.1.0"
"@types/chai" "^5.2.2"
"@vitest/spy" "4.1.10"
"@vitest/utils" "4.1.10"
chai "^6.2.2"
tinyrainbow "^3.1.0"
"@vitest/expect@4.1.11":
version "4.1.11"
resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.1.11.tgz#5f580d1f9cdbba314dbf23b2d911f8eb23878f5f"
@@ -6946,6 +6877,15 @@
chai "^6.2.2"
tinyrainbow "^3.1.0"
"@vitest/mocker@4.1.10":
version "4.1.10"
resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.1.10.tgz#2413987ab4cd7fa1c2b614b404c407bf6ad1ead1"
integrity sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==
dependencies:
"@vitest/spy" "4.1.10"
estree-walker "^3.0.3"
magic-string "^0.30.21"
"@vitest/mocker@4.1.11":
version "4.1.11"
resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.1.11.tgz#8e2906361bc5dfa271757a858ae80643118fcbb4"
@@ -6955,6 +6895,13 @@
estree-walker "^3.0.3"
magic-string "^0.30.21"
"@vitest/pretty-format@4.1.10":
version "4.1.10"
resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.1.10.tgz#75542e7273a08cc10fd4d8dad4e3eb1f16cd958c"
integrity sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==
dependencies:
tinyrainbow "^3.1.0"
"@vitest/pretty-format@4.1.11":
version "4.1.11"
resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.1.11.tgz#8b28eb8240771d6ea970e33beaeb41384b51868e"
@@ -6962,6 +6909,14 @@
dependencies:
tinyrainbow "^3.1.0"
"@vitest/runner@4.1.10":
version "4.1.10"
resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.1.10.tgz#febf0a21a9168421422d1955370e606feab60355"
integrity sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==
dependencies:
"@vitest/utils" "4.1.10"
pathe "^2.0.3"
"@vitest/runner@4.1.11":
version "4.1.11"
resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.1.11.tgz#bfbad98c8d6c3f1fb4df12056ad569821ff77f21"
@@ -6970,6 +6925,16 @@
"@vitest/utils" "4.1.11"
pathe "^2.0.3"
"@vitest/snapshot@4.1.10":
version "4.1.10"
resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.1.10.tgz#7e3e9fec7d4d47232e493cfdcbd2170de4371c04"
integrity sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==
dependencies:
"@vitest/pretty-format" "4.1.10"
"@vitest/utils" "4.1.10"
magic-string "^0.30.21"
pathe "^2.0.3"
"@vitest/snapshot@4.1.11":
version "4.1.11"
resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.1.11.tgz#df461eb165924a3155986dde68e13360f53f3d4c"
@@ -6980,11 +6945,25 @@
magic-string "^0.30.21"
pathe "^2.0.3"
"@vitest/spy@4.1.10":
version "4.1.10"
resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.10.tgz#5c0bfa97b56bba9e37403c976db776ff6ab56f65"
integrity sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==
"@vitest/spy@4.1.11":
version "4.1.11"
resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.11.tgz#0add45cae953afed9c88f98e2f6fc9164558c32a"
integrity sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==
"@vitest/utils@4.1.10":
version "4.1.10"
resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.1.10.tgz#ffc71055f18bfccb1fd0586365ebc2824892e403"
integrity sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==
dependencies:
"@vitest/pretty-format" "4.1.10"
convert-source-map "^2.0.0"
tinyrainbow "^3.1.0"
"@vitest/utils@4.1.11":
version "4.1.11"
resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.1.11.tgz#9b27a4293b827942b223539bfab1bd9f7eada31b"
@@ -7440,18 +7419,6 @@ async-function@^1.0.0:
resolved "https://registry.yarnpkg.com/async-function/-/async-function-1.0.0.tgz#509c9fca60eaf85034c6829838188e4e4c8ffb2b"
integrity sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==
async-lock@^1.3.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/async-lock/-/async-lock-1.4.1.tgz#56b8718915a9b68b10fce2f2a9a3dddf765ef53f"
integrity sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==
async-mutex@^0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/async-mutex/-/async-mutex-0.5.0.tgz#353c69a0b9e75250971a64ac203b0ebfddd75482"
integrity sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==
dependencies:
tslib "^2.4.0"
async@^3.2.6:
version "3.2.6"
resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce"
@@ -7484,16 +7451,6 @@ axe-core@^4.10.0:
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.11.0.tgz#16f74d6482e343ff263d4f4503829e9ee91a86b6"
integrity sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==
axios@1.19.0:
version "1.19.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.19.0.tgz#ddf864d4c8233c0e6873746ab59361537d05ad39"
integrity sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==
dependencies:
follow-redirects "^1.16.0"
form-data "^4.0.6"
https-proxy-agent "^5.0.1"
proxy-from-env "^2.1.0"
axobject-query@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.1.0.tgz#28768c76d0e3cff21bc62a9e2d0b6ac30042a1ee"
@@ -9696,13 +9653,6 @@ expect@^30.0.0:
jest-mock "30.2.0"
jest-util "30.2.0"
express-ws@5.0.2:
version "5.0.2"
resolved "https://registry.yarnpkg.com/express-ws/-/express-ws-5.0.2.tgz#5b02d41b937d05199c6c266d7cc931c823bda8eb"
integrity sha512-0uvmuk61O9HXgLhGl3QhNSEtRsQevtmbL94/eILaliEADZBHZOQUAiHFrGPrgsjikohyrmSG5g+sCfASTt0lkQ==
dependencies:
ws "^7.4.6"
express@5.2.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/express/-/express-5.2.1.tgz#8f21d15b6d327f92b4794ecf8cb08a72f956ac04"
@@ -9948,11 +9898,6 @@ flatted@^3.3.3:
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726"
integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==
follow-redirects@^1.16.0:
version "1.16.0"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc"
integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==
fontkit@^2.0.2, fontkit@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/fontkit/-/fontkit-2.0.4.tgz#4765d664c68b49b5d6feb6bd1051ee49d8ec5ab0"
@@ -9983,7 +9928,7 @@ foreground-child@^3.1.0:
cross-spawn "^7.0.6"
signal-exit "^4.0.1"
form-data@^4.0.0, form-data@^4.0.5, form-data@^4.0.6:
form-data@^4.0.0, form-data@^4.0.5:
version "4.0.6"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.6.tgz#28e864e1b786dbebb68db1f452f9635278665827"
integrity sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==
@@ -10555,7 +10500,7 @@ http-errors@^2.0.1, http-errors@~2.0.1:
statuses "~2.0.2"
toidentifier "~1.0.1"
https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1:
https-proxy-agent@^5.0.0:
version "5.0.1"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
@@ -11761,11 +11706,6 @@ kind-of@^6.0.2:
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
kleur@^4.1.4:
version "4.1.5"
resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780"
integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==
known-css-properties@^0.37.0:
version "0.37.0"
resolved "https://registry.yarnpkg.com/known-css-properties/-/known-css-properties-0.37.0.tgz#10ebe49b9dbb6638860ff8a002fb65a053f4aec5"
@@ -11816,14 +11756,14 @@ lib0@1.0.0-rc.22:
resolved "https://registry.yarnpkg.com/lib0/-/lib0-1.0.0-rc.22.tgz#c154151f5188009afc7f73e1de3f4888011e6fb9"
integrity sha512-KNefJloRQIsWncTF2tIcRqQXSQ7bDRYHwVSUhf1lY2P65Rej4WWFnen6L8L+odJQIo1ZNJGVVjK2WzqB9a+B/g==
lib0@^0.2.109, lib0@^0.2.99:
lib0@^0.2.102, lib0@^0.2.109, lib0@^0.2.99:
version "0.2.117"
resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.117.tgz#6c3f926475d28904af05b590703cbbbc29475716"
integrity sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==
dependencies:
isomorphic.js "^0.2.4"
lib0@^0.2.47, lib0@^0.2.85, lib0@^0.2.87:
lib0@^0.2.85:
version "0.2.114"
resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.114.tgz#0b0e55c3ffa8768fe3d9efca971059f465db4baf"
integrity sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==
@@ -13258,11 +13198,6 @@ proxy-from-env@^1.1.0:
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
proxy-from-env@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba"
integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==
pstree.remy@^1.1.8:
version "1.1.8"
resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a"
@@ -15802,13 +15737,39 @@ vinyl@^3.0.0, vinyl@^3.0.1:
optionalDependencies:
fsevents "~2.3.3"
vitest-mock-extended@5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/vitest-mock-extended/-/vitest-mock-extended-5.1.1.tgz#41062473ebb30d7876ffb8a446c8ab410a94c89b"
integrity sha512-k5Ji2+t4+nsdepXeakCkilyKydtgaULtP0HsjwQGIZsYr5hDTqrwCtC0+Sh0YBKUS7cY/Wxplf7SVjcZstTIMQ==
vitest-mock-extended@5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/vitest-mock-extended/-/vitest-mock-extended-5.1.0.tgz#aa0693ffe1e83a8f5ef2c703c2f8c90b38c3bfbe"
integrity sha512-xW28qmo6CbEROzwFRKw+fhLQcwoxBbUHdezDtBJXiHinthfyYZ3pwt4ez4r+XPdimTbXJcFetjzKBJqMw1A6Jw==
dependencies:
ts-essentials "^10.2.1"
vitest@4.1.10:
version "4.1.10"
resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.1.10.tgz#7e9285efe264b1167050b7a3a7ff34788e1b7afc"
integrity sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==
dependencies:
"@vitest/expect" "4.1.10"
"@vitest/mocker" "4.1.10"
"@vitest/pretty-format" "4.1.10"
"@vitest/runner" "4.1.10"
"@vitest/snapshot" "4.1.10"
"@vitest/spy" "4.1.10"
"@vitest/utils" "4.1.10"
es-module-lexer "^2.0.0"
expect-type "^1.3.0"
magic-string "^0.30.21"
obug "^2.1.1"
pathe "^2.0.3"
picomatch "^4.0.3"
std-env "^4.0.0-rc.1"
tinybench "^2.9.0"
tinyexec "^1.0.2"
tinyglobby "^0.2.15"
tinyrainbow "^3.1.0"
vite "^6.0.0 || ^7.0.0 || ^8.0.0"
why-is-node-running "^2.3.0"
vitest@4.1.11:
version "4.1.11"
resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.1.11.tgz#1653c1521ae917f960d9b21877797c47dfd8bf21"
@@ -16283,21 +16244,6 @@ write-file-atomic@^5.0.1:
imurmurhash "^0.1.4"
signal-exit "^4.0.1"
ws@8.21.3:
version "8.21.3"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.3.tgz#660b4faddb6a3e575c86e078126919961f4de4fc"
integrity sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==
ws@^7.4.6:
version "7.5.11"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.11.tgz#9460daf1812bb81a423c5b9eac746941a86310fa"
integrity sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==
ws@^8.17.1, ws@^8.5.0:
version "8.21.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.0.tgz#012e413fc07429945121b0c153158c4343086951"
integrity sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==
xml-js@^1.6.8:
version "1.6.11"
resolved "https://registry.yarnpkg.com/xml-js/-/xml-js-1.6.11.tgz#927d2f6947f7f1c19a316dd8eea3614e8b18f8e9"
@@ -16332,13 +16278,21 @@ y-prosemirror@1.3.7:
dependencies:
lib0 "^0.2.109"
y-protocols@1.0.7:
y-protocols@1.0.7, y-protocols@^1.0.5:
version "1.0.7"
resolved "https://registry.yarnpkg.com/y-protocols/-/y-protocols-1.0.7.tgz#6631c492e75b78b3a61353a60067e6f8a4c38d5f"
integrity sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==
dependencies:
lib0 "^0.2.85"
y-websocket@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/y-websocket/-/y-websocket-3.0.0.tgz#e86bdb29cc0a53cb8d6e33ec8d24614a723832af"
integrity sha512-mUHy7AzkOZ834T/7piqtlA8Yk6AchqKqcrCXjKW8J1w2lPtRDjz8W5/CvXz9higKAHgKRKqpI3T33YkRFLkPtg==
dependencies:
lib0 "^0.2.102"
y-protocols "^1.0.5"
y18n@^5.0.5:
version "5.0.8"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"