diff --git a/apps/live/src/core/extensions/index.ts b/apps/live/src/core/extensions/index.ts index d30531327c..9427e225a0 100644 --- a/apps/live/src/core/extensions/index.ts +++ b/apps/live/src/core/extensions/index.ts @@ -9,7 +9,7 @@ import { logger } from "@plane/logger"; // core helpers and utilities import { getRedisUrl } from "@/core/lib/utils/redis-url.js"; // core libraries -import { fetchPageDescriptionBinary, updatePageDescription } from "@/core/lib/page.js"; +import { fetchPageDescriptionBinary, updatePageDescription } from "@/core/lib/page"; // plane live libraries import { fetchDocument } from "@/plane-live/lib/fetch-document.js"; import { updateDocument } from "@/plane-live/lib/update-document.js"; diff --git a/apps/live/src/core/hocuspocus-server.ts b/apps/live/src/core/hocuspocus-server.ts deleted file mode 100644 index 3519d7ef3a..0000000000 --- a/apps/live/src/core/hocuspocus-server.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Server } from "@hocuspocus/server"; -import { v4 as uuidv4 } from "uuid"; -// lib -import { handleAuthentication } from "@/core/lib/authentication"; -// extensions -import { getExtensions } from "@/core/extensions"; -import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib"; -// editor types -import { TUserDetails } from "@plane/editor"; -// types -import { type HocusPocusServerContext } from "@/core/types/common"; - -export const getHocusPocusServer = async () => { - const extensions = await getExtensions(); - const serverName = process.env.HOSTNAME || uuidv4(); - return Server.configure({ - name: serverName, - onAuthenticate: async ({ - requestHeaders, - context, - // user id used as token for authentication - token, - }) => { - let cookie: string | undefined = undefined; - let userId: string | undefined = undefined; - - // Extract cookie (fallback to request headers) and userId from token (for scenarios where - // the cookies are not passed in the request headers) - try { - const parsedToken = JSON.parse(token) as TUserDetails; - userId = parsedToken.id; - cookie = parsedToken.cookie; - } catch (error) { - // If token parsing fails, fallback to request headers - console.error("Token parsing failed, using request headers:", error); - } finally { - // If cookie is still not found, fallback to request headers - if (!cookie) { - cookie = requestHeaders.cookie?.toString(); - } - } - - if (!cookie || !userId) { - throw new Error("Credentials not provided"); - } - - // set cookie in context, so it can be used throughout the ws connection - (context as HocusPocusServerContext).cookie = cookie; - - try { - await handleAuthentication({ - cookie, - userId, - }); - } catch (_error) { - throw Error("Authentication unsuccessful!"); - } - }, - async onStateless({ payload, document }) { - // broadcast the client event (derived from the server event) to all the clients so that they can update their state - const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer].client; - if (response) { - document.broadcastStateless(response); - } - }, - extensions, - debounce: 10000, - }); -}; diff --git a/apps/live/src/core/lib/authentication.ts b/apps/live/src/core/lib/authentication.ts index b077417255..cc7ee942f1 100644 --- a/apps/live/src/core/lib/authentication.ts +++ b/apps/live/src/core/lib/authentication.ts @@ -1,5 +1,5 @@ // services -import { UserService } from "@/core/services/user.service.js"; +import { UserService } from "@/core/services/user.service"; // core helpers import { logger } from "@plane/logger"; diff --git a/apps/live/src/hocuspocus.ts b/apps/live/src/hocuspocus.ts new file mode 100644 index 0000000000..99b9076ecb --- /dev/null +++ b/apps/live/src/hocuspocus.ts @@ -0,0 +1,206 @@ +import { Server, Hocuspocus } from "@hocuspocus/server"; +import { v4 as uuidv4 } from "uuid"; +import { Logger } from "@hocuspocus/extension-logger"; +import { Database } from "@hocuspocus/extension-database"; +import { Redis } from "@hocuspocus/extension-redis"; +import { logger } from "@plane/logger"; +// lib +import { handleAuthentication } from "@/core/lib/authentication"; +// extensions +import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib"; +import { fetchPageDescriptionBinary, updatePageDescription } from "@/core/lib/page"; +// plane live libraries +import { fetchDocument } from "@/plane-live/lib/fetch-document.js"; +import { updateDocument } from "@/plane-live/lib/update-document.js"; +// editor types +import { TUserDetails } from "@plane/editor"; +// types +import type { HocusPocusServerContext, TDocumentTypes } from "@/core/types/common"; +// redis +import { redisManager } from "@/redis"; + +export class HocusPocusServerManager { + private static instance: HocusPocusServerManager | null = null; + private server: Hocuspocus | null = null; + private isInitialized: boolean = false; + // server options + private serverName = process.env.HOSTNAME || uuidv4(); + + private constructor() { + // Private constructor to prevent direct instantiation + } + + /** + * Get the singleton instance of HocusPocusServerManager + */ + public static getInstance(): HocusPocusServerManager { + if (!HocusPocusServerManager.instance) { + HocusPocusServerManager.instance = new HocusPocusServerManager(); + } + return HocusPocusServerManager.instance; + } + + /** + * Authenticate the user + * @param requestHeaders - The request headers + * @param context - The context + * @param token - The token + * @returns The authenticated user + */ + private onAuthenticate = async ({ requestHeaders, context, token }: any) => { + let cookie: string | undefined = undefined; + let userId: string | undefined = undefined; + + // Extract cookie (fallback to request headers) and userId from token (for scenarios where + // the cookies are not passed in the request headers) + try { + const parsedToken = JSON.parse(token) as TUserDetails; + userId = parsedToken.id; + cookie = parsedToken.cookie; + } catch (error) { + // If token parsing fails, fallback to request headers + console.error("Token parsing failed, using request headers:", error); + } finally { + // If cookie is still not found, fallback to request headers + if (!cookie) { + cookie = requestHeaders.cookie?.toString(); + } + } + + if (!cookie || !userId) { + throw new Error("Credentials not provided"); + } + + // set cookie in context, so it can be used throughout the ws connection + (context as HocusPocusServerContext).cookie = cookie; + + try { + await handleAuthentication({ + cookie, + userId, + }); + } catch (_error) { + throw Error("Authentication unsuccessful!"); + } + }; + + private onStateless = async ({ payload, document }: any) => { + // broadcast the client event (derived from the server event) to all the clients so that they can update their state + const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer].client; + if (response) { + document.broadcastStateless(response); + } + }; + + private onDatabaseFetch = async ({ context, documentName: pageId, requestParameters }: any) => { + const cookie = (context as HocusPocusServerContext).cookie; + // query params + const params = requestParameters; + const documentType = params.get("documentType")?.toString() as TDocumentTypes | undefined; + return new Promise(async (resolve) => { + try { + let fetchedData = null; + if (documentType === "project_page") { + fetchedData = await fetchPageDescriptionBinary(params, pageId, cookie); + } else { + fetchedData = await fetchDocument({ + cookie, + documentType, + pageId, + params, + }); + } + resolve(fetchedData); + } catch (error) { + logger.error("Error in fetching document", error); + } + }); + }; + + private onDatabaseStore = async ({ context, state, documentName: pageId, requestParameters }: any) => { + const cookie = (context as HocusPocusServerContext).cookie; + // query params + const params = requestParameters; + const documentType = params.get("documentType")?.toString() as TDocumentTypes | undefined; + + // TODO: Fix this lint error. + // eslint-disable-next-line no-async-promise-executor + return new Promise(async () => { + try { + if (documentType === "project_page") { + await updatePageDescription(params, pageId, state, cookie); + } else { + await updateDocument({ + cookie, + documentType, + pageId, + params, + updatedDescription: state, + }); + } + } catch (error) { + logger.error("Error in updating document:", error); + } + }); + }; + /** + * Initialize and configure the HocusPocus server + */ + public async initialize(): Promise { + if (this.isInitialized && this.server) { + return this.server; + } + + this.server = Server.configure({ + name: this.serverName, + onAuthenticate: this.onAuthenticate, + onStateless: this.onStateless, + extensions: [ + new Logger({ + onChange: false, + log: (message) => { + logger.info(message); + }, + }), + new Database({ + fetch: this.onDatabaseFetch as any, + store: this.onDatabaseStore as any, + }), + new Redis({ + redis: redisManager.getClient(), + }), + ], + debounce: 10000, + }); + + this.isInitialized = true; + return this.server; + } + + /** + * Get the configured server instance + */ + public getServer(): Hocuspocus | null { + return this.server; + } + + /** + * Check if the server has been initialized + */ + public isServerInitialized(): boolean { + return this.isInitialized; + } + + /** + * Reset the singleton instance (useful for testing) + */ + public static resetInstance(): void { + HocusPocusServerManager.instance = null; + } +} + +// Legacy function for backward compatibility +export const getHocusPocusServer = async (): Promise => { + const manager = HocusPocusServerManager.getInstance(); + return await manager.initialize(); +}; diff --git a/apps/live/src/redis.ts b/apps/live/src/redis.ts new file mode 100644 index 0000000000..3385525eb0 --- /dev/null +++ b/apps/live/src/redis.ts @@ -0,0 +1,210 @@ +import Redis from "ioredis"; +import { logger } from "@plane/logger"; + +export class RedisManager { + private static instance: RedisManager; + private redisClient: Redis | null = null; + private isConnected: boolean = false; + private connectionPromise: Promise | null = null; + + private constructor() {} + + public static getInstance(): RedisManager { + if (!RedisManager.instance) { + RedisManager.instance = new RedisManager(); + } + return RedisManager.instance; + } + + public async initialize(): Promise { + if (this.redisClient && this.isConnected) { + logger.info("Redis client already initialized and connected"); + return; + } + + if (this.connectionPromise) { + logger.info("Redis connection already in progress, waiting..."); + await this.connectionPromise; + return; + } + + this.connectionPromise = this.connect(); + await this.connectionPromise; + } + + private getRedisUrl(): string { + const redisUrl = process.env.REDIS_URL?.trim(); + const redisHost = process.env.REDIS_HOST?.trim(); + const redisPort = process.env.REDIS_PORT?.trim(); + + if (redisUrl) { + return redisUrl; + } + + if (redisHost && redisPort && !Number.isNaN(Number(redisPort))) { + return `redis://${redisHost}:${redisPort}`; + } + + return ""; + } + + private async connect(): Promise { + try { + const redisUrl = this.getRedisUrl(); + + if (!redisUrl) { + logger.warn("No Redis URL provided, Redis functionality will be disabled"); + this.isConnected = false; + return; + } + + this.redisClient = new Redis(redisUrl, { + lazyConnect: true, + keepAlive: 30000, + connectTimeout: 10000, + commandTimeout: 5000, + enableOfflineQueue: false, + maxRetriesPerRequest: 3, + }); + + // Set up event listeners + this.redisClient.on("connect", () => { + logger.info("Redis client connected"); + this.isConnected = true; + }); + + this.redisClient.on("ready", () => { + logger.info("Redis client ready"); + this.isConnected = true; + }); + + this.redisClient.on("error", (error) => { + logger.error("Redis client error:", error); + this.isConnected = false; + }); + + this.redisClient.on("close", () => { + logger.warn("Redis client connection closed"); + this.isConnected = false; + }); + + this.redisClient.on("reconnecting", () => { + logger.info("Redis client reconnecting..."); + this.isConnected = false; + }); + + // Connect to Redis + await this.redisClient.connect(); + + // Test the connection + await this.redisClient.ping(); + logger.info("Redis connection test successful"); + } catch (error) { + logger.error("Failed to initialize Redis client:", error); + this.isConnected = false; + throw error; + } finally { + this.connectionPromise = null; + } + } + + public getClient(): Redis | null { + if (!this.redisClient || !this.isConnected) { + logger.warn("Redis client not available or not connected"); + return null; + } + return this.redisClient; + } + + public isClientConnected(): boolean { + return this.isConnected && this.redisClient !== null; + } + + public async disconnect(): Promise { + if (this.redisClient) { + try { + await this.redisClient.quit(); + logger.info("Redis client disconnected gracefully"); + } catch (error) { + logger.error("Error disconnecting Redis client:", error); + // Force disconnect if quit fails + this.redisClient.disconnect(); + } finally { + this.redisClient = null; + this.isConnected = false; + } + } + } + + // Convenience methods for common Redis operations + public async set(key: string, value: string, ttl?: number): Promise { + const client = this.getClient(); + if (!client) return false; + + try { + if (ttl) { + await client.setex(key, ttl, value); + } else { + await client.set(key, value); + } + return true; + } catch (error) { + logger.error(`Error setting Redis key ${key}:`, error); + return false; + } + } + + public async get(key: string): Promise { + const client = this.getClient(); + if (!client) return null; + + try { + return await client.get(key); + } catch (error) { + logger.error(`Error getting Redis key ${key}:`, error); + return null; + } + } + + public async del(key: string): Promise { + const client = this.getClient(); + if (!client) return false; + + try { + await client.del(key); + return true; + } catch (error) { + logger.error(`Error deleting Redis key ${key}:`, error); + return false; + } + } + + public async exists(key: string): Promise { + const client = this.getClient(); + if (!client) return false; + + try { + const result = await client.exists(key); + return result === 1; + } catch (error) { + logger.error(`Error checking Redis key ${key}:`, error); + return false; + } + } + + public async expire(key: string, ttl: number): Promise { + const client = this.getClient(); + if (!client) return false; + + try { + const result = await client.expire(key, ttl); + return result === 1; + } catch (error) { + logger.error(`Error setting expiry for Redis key ${key}:`, error); + return false; + } + } +} + +// Export a default instance for convenience +export const redisManager = RedisManager.getInstance(); diff --git a/apps/live/src/server.ts b/apps/live/src/server.ts index 2188a0289b..64b5314a7b 100644 --- a/apps/live/src/server.ts +++ b/apps/live/src/server.ts @@ -5,12 +5,14 @@ import express, { Request, Response } from "express"; import helmet from "helmet"; import { logger } from "@plane/logger"; // hocuspocus server -import { getHocusPocusServer } from "@/core/hocuspocus-server"; +import { HocusPocusServerManager } from "@/hocuspocus"; // helpers import { convertHTMLDocumentToAllFormats } from "@/core/helpers/convert-document"; import { logger as loggerMiddleware } from "@/middlewares/logger"; // types import { TConvertDocumentRequestBody } from "@/core/types/common"; +// redis +import { redisManager } from "@/redis"; export class Server { private app: any; @@ -24,10 +26,26 @@ export class Server { expressWs(this.app); this.app.set("port", process.env.PORT || 3000); this.setupMiddleware(); - this.setupHocusPocus(); this.setupRoutes(); } + public async initialize(): Promise { + return redisManager + .initialize() + .then(() => { + logger.info("Redis setup completed"); + const manager = HocusPocusServerManager.getInstance(); + manager.initialize().catch(() => { + logger.error("Failed to initialize HocusPocusServer:"); + process.exit(1); + }); + }) + .catch((error) => { + logger.error("Failed to setup Redis:", error); + process.exit(1); + }); + } + private setupMiddleware() { // Security middleware this.app.use(helmet()); @@ -43,13 +61,6 @@ export class Server { this.app.use(process.env.LIVE_BASE_PATH || "/live", this.router); } - private async setupHocusPocus() { - this.hocuspocusServer = await getHocusPocusServer().catch((err) => { - logger.error("Failed to initialize HocusPocusServer:", err); - process.exit(1); - }); - } - private setupRoutes() { this.router.get("/health", (_req: Request, res: Response) => { res.status(200).json({ status: "OK" }); @@ -106,6 +117,11 @@ export class Server { // Close the HocusPocus server WebSocket connections await this.hocuspocusServer.destroy(); logger.info("HocusPocus server WebSocket connections closed gracefully."); + + // Disconnect Redis + await redisManager.disconnect(); + logger.info("Redis connection closed gracefully."); + // Close the Express server this.serverInstance.close(() => { logger.info("Express server closed gracefully."); diff --git a/apps/live/src/start.ts b/apps/live/src/start.ts index d85fae026e..d5ab928268 100644 --- a/apps/live/src/start.ts +++ b/apps/live/src/start.ts @@ -1,19 +1,36 @@ import { Server } from "./server"; import { logger } from "@plane/logger"; -const server = new Server(); -server.listen(); +let server: Server; + +async function startServer() { + server = new Server(); + + try { + await server.initialize(); + server.listen(); + } catch (error) { + logger.error("Failed to start server:", error); + process.exit(1); + } +} + +startServer(); // Graceful shutdown on unhandled rejection process.on("unhandledRejection", async (err: any) => { logger.info("Unhandled Rejection: ", err); logger.info(`UNHANDLED REJECTION! 💥 Shutting down...`); - await server.destroy(); + if (server) { + await server.destroy(); + } }); // Graceful shutdown on uncaught exception process.on("uncaughtException", async (err: any) => { logger.info("Uncaught Exception: ", err); logger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`); - await server.destroy(); + if (server) { + await server.destroy(); + } });