From fd36076ae7eaee7df0b97f2b73aee1c9d49571aa Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sat, 8 Mar 2025 18:16:23 +0000 Subject: [PATCH] feat: disconnect collab websocket on idle tabs (#848) * disconnect real-time collab if user is idle * log yjs document disconnect and unload in dev mode * no longer set editor to read-only mode on collab websocket disconnection * treat delayed collab websocket "connecting" state as disconnected * increase maxDebounce to 45 seconds * add reset handle to useIdle hook --- .../src/features/editor/page-editor.tsx | 43 ++++++++++---- apps/client/src/hooks/use-idle.ts | 58 +++++++++++++++++++ apps/client/src/lib/constants.ts | 2 + apps/server/package.json | 1 + .../collaboration/collaboration.gateway.ts | 5 +- .../src/collaboration/collaboration.module.ts | 2 + .../extensions/logger.extension.ts | 19 ++++++ package.json | 1 + 8 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 apps/client/src/hooks/use-idle.ts create mode 100644 apps/server/src/collaboration/extensions/logger.extension.ts diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index feb9fd6f..9f103445 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -41,7 +41,9 @@ import LinkMenu from "@/features/editor/components/link/link-menu.tsx"; import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu"; import DrawioMenu from "./components/drawio/drawio-menu"; import { useCollabToken } from "@/features/auth/queries/auth-query.tsx"; -import { isCloud } from "@/lib/config.ts"; +import { useDocumentVisibility } from "@mantine/hooks"; +import { useIdle } from "@/hooks/use-idle.ts"; +import { FIVE_MINUTES } from "@/lib/constants.ts"; interface PageEditorProps { pageId: string; @@ -69,6 +71,8 @@ export default function PageEditor({ const menuContainerRef = useRef(null); const documentName = `page.${pageId}`; const { data } = useCollabToken(); + const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false }); + const documentState = useDocumentVisibility(); const localProvider = useMemo(() => { const provider = new IndexeddbPersistence(documentName, ydoc); @@ -87,6 +91,7 @@ export default function PageEditor({ document: ydoc, token: data?.token, connect: false, + preserveConnection: false, onStatus: (status) => { if (status.status === "connected") { setYjsConnectionStatus(status.status); @@ -126,6 +131,7 @@ export default function PageEditor({ extensions, editable, immediatelyRender: true, + shouldRerenderOnTransaction: false, editorProps: { scrollThreshold: 80, scrollMargin: 80, @@ -158,7 +164,7 @@ export default function PageEditor({ } }, }, - [pageId, editable, remoteProvider], + [pageId, editable, remoteProvider?.status], ); const handleActiveCommentEvent = (event) => { @@ -188,16 +194,31 @@ export default function PageEditor({ }, [pageId]); useEffect(() => { - if (isCloud()) return; - if (editable) { - if (yjsConnectionStatus === WebSocketStatus.Connected) { - editor.setEditable(true); - } else { - // disable edits if connection fails - editor.setEditable(false); - } + if (remoteProvider?.status === WebSocketStatus.Connecting) { + const timeout = setTimeout(() => { + setYjsConnectionStatus(WebSocketStatus.Disconnected); + }, 5000); + return () => clearTimeout(timeout); } - }, [yjsConnectionStatus]); + }, [remoteProvider?.status]); + + useEffect(() => { + if ( + isIdle && + documentState === "hidden" && + remoteProvider?.status === WebSocketStatus.Connected + ) { + remoteProvider.disconnect(); + } + + if ( + documentState === "visible" && + remoteProvider?.status === WebSocketStatus.Disconnected + ) { + remoteProvider.connect(); + resetIdle(); + } + }, [isIdle, documentState, remoteProvider?.status]); const isSynced = isLocalSynced && isRemoteSynced; diff --git a/apps/client/src/hooks/use-idle.ts b/apps/client/src/hooks/use-idle.ts new file mode 100644 index 00000000..cb56580c --- /dev/null +++ b/apps/client/src/hooks/use-idle.ts @@ -0,0 +1,58 @@ +// Mantine Idle hook to support reset handle - MIT +//src: https://github.com/mantinedev/mantine/blob/06018d0beff22caa7b36d796e56ad597cc5c23f7/packages/%40mantine/hooks/src/use-idle/use-idle.ts +import { useEffect, useRef, useState } from "react"; + +const DEFAULT_EVENTS: (keyof DocumentEventMap)[] = [ + "keypress", + "mousemove", + "touchmove", + "click", + "scroll", +]; +const DEFAULT_OPTIONS = { + events: DEFAULT_EVENTS, + initialState: true, +}; + +export function useIdle( + timeout: number, + options?: Partial<{ + events: (keyof DocumentEventMap)[]; + initialState: boolean; + }>, +) { + const { events, initialState } = { ...DEFAULT_OPTIONS, ...options }; + const [idle, setIdle] = useState(initialState); + const timer = useRef(-1); + + const reset = () => { + setIdle(false); + if (timer.current) { + window.clearTimeout(timer.current); + } + timer.current = window.setTimeout(() => { + setIdle(true); + }, timeout); + }; + + useEffect(() => { + const handleEvents = () => { + reset(); + }; + + events.forEach((event) => document.addEventListener(event, handleEvents)); + + // Start the timer immediately instead of waiting for the first event to happen + timer.current = window.setTimeout(() => { + setIdle(true); + }, timeout); + + return () => { + events.forEach((event) => + document.removeEventListener(event, handleEvents), + ); + }; + }, [timeout, events]); + + return { isIdle: idle, resetIdle: reset }; +} diff --git a/apps/client/src/lib/constants.ts b/apps/client/src/lib/constants.ts index 7685837f..eb8c9105 100644 --- a/apps/client/src/lib/constants.ts +++ b/apps/client/src/lib/constants.ts @@ -1,2 +1,4 @@ export const INTERNAL_LINK_REGEX = /^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/; + +export const FIVE_MINUTES = 5 * 60 * 1000; \ No newline at end of file diff --git a/apps/server/package.json b/apps/server/package.json index 2105002a..b5d04309 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -13,6 +13,7 @@ "start:debug": "cross-env NODE_ENV=development nest start --debug --watch", "start:prod": "cross-env NODE_ENV=production node dist/main", "collab:prod": "cross-env NODE_ENV=production node dist/collaboration/server/collab-main", + "collab:dev": "cross-env NODE_ENV=development node dist/collaboration/server/collab-main", "email:dev": "email dev -p 5019 -d ./src/integrations/transactional/emails", "migration:create": "tsx src/database/migrate.ts create", "migration:up": "tsx src/database/migrate.ts up", diff --git a/apps/server/src/collaboration/collaboration.gateway.ts b/apps/server/src/collaboration/collaboration.gateway.ts index 6f0641dc..3f894572 100644 --- a/apps/server/src/collaboration/collaboration.gateway.ts +++ b/apps/server/src/collaboration/collaboration.gateway.ts @@ -11,6 +11,7 @@ import { parseRedisUrl, RedisConfig, } from '../common/helpers'; +import { LoggerExtension } from './extensions/logger.extension'; @Injectable() export class CollaborationGateway { @@ -20,17 +21,19 @@ export class CollaborationGateway { constructor( private authenticationExtension: AuthenticationExtension, private persistenceExtension: PersistenceExtension, + private loggerExtension: LoggerExtension, private environmentService: EnvironmentService, ) { this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl()); this.hocuspocus = HocuspocusServer.configure({ debounce: 10000, - maxDebounce: 20000, + maxDebounce: 45000, unloadImmediately: false, extensions: [ this.authenticationExtension, this.persistenceExtension, + this.loggerExtension, ...(this.environmentService.isCollabDisableRedis() ? [] : [ diff --git a/apps/server/src/collaboration/collaboration.module.ts b/apps/server/src/collaboration/collaboration.module.ts index 3011d290..bd5e1e6f 100644 --- a/apps/server/src/collaboration/collaboration.module.ts +++ b/apps/server/src/collaboration/collaboration.module.ts @@ -8,12 +8,14 @@ import { IncomingMessage } from 'http'; import { WebSocket } from 'ws'; import { TokenModule } from '../core/auth/token.module'; import { HistoryListener } from './listeners/history.listener'; +import { LoggerExtension } from './extensions/logger.extension'; @Module({ providers: [ CollaborationGateway, AuthenticationExtension, PersistenceExtension, + LoggerExtension, HistoryListener, ], exports: [CollaborationGateway], diff --git a/apps/server/src/collaboration/extensions/logger.extension.ts b/apps/server/src/collaboration/extensions/logger.extension.ts new file mode 100644 index 00000000..969fa712 --- /dev/null +++ b/apps/server/src/collaboration/extensions/logger.extension.ts @@ -0,0 +1,19 @@ +import { + Extension, + onDisconnectPayload, + onLoadDocumentPayload, +} from '@hocuspocus/server'; +import { Injectable, Logger } from '@nestjs/common'; + +@Injectable() +export class LoggerExtension implements Extension { + private readonly logger = new Logger('Collab' + LoggerExtension.name); + + async onDisconnect(data: onDisconnectPayload) { + this.logger.debug(`User disconnected from "${data.documentName}".`); + } + + async afterUnloadDocument(data: onLoadDocumentPayload) { + this.logger.debug('Unloaded ' + data.documentName + ' from memory'); + } +} diff --git a/package.json b/package.json index fe99674d..4a4338ca 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "nx run-many -t build", "start": "pnpm --filter ./apps/server run start:prod", "collab": "pnpm --filter ./apps/server run collab:prod", + "collab:dev": "pnpm --filter ./apps/server run collab:dev", "server:build": "nx run server:build", "client:build": "nx run client:build", "editor-ext:build": "nx run @docmost/editor-ext:build",