From 4b9ab4f63cad678727c758e02c6bde3af404fc6a Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 25 Feb 2025 13:15:51 +0000 Subject: [PATCH] feat: standalone collab server (#767) * feat: standalone collab server * * custom collab server port env * fix collab start script command * * API prefix * Log startup PORT * Tweak collab debounce --- apps/client/src/lib/config.ts | 14 +++---- apps/client/vite.config.ts | 11 ++++-- apps/server/package.json | 1 + .../collaboration/collaboration.gateway.ts | 34 ++++++++++------ .../collaboration/server/collab-app.module.ts | 23 +++++++++++ .../src/collaboration/server/collab-main.ts | 39 +++++++++++++++++++ .../environment/environment.service.ts | 11 ++++++ .../environment/environment.validation.ts | 6 +++ .../src/integrations/static/static.module.ts | 1 + package.json | 1 + 10 files changed, 119 insertions(+), 22 deletions(-) create mode 100644 apps/server/src/collaboration/server/collab-app.module.ts create mode 100644 apps/server/src/collaboration/server/collab-main.ts diff --git a/apps/client/src/lib/config.ts b/apps/client/src/lib/config.ts index af92de5d..49712e62 100644 --- a/apps/client/src/lib/config.ts +++ b/apps/client/src/lib/config.ts @@ -19,15 +19,13 @@ export function getBackendUrl(): string { } export function getCollaborationUrl(): string { - const COLLAB_PATH = "/collab"; + const baseUrl = + getConfigValue("COLLAB_URL") || + (import.meta.env.DEV ? process.env.APP_URL : getAppUrl()); - let url = getAppUrl(); - if (import.meta.env.DEV) { - url = process.env.APP_URL; - } - - const wsProtocol = url.startsWith("https") ? "wss" : "ws"; - return `${wsProtocol}://${url.split("://")[1]}${COLLAB_PATH}`; + const collabUrl = new URL("/collab", baseUrl); + collabUrl.protocol = collabUrl.protocol === "https:" ? "wss:" : "ws:"; + return collabUrl.toString(); } export function getAvatarUrl(avatarUrl: string) { diff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts index 571481f5..299c42b2 100644 --- a/apps/client/vite.config.ts +++ b/apps/client/vite.config.ts @@ -5,16 +5,21 @@ import * as path from "path"; export const envPath = path.resolve(process.cwd(), "..", ".."); export default defineConfig(({ mode }) => { - const { APP_URL, FILE_UPLOAD_SIZE_LIMIT, DRAWIO_URL } = loadEnv(mode, envPath, ""); + const { APP_URL, FILE_UPLOAD_SIZE_LIMIT, DRAWIO_URL, COLLAB_URL } = loadEnv( + mode, + envPath, + "", + ); return { define: { "process.env": { APP_URL, FILE_UPLOAD_SIZE_LIMIT, - DRAWIO_URL + DRAWIO_URL, + COLLAB_URL, }, - 'APP_VERSION': JSON.stringify(process.env.npm_package_version), + APP_VERSION: JSON.stringify(process.env.npm_package_version), }, plugins: [react()], resolve: { diff --git a/apps/server/package.json b/apps/server/package.json index 41c474d2..465e48f1 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -12,6 +12,7 @@ "start:dev": "cross-env NODE_ENV=development nest start --watch", "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", "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 cfa31b6c..71b7245b 100644 --- a/apps/server/src/collaboration/collaboration.gateway.ts +++ b/apps/server/src/collaboration/collaboration.gateway.ts @@ -25,21 +25,25 @@ export class CollaborationGateway { this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl()); this.hocuspocus = HocuspocusServer.configure({ - debounce: 5000, - maxDebounce: 10000, + debounce: 10000, + maxDebounce: 20000, unloadImmediately: false, extensions: [ this.authenticationExtension, this.persistenceExtension, - new Redis({ - host: this.redisConfig.host, - port: this.redisConfig.port, - options: { - password: this.redisConfig.password, - db: this.redisConfig.db, - retryStrategy: createRetryStrategy(), - }, - }), + ...(this.environmentService.isCollabDisableRedis() + ? [] + : [ + new Redis({ + host: this.redisConfig.host, + port: this.redisConfig.port, + options: { + password: this.redisConfig.password, + db: this.redisConfig.db, + retryStrategy: createRetryStrategy(), + }, + }), + ]), ], }); } @@ -48,6 +52,14 @@ export class CollaborationGateway { this.hocuspocus.handleConnection(client, request); } + getConnectionCount() { + return this.hocuspocus.getConnectionsCount(); + } + + getDocumentCount() { + return this.hocuspocus.getDocumentsCount(); + } + async destroy(): Promise { await this.hocuspocus.destroy(); } diff --git a/apps/server/src/collaboration/server/collab-app.module.ts b/apps/server/src/collaboration/server/collab-app.module.ts new file mode 100644 index 00000000..d30426c1 --- /dev/null +++ b/apps/server/src/collaboration/server/collab-app.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { AppController } from '../../app.controller'; +import { AppService } from '../../app.service'; +import { EnvironmentModule } from '../../integrations/environment/environment.module'; +import { CollaborationModule } from '../collaboration.module'; +import { DatabaseModule } from '@docmost/db/database.module'; +import { QueueModule } from '../../integrations/queue/queue.module'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import { HealthModule } from '../../integrations/health/health.module'; + +@Module({ + imports: [ + DatabaseModule, + EnvironmentModule, + CollaborationModule, + QueueModule, + HealthModule, + EventEmitterModule.forRoot(), + ], + controllers: [AppController], + providers: [AppService], +}) +export class CollabAppAppModule {} diff --git a/apps/server/src/collaboration/server/collab-main.ts b/apps/server/src/collaboration/server/collab-main.ts new file mode 100644 index 00000000..62621115 --- /dev/null +++ b/apps/server/src/collaboration/server/collab-main.ts @@ -0,0 +1,39 @@ +import { NestFactory } from '@nestjs/core'; +import { CollabAppAppModule } from './collab-app.module'; +import { + FastifyAdapter, + NestFastifyApplication, +} from '@nestjs/platform-fastify'; +import { TransformHttpResponseInterceptor } from '../../common/interceptors/http-response.interceptor'; +import { InternalLogFilter } from '../../common/logger/internal-log-filter'; +import { Logger } from '@nestjs/common'; + +async function bootstrap() { + const app = await NestFactory.create( + CollabAppAppModule, + new FastifyAdapter({ + ignoreTrailingSlash: true, + ignoreDuplicateSlashes: true, + maxParamLength: 500, + }), + { + logger: new InternalLogFilter(), + }, + ); + + app.setGlobalPrefix('api', { exclude: ['/'] }); + + app.enableCors(); + + app.useGlobalInterceptors(new TransformHttpResponseInterceptor()); + app.enableShutdownHooks(); + + const logger = new Logger('CollabServer'); + + const port = process.env.COLLAB_PORT || 3001; + await app.listen(port, '0.0.0.0', () => { + logger.log(`Listening on http://127.0.0.1:${port}`); + }); +} + +bootstrap(); diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts index d8e7b34d..d6913ab0 100644 --- a/apps/server/src/integrations/environment/environment.service.ts +++ b/apps/server/src/integrations/environment/environment.service.ts @@ -145,4 +145,15 @@ export class EnvironmentService { isSelfHosted(): boolean { return !this.isCloud(); } + + getCollabUrl(): string { + return this.configService.get('COLLAB_URL'); + } + + isCollabDisableRedis(): boolean { + const isStandalone = this.configService + .get('COLLAB_DISABLE_REDIS', 'false') + .toLowerCase(); + return isStandalone === 'true'; + } } diff --git a/apps/server/src/integrations/environment/environment.validation.ts b/apps/server/src/integrations/environment/environment.validation.ts index fd0b33e7..c0aea146 100644 --- a/apps/server/src/integrations/environment/environment.validation.ts +++ b/apps/server/src/integrations/environment/environment.validation.ts @@ -5,6 +5,7 @@ import { IsOptional, IsUrl, MinLength, + ValidateIf, validateSync, } from 'class-validator'; import { plainToInstance } from 'class-transformer'; @@ -48,6 +49,11 @@ export class EnvironmentVariables { @IsOptional() @IsIn(['local', 's3']) STORAGE_DRIVER: string; + + @IsOptional() + @ValidateIf((obj) => obj.COLLAB_URL != '' && obj.COLLAB_URL != null) + @IsUrl({ protocols: ['http', 'https'], require_tld: false }) + COLLAB_URL: string; } export function validate(config: Record) { diff --git a/apps/server/src/integrations/static/static.module.ts b/apps/server/src/integrations/static/static.module.ts index 56272cc3..aea6cce0 100644 --- a/apps/server/src/integrations/static/static.module.ts +++ b/apps/server/src/integrations/static/static.module.ts @@ -38,6 +38,7 @@ export class StaticModule implements OnModuleInit { FILE_UPLOAD_SIZE_LIMIT: this.environmentService.getFileUploadSizeLimit(), DRAWIO_URL: this.environmentService.getDrawioUrl(), + COLLAB_URL: this.environmentService.getCollabUrl(), }; const windowScriptContent = ``; diff --git a/package.json b/package.json index 7883f180..90f94c13 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "nx run-many -t build", "start": "pnpm --filter ./apps/server run start:prod", + "collab": "pnpm --filter ./apps/server run collab:prod", "server:build": "nx run server:build", "client:build": "nx run client:build", "editor-ext:build": "nx run @docmost/editor-ext:build",