fix: live restructuring

This commit is contained in:
Palanikannan M
2025-06-24 17:39:23 +05:30
parent 24a3c08758
commit c063eaeedb
40 changed files with 1995 additions and 699 deletions
+17
View File
@@ -0,0 +1,17 @@
// Export all controllers from this barrel file
import { CollaborationController } from "./collaboration.controller";
import { LiveDocumentController } from "./live-document.controller";
import { DocumentController } from "./document.controller";
import { HealthController } from "./health.controller";
export const CONTROLLERS = {
// Core system controllers (health checks, status endpoints)
CORE: [HealthController],
// Document management controllers
DOCUMENT: [DocumentController, LiveDocumentController],
// WebSocket controllers for real-time functionality
WEBSOCKET: [CollaborationController],
};
@@ -0,0 +1,143 @@
import { Controller, Get } from "@plane/decorators";
import type { Request, Response } from "express";
import { z } from "zod";
import * as Y from "yjs";
// Server agent
import { serverAgentManager } from "@/core/agents/server-agent";
// Helpers
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { AppError } from "@/core/helpers/error-handling/error-handler";
import { env } from "@/env";
// Types
import { HocusPocusServerContext } from "@/core/types/common";
import { manualLogger } from "@/core/helpers/logger";
import { getAllDocumentFormatsFromDocumentEditorBinaryData } from "@plane/editor";
// Schema for request validation
const getLiveDocumentValuesSchema = z.object({
documentId: z.string().min(1, "Document ID is required"),
variant: z.enum(["document"]),
workspaceSlug: z.string().min(1, "Workspace slug is required"),
});
@Controller("/live-document")
export class LiveDocumentController {
@Get("/")
async getLiveDocumentValues(req: Request, res: Response) {
const requestId = req.id || crypto.randomUUID();
try {
if (req.headers["live-server-secret-key"] !== env.LIVE_SERVER_SECRET_KEY) {
return res.status(401).json({
message: "Unauthorized access",
status: 401,
context: {
component: "get-live-document-values-controller",
operation: "getLiveDocumentValues",
requestId,
},
});
}
const validatedData = getLiveDocumentValuesSchema.parse(req.query);
const { documentId, workspaceSlug } = validatedData;
const context: Partial<HocusPocusServerContext> = {
workspaceSlug,
};
try {
const { connection } = await serverAgentManager.getConnection(documentId, context);
// Define the document type
type DocumentData = {
description_binary: string;
description: object;
description_html: string;
name: string;
};
// Create a promise to wrap the setTimeout
const loadDocumentWithDelay = new Promise<DocumentData | null>((resolve) => {
let documentData: DocumentData;
connection.transact((doc) => {
const type = doc.getXmlFragment("default");
const contentDoc = type.doc;
if (!contentDoc) {
resolve(null);
return;
}
const yjsBinary = Y.encodeStateAsUpdate(contentDoc);
const { contentBinaryEncoded, contentJSON, contentHTML, titleHTML } =
getAllDocumentFormatsFromDocumentEditorBinaryData(yjsBinary);
documentData = {
description_binary: contentBinaryEncoded,
description: contentJSON,
description_html: contentHTML,
name: titleHTML,
};
resolve(documentData);
});
});
// Await the delayed document loading
const documentLoaded = await loadDocumentWithDelay;
await serverAgentManager.releaseConnection(documentId);
// Return the converted document
res.status(200).json(documentLoaded);
} catch (error) {
// Error during server agent connection or conversion
manualLogger.error(`Error processing document ${documentId}:`, error);
res.status(400).json({
loaded: false,
message: "Document not currently loaded in memory",
});
}
} catch (error) {
let appError: AppError;
if (error instanceof z.ZodError) {
// Handle validation errors
appError = handleError(error, {
errorType: "unprocessable-entity",
message: "Invalid request data",
component: "get-live-document-values-controller",
operation: "getLiveDocumentValues",
extraContext: {
requestId,
validationErrors: error.errors.map((err) => ({
path: err.path.join("."),
message: err.message,
})),
},
});
} else {
// Handle other errors
appError = handleError(error, {
errorType: "internal",
message: "Internal server error",
component: "get-live-document-values-controller",
operation: "getLiveDocumentValues",
extraContext: { requestId },
});
}
res.status(appError.status).json({
message: appError.message,
status: appError.status,
context: appError.context,
});
}
}
}
+15
View File
@@ -0,0 +1,15 @@
import * as Y from "yjs";
import { HocusPocusServerContext } from "@/core/types/common";
export class DocumentProcessor {
static async process(
xmlFragment: Y.XmlFragment,
pageId: string,
context: HocusPocusServerContext,
options: {
targetNodeId?: string;
componentType?: string;
[key: string]: any;
} = {}
): Promise<void> {}
}
+425
View File
@@ -0,0 +1,425 @@
import * as Y from "yjs";
import { Response } from "express";
import { DirectConnection, Hocuspocus } from "@hocuspocus/server";
import { v4 as uuidv4 } from "uuid";
import { manualLogger } from "@/core/helpers/logger";
import { HocusPocusServerContext } from "@/core/types/common";
import { DocumentProcessor } from "@/plane-live/lib/document-processor";
/**
* Metadata for a stored connection
*/
interface ConnectionData {
connection: DirectConnection;
context: Partial<HocusPocusServerContext>;
createdAt: number;
lastUsed: number;
}
/**
* Connection statistics for a document
*/
interface ConnectionStats {
documentId: string;
createdAt: string;
lastUsed: string;
idleTime: string;
}
/**
* Overall statistics for the connection manager
*/
interface ManagerStats {
totalConnections: number;
connections: ConnectionStats[];
}
/**
* Error thrown when the ServerAgentManager is not properly initialized
*/
class ServerAgentManagerError extends Error {
constructor(message: string) {
super(message);
this.name = "ServerAgentManagerError";
}
}
/**
* Manages server-side connections (agents) to the Hocuspocus server
* Implements the Singleton pattern to ensure only one instance exists
*/
export class ServerAgentManager {
private static instance: ServerAgentManager;
private connections: Map<string, ConnectionData>;
public hocuspocusServer: Hocuspocus | null;
private cleanupInterval: ReturnType<typeof setTimeout> | null;
/**
* Private constructor to enforce singleton pattern
*/
private constructor() {
this.connections = new Map<string, ConnectionData>();
this.hocuspocusServer = null;
this.cleanupInterval = null;
}
/**
* Get the singleton instance of the ServerAgentManager
* @returns {ServerAgentManager} The singleton instance
*/
public static getInstance(): ServerAgentManager {
if (!ServerAgentManager.instance) {
ServerAgentManager.instance = new ServerAgentManager();
}
return ServerAgentManager.instance;
}
/**
* Initialize the manager with a Hocuspocus server instance
* @param {Hocuspocus} server - The Hocuspocus server instance
* @returns {ServerAgentManager} The initialized manager instance for chaining
*/
public initialize(server: Hocuspocus): ServerAgentManager {
this.hocuspocusServer = server;
// Set up periodic cleanup of unused connections
this.startConnectionCleanup();
manualLogger.info("ServerAgentManager initialized");
return this;
}
/**
* Get or create a connection to a document
* @param {string} documentId - The document ID to connect to
* @param {HocusPocusServerContext} context - Additional context for the connection
* @returns {Promise<ConnectionData>} - The connection data object
* @throws {ServerAgentManagerError} If the manager is not initialized
*/
public async getConnection(documentId: string, context: Partial<HocusPocusServerContext>): Promise<ConnectionData> {
if (!this.hocuspocusServer) {
throw new ServerAgentManagerError("ServerAgentManager not initialized with a Hocuspocus server");
}
// Check if we already have a connection for this document
if (this.connections.has(documentId)) {
// manualLogger.info(`Reusing existing connection for document: ${documentId}`);
const connectionData = this.connections.get(documentId)!;
// Update last used timestamp
connectionData.lastUsed = Date.now();
return connectionData;
}
context.documentType = context.documentType === "sync_agent" ? "sync_agent" : "server_agent";
try {
// Create a new connection
const connection = await this.hocuspocusServer.openDirectConnection(documentId, {
documentType: context.documentType,
projectId: context.projectId,
workspaceSlug: context.workspaceSlug,
// triggerExecutionAfterLoad: context.triggerExecutionAfterLoad,
agentId: uuidv4(), // Unique ID for this server agent
});
// Store the connection with metadata
const connectionData: ConnectionData = {
connection,
context,
createdAt: Date.now(),
lastUsed: Date.now(),
};
this.connections.set(documentId, connectionData);
return connectionData;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
manualLogger.error(`Failed to create connection for document ${documentId}: ${errorMessage}`);
throw new ServerAgentManagerError(`Failed to create connection: ${errorMessage}`);
}
}
/**
* Execute a transaction on a document
* @param {string} documentId - The document ID
* @param {(doc: Y.Doc) => void | Promise<void>} transactionFn - The transaction function
* @param {HocusPocusServerContext} context - Additional context for the connection
* @returns {Promise<void>} - Promise that resolves when the transaction is complete
* @throws {ServerAgentManagerError} If the transaction fails
*/
public async executeTransaction(
documentId: string,
transactionFn: (_doc: Y.Doc) => void | Promise<void>,
context: Partial<HocusPocusServerContext>,
res?: Response
): Promise<boolean> {
let connectionData: ConnectionData | null = null;
try {
connectionData = await this.getConnection(documentId, context);
connectionData.lastUsed = Date.now();
connectionData.context = { ...connectionData.context };
// Execute the transaction
await connectionData.connection.transact(transactionFn);
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
manualLogger.error(`Transaction error for document ${documentId}: ${errorMessage}`, {
documentId,
error: error instanceof Error ? error.stack : errorMessage,
context,
});
// Notify about transaction failure if needed
this.notifyTransactionFailure(documentId, errorMessage, res);
return false;
}
}
public notifySyncTrigger(
pageId: string,
context: HocusPocusServerContext,
options: {
componentType?: string;
targetNodeId?: string;
[key: string]: any;
} = {}
) {
if (!this.hocuspocusServer) return;
this.executeTransaction(
pageId,
async (doc) => {
const xmlFragment = doc.getXmlFragment("default");
// Process the document using our extensible system
DocumentProcessor.process(xmlFragment, pageId, context, options);
},
{
...context,
documentType: "sync_agent",
}
);
}
/**
* Notify clients about a successful transaction
* @private
* @param {string} documentId - The document ID
*/
public notifyTransactionSuccess(documentId: string, res?: Response): void {
if (!this.hocuspocusServer) return;
const document = this.hocuspocusServer.documents.get(documentId);
if (!document) return;
try {
manualLogger.info(`notified transaction success for ${documentId}:`);
// res.status(200).json({ success: true });
} catch (error) {
manualLogger.error(`Error notifying transaction success for ${documentId}:`, error);
}
}
/**
* Notify clients about a failed transaction
* @private
* @param {string} documentId - The document ID
* @param {string} errorMessage - The error message
*/
private notifyTransactionFailure(documentId: string, errorMessage: string, res?: Response): void {
if (!this.hocuspocusServer) return;
const document = this.hocuspocusServer.documents.get(documentId);
if (!document) return;
try {
// res.status(200).json({ success: true });
// document.broadcastStateless(
// JSON.stringify({
// type: "transaction_status",
// status: "error",
// message: errorMessage,
// timestamp: new Date().toISOString(),
// })
// );
} catch (error) {
manualLogger.error(`Error notifying transaction failure for ${documentId}:`, error);
}
}
/**
* Release a connection when it's no longer needed
* @param {string} documentId - The document ID
* @returns {Promise<void>}
*/
public async releaseConnection(documentId: string): Promise<void> {
if (!this.connections.has(documentId)) {
return;
}
const connectionData = this.connections.get(documentId)!;
try {
connectionData.connection.disconnect();
this.connections.delete(documentId);
manualLogger.info(`Released connection for document: ${documentId}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
manualLogger.error(`Error releasing connection for document ${documentId}: ${errorMessage}`);
// We still want to remove it from our map even if disconnect fails
this.connections.delete(documentId);
}
}
/**
* Check if a document has any client connections (excluding our agent)
* @param {string} documentId - The document ID
* @returns {boolean} - True if there are client connections
*/
/**
* Check if a document has any client connections (excluding our agent)
* @param {string} documentId - The document ID
* @returns {boolean} - True if there are client connections
*/
public hasClientConnections(documentId: string): boolean {
if (!this.hocuspocusServer) return false;
// Get the document from the server
const document = this.hocuspocusServer.documents.get(documentId);
if (!document) return false;
return document.connections.size > 0;
}
/**
* Start periodic cleanup of unused connections
* @private
*/
private startConnectionCleanup(): void {
// Check every 5 minutes for unused connections
const CLEANUP_INTERVAL = 5 * 60 * 1000;
// Clear any existing interval
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
this.cleanupInterval = setInterval(() => {
this.cleanupUnusedConnections().catch((error) => {
manualLogger.error("Error during connection cleanup:", error);
});
}, CLEANUP_INTERVAL);
}
/**
* Clean up connections that are no longer needed
* @private
*/
private async cleanupUnusedConnections(): Promise<void> {
const documentsToCleanup: string[] = [];
for (const [documentId] of this.connections.entries()) {
// If no client connections exist (only our agent remains), schedule cleanup
if (!this.hasClientConnections(documentId)) {
documentsToCleanup.push(documentId);
}
}
// Release connections outside the loop to avoid modifying the map during iteration
for (const documentId of documentsToCleanup) {
manualLogger.info(`Cleaning up connection for document with no clients: ${documentId}`);
await this.releaseConnection(documentId);
}
if (documentsToCleanup.length > 0) {
manualLogger.info(`Cleaned up ${documentsToCleanup.length} connections with no clients`);
}
}
/**
* Check if a document has no client connections and release the agent if needed
* @param {string} documentId - The document ID to check
* @returns {Promise<boolean>} - True if the connection was released
*/
public async checkAndReleaseIfNoClients(documentId: string): Promise<boolean> {
if (!this.connections.has(documentId)) {
return false;
}
if (!this.hasClientConnections(documentId)) {
manualLogger.info(`No clients left for document ${documentId}, releasing agent connection`);
await this.releaseConnection(documentId);
return true;
}
return false;
}
/**
* Set up hooks on the HocusPocus server to trigger cleanup when clients disconnect
* @returns {ServerAgentManager} The manager instance for chaining
*/
public setupHocusPocusHooks(): ServerAgentManager {
if (!this.hocuspocusServer) {
throw new ServerAgentManagerError("ServerAgentManager not initialized with a Hocuspocus server");
}
// Add a hook to the onDisconnect event
this.hocuspocusServer.configure({
async onDisconnect({ documentName }) {
// Use a small delay to ensure the connection is fully closed before checking
setTimeout(async () => {
try {
await serverAgentManager.checkAndReleaseIfNoClients(documentName);
} catch (error) {
manualLogger.error(`Error checking client connections for ${documentName}:`, error);
}
}, 100); // Small delay to ensure connection state is updated
},
});
return this;
}
/**
* Get statistics about current connections
* @returns {ManagerStats} - Connection statistics
*/
public getStats(): ManagerStats {
return {
totalConnections: this.connections.size,
connections: Array.from(this.connections.entries()).map(([documentId, data]) => ({
documentId,
createdAt: new Date(data.createdAt).toISOString(),
lastUsed: new Date(data.lastUsed).toISOString(),
idleTime: Math.round((Date.now() - data.lastUsed) / 1000) + "s",
})),
};
}
/**
* Shutdown the manager and clean up all connections
* @returns {Promise<void>}
*/
public async shutdown(): Promise<void> {
// Clear the cleanup interval
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
// Release all connections
const documentIds = Array.from(this.connections.keys());
for (const documentId of documentIds) {
await this.releaseConnection(documentId);
}
manualLogger.info("ServerAgentManager shut down successfully");
}
}
// Create and export the singleton instance
export const serverAgentManager = ServerAgentManager.getInstance();
+1 -18
View File
@@ -1,21 +1,4 @@
import { HealthController } from "@/core/controllers/health.controller";
import { DocumentController } from "@/core/controllers/document.controller";
import { CollaborationController } from "@/core/controllers/collaboration.controller";
/**
* Controller registry exports
* Simple grouped arrays of controller classes for better organization
*/
export const CONTROLLERS = {
// Core system controllers (health checks, status endpoints)
CORE: [HealthController],
// Document management controllers
DOCUMENT: [DocumentController],
// WebSocket controllers for real-time functionality
WEBSOCKET: [CollaborationController],
};
import { CONTROLLERS } from "@/plane-live/controllers";
// Helper to get all REST controllers
export const getAllControllers = () => [...CONTROLLERS.CORE, ...CONTROLLERS.DOCUMENT, ...CONTROLLERS.WEBSOCKET];
-4
View File
@@ -1,4 +0,0 @@
// Export all controllers from this barrel file
export { HealthController } from "./health.controller";
export { CollaborationController } from "./collaboration.controller";
export { DocumentController } from "./document.controller";
@@ -0,0 +1,233 @@
import { HocusPocusServerContext } from "@/core/types/common";
import { BasePageService } from "@/core/services/base-page.service";
import { logger } from "@plane/logger";
import { getAllDocumentFormatsFromBinaryData, getBinaryDataFromHTMLString } from "@/core/helpers/page";
import { TPage } from "@plane/types";
import { DocumentHandler, HandlerDefinition } from "@/core/types/document-handler";
import { handlerFactory } from "@/core/handlers/page-handlers/handler-factory";
/**
* Base class for page handlers with factory integration
*/
export abstract class BasePageHandler<TService extends BasePageService, TConfig extends Record<string, any>> {
/**
* The document type identifier
*/
protected abstract documentType: string;
constructor(protected service: TService) {}
/**
* Abstract method to get config from context
*/
protected abstract getConfig(context: HocusPocusServerContext): TConfig;
/**
* Fetches the binary description data for a page
*/
public async fetchPageDescriptionBinary({
pageId,
context,
}: {
pageId: string;
context: HocusPocusServerContext;
}): Promise<Uint8Array | undefined> {
const { cookie } = context;
const config = this.getConfig(context);
if (!pageId) return;
const response = await this.service.fetchDescriptionBinary({
pageId,
cookie,
config,
});
const binaryData = new Uint8Array(response);
if (binaryData.byteLength === 0) {
const binary = await this.transformHTMLToBinary(config, pageId, cookie);
if (binary) {
return binary;
}
}
return binaryData;
}
/**
* Updates the description of a page
*/
public async updatePageDescription({
pageId,
state: updatedDescription,
title,
context,
}: {
pageId: string;
state: Uint8Array;
title: string;
context: HocusPocusServerContext;
}): Promise<void> {
if (!(updatedDescription instanceof Uint8Array)) {
throw new Error("Invalid updatedDescription: must be an instance of Uint8Array");
}
const { cookie } = context;
const config = this.getConfig(context);
if (!pageId) return;
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromBinaryData(updatedDescription);
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
name: title,
};
await this.service.updateDescription({
config,
pageId,
data: payload,
cookie,
});
}
/**
* Fetches the title of a page
*/
public async fetchPageTitle({
context,
pageId,
}: {
pageId: string;
context: HocusPocusServerContext;
}): Promise<string | undefined> {
const { cookie } = context;
const config = this.getConfig(context);
if (!pageId) return;
try {
const pageDetails = await this.service.fetchDetails({
config,
pageId,
cookie,
});
return pageDetails.name;
} catch (error) {
logger.error("Error while fetching page title", error);
throw error;
}
}
/**
* Updates the title of a page
*/
public async updatePageTitle({
context,
pageId,
title,
abortSignal,
}: {
pageId: string;
title: string;
abortSignal?: AbortSignal;
context: HocusPocusServerContext;
}): Promise<void> {
const { cookie } = context;
const config = this.getConfig(context);
if (!pageId) return;
const payload = {
name: title,
};
await this.service.updateTitle({
config,
pageId,
data: payload,
cookie,
abortSignal,
});
}
/**
* Fetches sub-page details
*/
public async fetchPageSubPageDetails({
context,
pageId,
}: {
pageId: string;
context: HocusPocusServerContext;
}): Promise<TPage[] | undefined> {
const { cookie } = context;
const config = this.getConfig(context);
if (!pageId) return;
try {
const response = await this.service.fetchSubPageDetails({
config,
pageId,
cookie,
});
return response;
} catch (error) {
logger.error("Fetch error:", error);
throw error;
}
}
/**
* Transforms HTML to binary data
*/
private async transformHTMLToBinary(
config: TConfig,
pageId: string,
cookie: string
): Promise<Uint8Array | undefined> {
try {
const pageDetails = await this.service.fetchDetails({
config,
pageId,
cookie,
});
const { contentBinary } = getBinaryDataFromHTMLString(pageDetails.description_html ?? "<p></p>");
return contentBinary;
} catch (error) {
logger.error("Error while transforming from HTML to Uint8Array", error);
throw error;
}
}
/**
* Creates the handler definition
*/
protected createHandlerDefinition(): HandlerDefinition {
const handler: DocumentHandler = {
fetch: this.fetchPageDescriptionBinary.bind(this),
store: this.updatePageDescription.bind(this),
fetchTitle: this.fetchPageTitle.bind(this),
updateTitle: this.updatePageTitle.bind(this),
fetchSubPages: this.fetchPageSubPageDetails.bind(this),
};
return {
selector: (context: Partial<HocusPocusServerContext>) => context.documentType === this.documentType,
handler,
priority: 10, // Standard priority
};
}
/**
* Registers the handler with the handler factory
*/
public register(): void {
const definition = this.createHandlerDefinition();
handlerFactory.register(definition);
}
}
Binary file not shown.
+30 -18
View File
@@ -1,27 +1,28 @@
import { Database } from "@hocuspocus/extension-database";
import { catchAsync } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { getDocumentHandler } from "../handlers/document-handlers";
import { type HocusPocusServerContext, type TDocumentTypes } from "@/core/types/common";
import { storePayload } from "@hocuspocus/server";
import { extractTextFromHTML } from "./title-update/title-utils";
import { getDocumentHandler } from "../handlers/page-handlers";
export const createDatabaseExtension = () => {
return new Database({
fetch: handleFetch,
store: handleStore,
store: handleStore as (data: storePayload) => Promise<void>,
});
};
const handleFetch = async ({
context,
documentName: pageId,
requestParameters,
documentName,
}: {
context: HocusPocusServerContext;
documentName: TDocumentTypes;
documentName: string;
requestParameters: URLSearchParams;
}) => {
const { documentType } = context;
const params = requestParameters;
const pageId = documentName as TDocumentTypes;
let fetchedData = null;
fetchedData = await catchAsync(
@@ -41,7 +42,6 @@ const handleFetch = async ({
fetchedData = await documentHandler.fetch({
context: context as HocusPocusServerContext,
pageId,
params,
});
if (!fetchedData) {
@@ -67,15 +67,20 @@ const handleFetch = async ({
const handleStore = async ({
context,
state,
documentName: pageId,
requestParameters,
}: {
documentName,
document,
}: Partial<storePayload> & {
context: HocusPocusServerContext;
state: Buffer;
documentName: TDocumentTypes;
requestParameters: URLSearchParams;
documentName: string;
}) => {
catchAsync(
const pageId = documentName;
if (!context) {
console.error("Context is undefined in handleStore for document:", pageId);
return;
}
await catchAsync(
async () => {
if (!state) {
handleError(null, {
@@ -87,8 +92,12 @@ const handleStore = async ({
throw: true,
});
}
let title = "";
if (document) {
title = extractTextFromHTML(document?.getXmlFragment("title")?.toJSON());
}
const { documentType } = context as HocusPocusServerContext;
const params = requestParameters;
if (!documentType) {
handleError(null, {
errorType: "bad-request",
@@ -105,12 +114,15 @@ const handleStore = async ({
context: context as HocusPocusServerContext,
pageId,
state,
params,
title,
});
},
{
params: { pageId, documentType: context.documentType },
params: {
pageId,
documentType: context?.documentType || "unknown",
},
extra: { operation: "store" },
}
)();
};
};
+6 -5
View File
@@ -1,9 +1,10 @@
// hocuspocus extensions and core
import { Extension } from "@hocuspocus/server";
import { Logger } from "@hocuspocus/extension-logger";
import { setupRedisExtension } from "@/core/extensions/redis";
import { setupRedisExtension } from "@/core/extensions/setup-redis";
import { createDatabaseExtension } from "@/core/extensions/database";
import { logger } from "@plane/logger";
import { TitleSyncExtension } from "./title-sync";
export const getExtensions = async (): Promise<Extension[]> => {
const extensions: Extension[] = [
@@ -14,13 +15,13 @@ export const getExtensions = async (): Promise<Extension[]> => {
},
}),
createDatabaseExtension(),
new TitleSyncExtension(),
];
// Add Redis extensions if Redis is available
const redisExtension = await setupRedisExtension();
if (redisExtension) {
logger.info("HocusPocus Redis extension configured ✅");
extensions.push(redisExtension);
const redisExtensions = await setupRedisExtension();
if (redisExtensions) {
extensions.push(redisExtensions);
}
return extensions;
+12 -29
View File
@@ -1,34 +1,17 @@
import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis";
// core helpers and utilities
import { logger } from "@plane/logger";
import { RedisManager } from "@/core/lib/redis-manager";
import { Redis } from "@hocuspocus/extension-redis";
import { OutgoingMessage } from "@hocuspocus/server";
/**
* Sets up the Redis extension for HocusPocus using the RedisManager singleton
* @returns Promise that resolves to a Redis extension array
*/
export const setupRedisExtension = async () => {
const redisManager = RedisManager.getInstance();
export class CustomHocuspocusRedisExtension extends Redis {
public broadcastToDocument(documentName: string, payload: any): Promise<number> {
const stringPayload = typeof payload === "string" ? payload : JSON.stringify(payload);
const message = new OutgoingMessage(documentName).writeBroadcastStateless(stringPayload);
// Wait for Redis connection
const redisClient = await redisManager.connect();
const emptyPrefix = Buffer.concat([Buffer.from([0])]);
if (redisClient) {
return new HocusPocusRedis({
redis: redisClient,
});
} else {
logger.warn(
"Redis connection failed, continuing without Redis extension (you won't be able to sync data between multiple plane live servers)"
return this.pub.publishBuffer(
// we're accessing the private method of the hocuspocus redis extension
this["pubKey"](documentName),
Buffer.concat([emptyPrefix, Buffer.from(message.toUint8Array())])
);
}
};
/**
* Helper to get the current Redis status
* Useful for health checks
*/
export const getRedisStatus = (): "connected" | "connecting" | "disconnected" | "not-configured" => {
const redisManager = RedisManager.getInstance();
return redisManager.getStatus();
};
}
+32
View File
@@ -0,0 +1,32 @@
// core helpers and utilities
import { RedisManager } from "@/core/lib/redis-manager";
import { CustomHocuspocusRedisExtension } from "./redis";
import { shutdownManager } from "../shutdown-manager";
/**
* Sets up the Redis extension for HocusPocus using the RedisManager singleton
* @returns Promise that resolves to a Redis extension array
*/
export const setupRedisExtension = async () => {
const redisManager = RedisManager.getInstance();
// Wait for Redis connection
const redisClient = await redisManager.connect();
if (redisClient) {
return new CustomHocuspocusRedisExtension({
redis: redisClient,
});
} else {
shutdownManager.shutdown("Redis connection failed and could not be recovered", 1);
}
};
/**
* Helper to get the current Redis status
* Useful for health checks
*/
export const getRedisStatus = (): "connected" | "connecting" | "disconnected" | "not-configured" => {
const redisManager = RedisManager.getInstance();
return redisManager.getStatus();
};
+139
View File
@@ -0,0 +1,139 @@
// hocuspocus
import { Extension, Hocuspocus, Document } from "@hocuspocus/server";
import { TiptapTransformer } from "@hocuspocus/transformer";
import * as Y from "yjs";
// types
import { HocusPocusServerContext } from "@/core/types/common";
// editor extensions
import { TITLE_EDITOR_EXTENSIONS, createRealtimeEvent } from "@plane/editor";
// handlers
import { getDocumentHandler } from "@/core/handlers/page-handlers";
// helpers
import { generateTitleProsemirrorJson } from "@/core/helpers/generate-title-prosemirror-json";
import { extractTextFromHTML } from "./title-update/title-utils";
import { TitleUpdateManager } from "./title-update/title-update-manager";
import { broadcastMessageToPage } from "@/plane-live/lib/broadcast-message-to-page";
/**
* Hocuspocus extension for synchronizing document titles
*/
export class TitleSyncExtension implements Extension {
instance!: Hocuspocus;
// Maps document names to their observers and update managers
private titleObservers: Map<string, (events: Y.YEvent<any>[]) => void> = new Map();
private titleUpdateManagers: Map<string, TitleUpdateManager> = new Map();
/**
* Handle document loading - migrate old titles if needed
*/
async onLoadDocument({ context, document }: { context: HocusPocusServerContext; document: Document }) {
try {
// initially for on demand migration of old titles to a new title field
// in the yjs binary
if (document.isEmpty("title")) {
const documentHandler = getDocumentHandler(context.documentType);
const title = await documentHandler.fetchTitle?.({
context,
pageId: document.name,
});
if (title == null) return;
const titleField = TiptapTransformer.toYdoc(
generateTitleProsemirrorJson(title),
"title",
// editor
TITLE_EDITOR_EXTENSIONS
);
document.merge(titleField);
}
} catch (error) {
console.error("Error in onLoadDocument: ", error);
}
}
/**
* Set up title synchronization for a document after it's loaded
*/
async afterLoadDocument({
document,
documentName,
context,
instance,
}: {
document: Document;
documentName: string;
context: HocusPocusServerContext;
instance: Hocuspocus;
}) {
const documentHandler = getDocumentHandler(context.documentType);
// Create a title update manager for this document
const updateManager = new TitleUpdateManager(documentName, context, documentHandler);
// Store the manager
this.titleUpdateManagers.set(documentName, updateManager);
// Set up observer for title field
const titleObserver = (events: Y.YEvent<any>[]) => {
let title = "";
events.forEach((event) => {
title = extractTextFromHTML(event.currentTarget.toJSON());
});
// Schedule an update with the manager
const manager = this.titleUpdateManagers.get(documentName);
// In your titleObserver
if (context.parentId) {
const event = createRealtimeEvent({
user_id: context.userId,
workspace_slug: context.workspaceSlug as string,
action: "title_updated",
page_id: documentName,
data: { title },
descendants_ids: [],
});
broadcastMessageToPage(instance, context.parentId, event);
}
if (manager) {
manager.scheduleUpdate(title);
}
};
// Observe the title field
document.getXmlFragment("title").observeDeep(titleObserver);
this.titleObservers.set(documentName, titleObserver);
}
/**
* Force save title before unloading the document
*/
async beforeUnloadDocument({ documentName }: { documentName: string }) {
const updateManager = this.titleUpdateManagers.get(documentName);
if (updateManager) {
// Force immediate save and wait for it to complete
await updateManager.forceSave();
// Clean up the manager
this.titleUpdateManagers.delete(documentName);
}
}
/**
* Remove observers after document unload
*/
async afterUnloadDocument({ documentName }: { documentName: string }) {
// Clean up observer when document is unloaded
const observer = this.titleObservers.get(documentName);
if (observer) {
this.titleObservers.delete(documentName);
}
// Ensure manager is cleaned up if beforeUnloadDocument somehow didn't run
if (this.titleUpdateManagers.has(documentName)) {
const manager = this.titleUpdateManagers.get(documentName)!;
manager.cancel();
this.titleUpdateManagers.delete(documentName);
}
}
}
@@ -0,0 +1,275 @@
/**
* DebounceState - Tracks the state of a debounced function
*/
export interface DebounceState {
lastArgs: any[] | null;
timerId: ReturnType<typeof setTimeout> | null;
lastCallTime: number | undefined;
lastExecutionTime: number;
inProgress: boolean;
abortController: AbortController | null;
}
/**
* Creates a new DebounceState object
*/
export const createDebounceState = (): DebounceState => ({
lastArgs: null,
timerId: null,
lastCallTime: undefined,
lastExecutionTime: 0,
inProgress: false,
abortController: null,
});
/**
* DebounceOptions - Configuration options for debounce
*/
export interface DebounceOptions {
/** The wait time in milliseconds */
wait: number;
/** Optional logging prefix for debug messages */
logPrefix?: string;
}
/**
* Enhanced debounce manager with abort support
* Manages the state and timing of debounced function calls
*/
export class DebounceManager {
private state: DebounceState;
private wait: number;
private logPrefix: string;
/**
* Creates a new DebounceManager
* @param options Debounce configuration options
*/
constructor(options: DebounceOptions) {
this.state = createDebounceState();
this.wait = options.wait;
this.logPrefix = options.logPrefix || "";
}
/**
* Schedule a debounced function call
* @param func The function to call
* @param args The arguments to pass to the function
*/
schedule(func: (...args: any[]) => Promise<void>, ...args: any[]): void {
// Always update the last arguments
this.state.lastArgs = args;
const time = Date.now();
this.state.lastCallTime = time;
// If an operation is in progress, just store the new args and start the timer
if (this.state.inProgress) {
// Always restart the timer for the new call, even if an operation is in progress
if (this.state.timerId) {
clearTimeout(this.state.timerId);
}
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
return;
}
// If already scheduled, update the args and restart the timer
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
return;
}
// Start the timer for the trailing edge execution
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
}
/**
* Called when the timer expires
*/
private timerExpired(func: (...args: any[]) => Promise<void>): void {
const time = Date.now();
// Check if this timer expiration represents the end of the debounce period
if (this.shouldInvoke(time)) {
// Execute the function
this.executeFunction(func, time);
return;
}
// Otherwise restart the timer
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.remainingWait(time));
}
/**
* Execute the debounced function
*/
private executeFunction(func: (...args: any[]) => Promise<void>, time: number): void {
this.state.timerId = null;
this.state.lastExecutionTime = time;
// Execute the function asynchronously
this.performFunction(func).catch((error) => {
console.error(`${this.logPrefix}: Error in execution:`, error);
});
}
/**
* Perform the actual function call, handling any in-progress operations
*/
private async performFunction(func: (...args: any[]) => Promise<void>): Promise<void> {
const args = this.state.lastArgs;
if (!args) return;
// Store the args we're about to use
const currentArgs = [...args];
// If another operation is in progress, abort it
await this.abortOngoingOperation();
// Mark that we're starting a new operation
this.state.inProgress = true;
this.state.abortController = new AbortController();
try {
// Add the abort signal to the arguments if the function can use it
const execArgs = [...currentArgs];
execArgs.push(this.state.abortController.signal);
await func(...execArgs);
// Only clear lastArgs if they haven't been changed during this operation
if (this.state.lastArgs && this.arraysEqual(this.state.lastArgs, currentArgs)) {
this.state.lastArgs = null;
// Clear any timer as we've successfully processed the latest args
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
} else if (this.state.lastArgs) {
// If lastArgs have changed during this operation, the timer should already be running
// but let's make sure it is
if (!this.state.timerId) {
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
}
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
// Nothing to do here, the new operation will be triggered by the timer expiration
} else {
console.error(`${this.logPrefix}: Error during operation:`, error);
// On error (not abort), make sure we have a timer running to retry
if (!this.state.timerId && this.state.lastArgs) {
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
}
}
} finally {
this.state.inProgress = false;
this.state.abortController = null;
}
}
/**
* Abort any ongoing operation
*/
private async abortOngoingOperation(): Promise<void> {
if (this.state.inProgress && this.state.abortController) {
this.state.abortController.abort();
// Small delay to ensure the abort has had time to propagate
await new Promise((resolve) => setTimeout(resolve, 20));
// Double-check that state has been reset, force it if not
if (this.state.inProgress || this.state.abortController) {
this.state.inProgress = false;
this.state.abortController = null;
}
}
}
/**
* Determine if we should invoke the function now
*/
private shouldInvoke(time: number): boolean {
// Either this is the first call, or we've waited long enough since the last call
return this.state.lastCallTime === undefined || time - this.state.lastCallTime >= this.wait;
}
/**
* Calculate how much longer we should wait
*/
private remainingWait(time: number): number {
const timeSinceLastCall = time - (this.state.lastCallTime || 0);
return Math.max(0, this.wait - timeSinceLastCall);
}
/**
* Force immediate execution
*/
async flush(func: (...args: any[]) => Promise<void>): Promise<void> {
// Clear any pending timeout
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
// Reset timing state
this.state.lastCallTime = undefined;
// Perform the function immediately
if (this.state.lastArgs) {
await this.performFunction(func);
}
}
/**
* Cancel any pending operations without executing
*/
cancel(): void {
// Clear any pending timeout
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
// Reset timing state
this.state.lastCallTime = undefined;
// Abort any in-progress operation
if (this.state.inProgress && this.state.abortController) {
this.state.abortController.abort();
this.state.inProgress = false;
this.state.abortController = null;
}
// Clear args
this.state.lastArgs = null;
}
/**
* Compare two arrays for equality
*/
private arraysEqual(a: any[], b: any[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
}
@@ -0,0 +1,94 @@
import { DocumentHandler } from "@/core/types/document-handler";
import { DebounceManager } from "./debounce";
import { HocusPocusServerContext } from "@/core/types/common";
import { logger } from "@plane/logger";
/**
* Manages title update operations for a single document
* Handles debouncing, aborting, and force saving title updates
*/
export class TitleUpdateManager {
private documentName: string;
private context: HocusPocusServerContext;
private documentHandler: DocumentHandler;
private debounceManager: DebounceManager;
private lastTitle: string | null = null;
/**
* Create a new TitleUpdateManager instance
*/
constructor(
documentName: string,
context: HocusPocusServerContext,
documentHandler: DocumentHandler,
wait: number = 5000
) {
this.documentName = documentName;
this.context = context;
this.documentHandler = documentHandler;
// Set up debounce manager with logging
this.debounceManager = new DebounceManager({
wait,
logPrefix: `TitleManager[${documentName.substring(0, 8)}]`,
});
}
/**
* Schedule a debounced title update
*/
scheduleUpdate(title: string): void {
// Store the latest title
this.lastTitle = title;
// Schedule the update with the debounce manager
this.debounceManager.schedule(this.updateTitle.bind(this), title);
}
/**
* Update the title - will be called by the debounce manager
*/
private async updateTitle(title: string, signal?: AbortSignal): Promise<void> {
if (!this.documentHandler.updateTitle) {
logger.warn(`No updateTitle method found for document ${this.documentName}`);
return;
}
try {
await this.documentHandler.updateTitle({
context: this.context,
pageId: this.documentName,
title: title,
abortSignal: signal,
});
// Clear last title only if it matches what we just updated
if (this.lastTitle === title) {
this.lastTitle = null;
}
} catch (error) {
console.error(`Error updating title for ${this.documentName}:`, error);
}
}
/**
* Force save the current title immediately
*/
async forceSave(): Promise<void> {
// Ensure we have the current title
if (!this.lastTitle) {
return;
}
// Use the debounce manager to flush the operation
await this.debounceManager.flush(this.updateTitle.bind(this));
}
/**
* Cancel any pending updates
*/
cancel(): void {
this.debounceManager.cancel();
this.lastTitle = null;
}
}
@@ -0,0 +1,8 @@
/**
* Utility function to extract text from HTML content
*/
export const extractTextFromHTML = (html: string): string => {
// Use a regex to extract text between tags
const textMatch = html.replace(/<[^>]*>/g, "");
return textMatch || "";
};
@@ -0,0 +1,29 @@
import { Request, Response } from "express";
import { manualLogger } from "@/core/helpers/logger";
import { TConvertDocumentRequestBody } from "@/core/types/common";
import { convertHTMLDocumentToAllFormats } from "@plane/editor";
export const handleConvertDocument = (req: Request, res: Response) => {
const { description_html, variant } = req.body as TConvertDocumentRequestBody;
try {
if (description_html === undefined || variant === undefined) {
res.status(400).send({
message: "Missing required fields",
});
return;
}
const { description, description_binary } = convertHTMLDocumentToAllFormats({
document_html: description_html,
variant,
});
res.status(200).json({
description,
description_binary,
});
} catch (error) {
manualLogger.error("Error in /convert-document endpoint:", error);
res.status(500).send({
message: `Internal server error. ${error}`,
});
}
};
+1
View File
@@ -0,0 +1 @@
export { handleConvertDocument } from "./convert-document.handler";
@@ -1,11 +1,12 @@
import { DocumentHandler, HandlerContext, HandlerDefinition } from "@/core/types/document-handler";
import { HocusPocusServerContext } from "@/core/types/common";
import { DocumentHandler, HandlerDefinition } from "@/core/types/document-handler";
/**
* Class that manages handler selection based on multiple criteria
*/
export class DocumentHandlerFactory {
private handlers: HandlerDefinition[] = [];
/**
* Register a handler with its selection criteria
*/
@@ -14,14 +15,15 @@ export class DocumentHandlerFactory {
// Sort handlers by priority (highest first)
this.handlers.sort((a, b) => b.priority - a.priority);
}
/**
* Get the appropriate handler based on the provided context
*/
getHandler(context: HandlerContext): DocumentHandler {
getHandler(context: Partial<HocusPocusServerContext>): DocumentHandler {
// Find the first handler whose selector returns true
const matchingHandler = this.handlers.find(h => h.selector(context));
const matchingHandler = this.handlers.find((h) => h.selector(context));
// console.log("matchingHandler:", matchingHandler);
// Return the matching handler or fall back to null/undefined
// (This will cause an error if no handlers match, which is good for debugging)
return matchingHandler?.handler as DocumentHandler;
@@ -29,4 +31,4 @@ export class DocumentHandlerFactory {
}
// Create the singleton instance
export const handlerFactory = new DocumentHandlerFactory();
export const handlerFactory = new DocumentHandlerFactory();
@@ -1,12 +1,7 @@
import { DocumentHandler, HandlerContext } from "@/core/types/document-handler";
import { handlerFactory } from "@/core/handlers/document-handlers/handler-factory";
import { HocusPocusServerContext } from "@/core/types/common";
import { initializeDocumentHandlers } from "@/plane-live/document-types";
// Initialize all CE document handlers
initializeDocumentHandlers();
import { DocumentHandler } from "@/core/types/document-handler";
import { handlerFactory } from "@/core/handlers/page-handlers/handler-factory";
import { HocusPocusServerContext, TDocumentTypes } from "@/core/types/common";
/**
* Get a document handler based on the provided context criteria
* @param documentType The primary document type
@@ -14,12 +9,12 @@ initializeDocumentHandlers();
* @returns The appropriate document handler
*/
export function getDocumentHandler(
documentType: string,
additionalContext: Omit<HocusPocusServerContext, "documentType">
documentType: TDocumentTypes,
additionalContext?: Omit<HocusPocusServerContext, "documentType">
): DocumentHandler {
// Create a context object with all criteria
const context: HandlerContext = {
documentType: documentType as any,
const context: Partial<HocusPocusServerContext> = {
documentType: documentType,
...additionalContext,
};
-44
View File
@@ -1,44 +0,0 @@
// plane editor
import {
getAllDocumentFormatsFromDocumentEditorBinaryData,
getAllDocumentFormatsFromRichTextEditorBinaryData,
getBinaryDataFromDocumentEditorHTMLString,
getBinaryDataFromRichTextEditorHTMLString,
} from "@plane/editor";
// plane types
import { TDocumentPayload } from "@plane/types";
type TArgs = {
document_html: string;
variant: "rich" | "document";
};
export const convertHTMLDocumentToAllFormats = (args: TArgs): TDocumentPayload => {
const { document_html, variant } = args;
let allFormats: TDocumentPayload;
if (variant === "rich") {
const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else if (variant === "document") {
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else {
throw new Error(`Invalid variant provided: ${variant}`);
}
return allFormats;
};
@@ -0,0 +1,21 @@
export const generateTitleProsemirrorJson = (text: string) => {
return {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 1 },
...(text
? {
content: [
{
type: "text",
text,
},
],
}
: {}),
},
],
};
};
+35 -23
View File
@@ -1,20 +1,34 @@
import { getSchema } from "@tiptap/core";
import { generateHTML, generateJSON } from "@tiptap/html";
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
import * as Y from "yjs"
import * as Y from "yjs";
// plane editor
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
const DOCUMENT_EDITOR_EXTENSIONS = [
export const DOCUMENT_EDITOR_EXTENSIONS = [
...CoreEditorExtensionsWithoutProps,
...DocumentEditorExtensionsWithoutProps,
];
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
export const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
/**
* Extracts the text content from an HTML string
* @param html HTML string
* @returns text content
*/
export const extractTextFromHTML = (html: string): string => {
// Use a regex to extract text between tags
const textMatch = html.replace(/<[^>]*>/g, "");
return textMatch || "";
};
export const getAllDocumentFormatsFromBinaryData = (
description: Uint8Array
): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
titleHTML: string;
} => {
// encode binary description data
const base64Data = Buffer.from(description).toString("base64");
@@ -22,10 +36,11 @@ export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
Y.applyUpdate(yDoc, description);
// convert to JSON
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(
type,
documentEditorSchema
).toJSON();
const contentJSON = yXmlFragmentToProseMirrorRootNode(type, documentEditorSchema).toJSON();
const title = yDoc.getXmlFragment("title");
const titleJSON = yXmlFragmentToProseMirrorRootNode(title, documentEditorSchema).toJSON();
const titleHTML = extractTextFromHTML(generateHTML(titleJSON, DOCUMENT_EDITOR_EXTENSIONS));
// convert to HTML
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
@@ -33,27 +48,24 @@ export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
titleHTML,
};
}
};
export const getBinaryDataFromHTMLString = (descriptionHTML: string): {
contentBinary: Uint8Array
export const getBinaryDataFromHTMLString = (
descriptionHTML: string
): {
contentBinary: Uint8Array;
} => {
// convert HTML to JSON
const contentJSON = generateJSON(
descriptionHTML ?? "<p></p>",
DOCUMENT_EDITOR_EXTENSIONS
);
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", DOCUMENT_EDITOR_EXTENSIONS);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(
documentEditorSchema,
contentJSON,
"default"
);
const transformedData = prosemirrorJSONToYDoc(documentEditorSchema, contentJSON, "default");
// convert Y.Doc to Uint8Array format
const encodedData = Y.encodeStateAsUpdate(transformedData);
return {
contentBinary: encodedData
}
}
contentBinary: encodedData,
};
};
-261
View File
@@ -1,261 +0,0 @@
import { handleError } from "./error-handling/error-factory";
/**
* A simple validation utility that integrates with our error system.
*
* This provides a fluent interface for validating data and throwing
* appropriate errors if validation fails.
*/
export class Validator<T> {
constructor(
private readonly data: T,
private readonly name: string = "data"
) {}
/**
* Ensures a value is defined (not undefined or null)
*/
required(message?: string): Validator<T> {
if (this.data === undefined || this.data === null) {
throw handleError(new ValidationError(this.name, message || `${this.name} is required`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateRequired',
extraContext: { field: this.name },
throw: true
});
}
return this;
}
/**
* Ensures a value is a string
*/
string(message?: string): Validator<T> {
if (typeof this.data !== "string") {
throw handleError(new ValidationError(this.name, message || `${this.name} must be a string`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateString',
extraContext: { field: this.name },
throw: true
});
}
return this;
}
/**
* Ensures a string is not empty
*/
notEmpty(message?: string): Validator<T> {
if (typeof this.data === "string" && this.data.trim() === "") {
throw handleError(new ValidationError(this.name, message || `${this.name} cannot be empty`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateNonEmptyString',
extraContext: { field: this.name },
throw: true
});
}
return this;
}
/**
* Ensures a value is a number
*/
number(message?: string): Validator<T> {
if (typeof this.data !== "number" || isNaN(this.data)) {
throw handleError(new ValidationError(this.name, message || `${this.name} must be a valid number`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateNumber',
extraContext: { field: this.name },
throw: true
});
}
return this;
}
/**
* Ensures an array is not empty
*/
nonEmptyArray(message?: string): Validator<T> {
if (!Array.isArray(this.data) || this.data.length === 0) {
throw handleError(new ValidationError(this.name, message || `${this.name} must be a non-empty array`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateArray',
extraContext: { field: this.name },
throw: true
});
}
return this;
}
/**
* Ensures a value matches a regular expression
*/
match(regex: RegExp, message?: string): Validator<T> {
if (typeof this.data !== "string" || !regex.test(this.data)) {
throw handleError(new ValidationError(this.name, message || `${this.name} has an invalid format`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateFormat',
extraContext: { field: this.name, format: regex.toString() },
throw: true
});
}
return this;
}
/**
* Ensures a value is one of the allowed values
*/
oneOf(allowedValues: any[], message?: string): Validator<T> {
if (!allowedValues.includes(this.data)) {
throw handleError(new ValidationError(this.name, message || `${this.name} must be one of: ${allowedValues.join(", ")}`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateEnum',
extraContext: { field: this.name, allowedValues },
throw: true
});
}
return this;
}
/**
* Custom validation function
*/
custom(validationFn: (value: T) => boolean, message?: string): Validator<T> {
if (!validationFn(this.data)) {
throw handleError(new ValidationError(this.name, message || `${this.name} is invalid`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateCustom',
extraContext: { field: this.name },
throw: true
});
}
return this;
}
/**
* Get the validated data
*/
get(): T {
return this.data;
}
}
/**
* Create a new validator for a value
*/
export const validate = <T>(data: T, name?: string): Validator<T> => {
return new Validator(data, name);
};
export default validate;
export class ValidationError extends Error {
constructor(public name: string, message: string) {
super(message);
this.name = name;
}
}
export const validateRequired = (value: any, name: string, message?: string) => {
if (value === undefined || value === null) {
throw handleError(new ValidationError(name, message || `${name} is required`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateRequired',
extraContext: { field: name },
throw: true
});
}
};
export const validateString = (value: any, name: string, message?: string) => {
if (typeof value !== 'string') {
throw handleError(new ValidationError(name, message || `${name} must be a string`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateString',
extraContext: { field: name },
throw: true
});
}
};
export const validateNonEmptyString = (value: string, name: string, message?: string) => {
if (!value.trim()) {
throw handleError(new ValidationError(name, message || `${name} cannot be empty`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateNonEmptyString',
extraContext: { field: name },
throw: true
});
}
};
export const validateNumber = (value: any, name: string, message?: string) => {
if (typeof value !== 'number' || isNaN(value)) {
throw handleError(new ValidationError(name, message || `${name} must be a valid number`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateNumber',
extraContext: { field: name },
throw: true
});
}
};
export const validateArray = (value: any, name: string, message?: string) => {
if (!Array.isArray(value) || value.length === 0) {
throw handleError(new ValidationError(name, message || `${name} must be a non-empty array`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateArray',
extraContext: { field: name },
throw: true
});
}
};
export const validateFormat = (value: string, name: string, format: RegExp, message?: string) => {
if (!format.test(value)) {
throw handleError(new ValidationError(name, message || `${name} has an invalid format`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateFormat',
extraContext: { field: name, format: format.toString() },
throw: true
});
}
};
export const validateEnum = (value: any, name: string, allowedValues: any[], message?: string) => {
if (!allowedValues.includes(value)) {
throw handleError(new ValidationError(name, message || `${name} must be one of: ${allowedValues.join(", ")}`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateEnum',
extraContext: { field: name, allowedValues },
throw: true
});
}
};
export const validateCustom = (value: any, name: string, validator: (value: any) => boolean, message?: string) => {
if (!validator(value)) {
throw handleError(new ValidationError(name, message || `${name} is invalid`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateCustom',
extraContext: { field: name },
throw: true
});
}
};
+75 -24
View File
@@ -7,48 +7,44 @@ import { handleAuthentication } from "@/core/lib/authentication";
import { getExtensions } from "@/core/extensions/index";
import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib";
// editor types
import { TUserDetails } from "@plane/editor";
import { EventToPayloadMap, TUserDetails, createRealtimeEvent } from "@plane/editor";
// types
import { TDocumentTypes, type HocusPocusServerContext } from "@/core/types/common";
// error handling
import { catchAsync } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
// server agent
import { serverAgentManager } from "./agents/server-agent";
export const getHocusPocusServer = async () => {
const extensions = await getExtensions();
const serverName = process.env.HOSTNAME || uuidv4();
return Server.configure({
const server = Server.configure({
name: serverName,
onAuthenticate: async ({
requestHeaders,
context,
requestParameters,
// user id used as token for authentication
context,
token,
}: {
requestHeaders: IncomingHttpHeaders;
context: HocusPocusServerContext; // Better than 'any', still allows property assignment
context: HocusPocusServerContext;
requestParameters: URLSearchParams;
token: string;
}) => {
// need to rethrow all errors since hocuspocus needs to know to stop
// further propagation of events to other document lifecycle
return catchAsync(
async () => {
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)
// Extract cookie (fallback to request headers) and userId from token
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();
}
@@ -66,9 +62,12 @@ export const getHocusPocusServer = async () => {
}
context.documentType = requestParameters.get("documentType")?.toString() as TDocumentTypes;
context.cookie = cookie ?? requestParameters.get("cookie");
context.cookie = cookie ?? requestParameters.get("cookie") ?? "";
context.userId = userId;
context.workspaceSlug = requestParameters.get("workspaceSlug")?.toString() as string;
context.workspaceSlug = requestParameters.get("workspaceSlug")?.toString() ?? "";
context.parentId = requestParameters.get("parentPageId")?.toString() ?? undefined;
context.projectId = requestParameters.get("projectId")?.toString() ?? "";
context.teamspaceId = requestParameters.get("teamspaceId")?.toString() ?? "";
return await handleAuthentication({
cookie: context.cookie,
@@ -77,24 +76,76 @@ export const getHocusPocusServer = async () => {
});
},
{ extra: { operation: "authenticate" } },
{
rethrow: true,
}
{ rethrow: true }
)();
},
onStateless: async ({ payload, document }) => {
onStateless: async ({ payload, document, connection }) => {
return catchAsync(
async () => {
// 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);
const payloadStr = payload as string;
// Function to safely parse JSON without throwing exceptions
const safeJsonParse = (str: string) => {
try {
return { success: true, data: JSON.parse(str) };
} catch (e) {
return { success: false, error: e };
}
};
// First check if this is a known document event
const documentEvent = DocumentCollaborativeEvents[payload as TDocumentEventsServer]?.client;
if (documentEvent) {
const eventType = documentEvent as keyof EventToPayloadMap;
let eventData: Partial<EventToPayloadMap[typeof eventType]> = {
user_id: connection.context.userId,
};
if (eventType === "archived") {
eventData = {
...eventData,
archived_at: new Date().toISOString(),
};
}
const realtimeEvent = createRealtimeEvent({
action: eventType,
page_id: document.name,
descendants_ids: [],
data: eventData as EventToPayloadMap[typeof eventType],
workspace_slug: connection.context.workspaceSlug || "",
user_id: connection.context.userId || "",
});
// Broadcast the event
document.broadcastStateless(JSON.stringify(realtimeEvent));
return;
}
// If not a document event, try to parse as JSON
const parseResult = safeJsonParse(payloadStr);
if (parseResult.success && parseResult.data && typeof parseResult.data === "object") {
const parsedPayload = parseResult.data as {
workspaceSlug?: string;
projectId?: string;
action?: string;
};
// Handle synced action
if (parsedPayload.action === "synced" && parsedPayload.workspaceSlug) {
serverAgentManager.notifySyncTrigger(document.name, connection.context);
return;
}
}
},
{ extra: { operation: "stateless", payload } }
);
)();
},
extensions,
debounce: 1000,
extensions: [...extensions],
debounce: 10000,
});
return server;
};
-1
View File
@@ -17,7 +17,6 @@ export const handleAuthentication = async (props: Props) => {
try {
response = await userService.currentUser(cookie);
} catch (error) {
console.log("caught?");
handleError(error, {
errorType: "unauthorized",
message: "Failed to authenticate user",
-64
View File
@@ -1,64 +0,0 @@
// helpers
import { getAllDocumentFormatsFromBinaryData, getBinaryDataFromHTMLString } from "@/core/helpers/page";
// services
import { PageService } from "@/core/services/page.service";
const pageService = new PageService();
export const updatePageDescription = async (
params: URLSearchParams,
pageId: string,
updatedDescription: Uint8Array,
cookie: string | undefined
) => {
if (!(updatedDescription instanceof Uint8Array)) {
throw new Error("Invalid updatedDescription: must be an instance of Uint8Array");
}
const workspaceSlug = params.get("workspaceSlug")?.toString();
const projectId = params.get("projectId")?.toString();
if (!workspaceSlug || !projectId || !cookie) return;
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromBinaryData(updatedDescription);
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
await pageService.updateDescription(workspaceSlug, projectId, pageId, payload, cookie);
};
const fetchDescriptionHTMLAndTransform = async (
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string
) => {
if (!workspaceSlug || !projectId || !cookie) return;
const pageDetails = await pageService.fetchDetails(workspaceSlug, projectId, pageId, cookie);
const { contentBinary } = getBinaryDataFromHTMLString(pageDetails.description_html ?? "<p></p>");
return contentBinary;
};
export const fetchPageDescriptionBinary = async (
params: URLSearchParams,
pageId: string,
cookie: string | undefined
) => {
const workspaceSlug = params.get("workspaceSlug")?.toString();
const projectId = params.get("projectId")?.toString();
if (!workspaceSlug || !projectId || !cookie) return null;
const response = await pageService.fetchDescriptionBinary(workspaceSlug, projectId, pageId, cookie);
const binaryData = new Uint8Array(response);
if (binaryData.byteLength === 0) {
const binary = await fetchDescriptionHTMLAndTransform(workspaceSlug, projectId, pageId, cookie);
if (binary) {
return binary;
}
}
return binaryData;
};
+20 -12
View File
@@ -1,9 +1,8 @@
import { Redis } from "ioredis";
import { logger } from "@plane/logger";
import { getRedisUrl } from "@/core/lib/utils/redis-url";
import { ShutdownManager } from "@/core/shutdown-manager";
import { shutdownManager } from "@/core/shutdown-manager";
// Define Redis error interface to handle specific error properties
interface RedisError extends Error {
code?: string;
}
@@ -32,9 +31,7 @@ export class RedisManager {
const redisUrl = getRedisUrl();
if (!redisUrl) {
logger.warn(
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)"
);
shutdownManager.shutdown("Redis URL is not set, shutting down", 1);
return null;
}
@@ -45,13 +42,12 @@ export class RedisManager {
logger.warn(
"Initial Redis connection attempt failed. Continuing without Redis (you won't be able to sync data between multiple plane live servers)"
);
shutdownManager.shutdown("Redis connection failed and could not be recovered", 1);
return null;
} else {
// Once connected at least once, try a few times before giving up
if (times > this.maxReconnectAttempts) {
logger.error(`Exceeded ${this.maxReconnectAttempts} Redis reconnect attempts. Shutting down the server.`);
// Use ShutdownManager to gracefully terminate the server
const shutdownManager = ShutdownManager.getInstance();
shutdownManager.shutdown("Redis connection lost and could not be recovered", 1);
return null; // This will never be reached due to shutdown, but needed for type safety
}
@@ -76,15 +72,27 @@ export class RedisManager {
});
this.client.on("error", (error: RedisError) => {
const fatalErrorCodes = [
"ENOTFOUND",
"ECONNREFUSED",
"ECONNRESET",
"ETIMEDOUT",
"EHOSTUNREACH",
"EPIPE",
"WRONGPASS",
"NOAUTH",
];
const fatalMessages = ["WRONGPASS", "NOAUTH", "READONLY", "LOADING", "CLUSTERDOWN", "CONNECTION_BROKEN"];
if (
error?.code === "ENOTFOUND" ||
error.message.includes("WRONGPASS") ||
error.message.includes("NOAUTH") ||
error.message.includes("ECONNREFUSED")
(error?.code && fatalErrorCodes.includes(error.code)) ||
fatalMessages.some((msg) => error.message.includes(msg))
) {
if (this.client) this.client.disconnect();
shutdownManager.shutdown("Redis connection failed and could not be recovered", 1);
} else {
logger.warn("Non-fatal Redis error:", error);
}
logger.warn("Redis error:", error);
});
this.client.on("close", () => {
+132 -29
View File
@@ -1,18 +1,67 @@
// types
import { TPage } from "@plane/types";
import { TDocumentPayload } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/core/services/api.service";
import { env } from "@/env";
export class PageService extends APIService {
// Base params interface
export type IBasePageParams<TConfig extends Record<string, any> = Record<string, any>> = {
pageId: string;
cookie?: string;
config: TConfig;
};
export type IFetchDetailsParams<TConfig extends Record<string, any> = Record<string, any>> = IBasePageParams<TConfig>;
export type IUpdateTitleParams<TConfig extends Record<string, any> = Record<string, any>> = IBasePageParams<TConfig> & {
data: { name: string };
abortSignal?: AbortSignal;
};
export type IUpdateDescriptionParams<TConfig extends Record<string, any> = Record<string, any>> =
IBasePageParams<TConfig> & {
data: TDocumentPayload;
};
export abstract class BasePageService extends APIService {
constructor() {
super(API_BASE_URL);
}
async fetchDetails(workspaceSlug: string, projectId: string, pageId: string, cookie: string): Promise<TPage> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`, {
headers: {
Cookie: cookie,
},
/**
* Gets the base URL path for API requests based on the context (workspace, project, teamspace)
*/
protected abstract getBasePath(params: Record<string, any>): string;
/**
* Gets the common headers used for requests
*/
protected getHeaders(params: { cookie?: string; isBinary?: boolean }): Record<string, string> {
const { cookie, isBinary = false } = params;
const headers: Record<string, string> = {};
if (cookie) {
headers.Cookie = cookie;
}
if (env.LIVE_SERVER_SECRET_KEY) {
headers["live-server-secret-key"] = env.LIVE_SERVER_SECRET_KEY;
}
if (isBinary) {
headers["Content-Type"] = "application/octet-stream";
}
return headers;
}
/**
* Fetches page details
*/
async fetchDetails<TConfig extends Record<string, any>>(params: IFetchDetailsParams<TConfig>) {
const { pageId, cookie, config } = params;
return this.get(`${this.getBasePath({ pageId, cookie, config })}/`, {
headers: this.getHeaders({ cookie }),
})
.then((response) => response?.data)
.catch((error) => {
@@ -20,39 +69,93 @@ export class PageService extends APIService {
});
}
async fetchDescriptionBinary(workspaceSlug: string, projectId: string, pageId: string, cookie: string): Promise<any> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, {
headers: {
"Content-Type": "application/octet-stream",
Cookie: cookie,
},
/**
* Fetches binary description data for a page
*/
async fetchDescriptionBinary<TConfig extends Record<string, any>>(params: IFetchDetailsParams<TConfig>) {
const { pageId, cookie, config } = params;
return this.get(`${this.getBasePath({ pageId, cookie, config })}/description/`, {
headers: this.getHeaders({ cookie: cookie, isBinary: true }),
responseType: "arraybuffer",
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
console.log("error", error);
// throw error?.response?.data;
});
}
async updateDescription(
workspaceSlug: string,
projectId: string,
pageId: string,
data: {
description_binary: string;
description_html: string;
description: object;
},
cookie: string
): Promise<any> {
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, data, {
headers: {
Cookie: cookie,
},
/**
* Updates the title of a page
*/
async updateTitle<TConfig extends Record<string, any>>(params: IUpdateTitleParams<TConfig>) {
const { config, pageId, data, cookie, abortSignal } = params;
// Early abort check
if (abortSignal?.aborted) {
throw new DOMException("Aborted", "AbortError");
}
// Create an abort listener that will reject the pending promise
let abortListener: (() => void) | undefined;
const abortPromise = new Promise((_, reject) => {
if (abortSignal) {
abortListener = () => {
reject(new DOMException("Aborted", "AbortError"));
};
abortSignal.addEventListener("abort", abortListener);
}
});
try {
return await Promise.race([
this.patch(`${this.getBasePath({ pageId, cookie, config })}/`, data, {
headers: this.getHeaders({ cookie }),
signal: abortSignal,
})
.then((response) => response?.data)
.catch((error) => {
if (error.name === "AbortError") {
throw new DOMException("Aborted", "AbortError");
}
throw error;
}),
abortPromise,
]);
} finally {
// Clean up abort listener
if (abortSignal && abortListener) {
abortSignal.removeEventListener("abort", abortListener);
}
}
}
/**
* Updates the description of a page
*/
async updateDescription<TConfig extends Record<string, any>>(params: IUpdateDescriptionParams<TConfig>) {
const { pageId, data, cookie, config } = params;
return this.patch(`${this.getBasePath({ pageId, cookie, config })}/description/`, data, {
headers: this.getHeaders({ cookie }),
})
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
/**
* Fetches sub-pages of a page
*/
async fetchSubPageDetails<TConfig extends Record<string, any>>(params: IFetchDetailsParams<TConfig>) {
const { pageId, cookie, config } = params;
return this.get(`${this.getBasePath({ pageId, cookie, config })}/sub-pages/`, {
headers: this.getHeaders({ cookie: cookie }),
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
@@ -0,0 +1,19 @@
// services
import { ProjectPageConfig } from "@/ce/document-types/project-page-handler";
import { BasePageService, IBasePageParams } from "@/core/services/base-page.service";
/**
* Service for handling project page operations
*/
export class ProjectPageService extends BasePageService {
/**
* Gets the base URL path for project pages
*/
protected getBasePath<TConfig extends ProjectPageConfig>(params: IBasePageParams<TConfig>): string {
const { pageId, config } = params;
const { workspaceSlug, projectId } = config;
// Handle project pages
return `/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}`;
}
}
@@ -0,0 +1,14 @@
// services
import { BasePageService, IBasePageParams } from "@/core/services/base-page.service";
export interface ServerAgentConfig {}
export class ServerAgentService extends BasePageService {
/**
* Gets the base URL path for workspace pages
*/
protected getBasePath<TConfig extends ServerAgentConfig>(params: IBasePageParams<TConfig>): string {
const { pageId } = params;
return `/api/pages/${pageId}`;
}
}
@@ -0,0 +1,20 @@
// services
import { BasePageService, IBasePageParams, IUpdateDescriptionParams } from "@/core/services/base-page.service";
export interface SyncAgentConfig {}
export class SyncAgentService extends BasePageService {
/**
* Gets the base URL path for workspace pages
*/
protected getBasePath<TConfig extends SyncAgentConfig>(params: IBasePageParams<TConfig>): string {
const { pageId } = params;
return `/api/pages/${pageId}`;
}
async updateDescription<TConfig extends SyncAgentConfig>(params: IUpdateDescriptionParams<TConfig>) {
// no op
// since we can't prevent hocuspocus from updating the description after a
// sync event, we need to manually override the method to not do anything
}
}
+54 -148
View File
@@ -1,168 +1,74 @@
import { Server as HttpServer } from "http";
import { Hocuspocus } from "@hocuspocus/server";
import { logger } from "@plane/logger";
import { RedisManager } from "@/core/lib/redis-manager";
// config
import { serverConfig } from "@/config/server-config";
import { Server } from "http";
// Global flag to prevent duplicate shutdown sequences
let isGlobalShutdownInProgress = false;
import { Server as HttpServer } from "http";
import { handleError } from "./helpers/error-handling/error-factory";
/**
* ShutdownManager - Handles graceful shutdown of all server components
*
* Implements the singleton pattern to ensure only one shutdown sequence
* can be initiated throughout the application.
* Handles graceful shutdown and process signal management for the HTTP server.
*/
export class ShutdownManager {
private static instance: ShutdownManager;
class ShutdownManager {
private httpServer: HttpServer | null = null;
private hocuspocusServer: Hocuspocus | null = null;
private isShuttingDown = false;
private exitCode = 0;
private forceExitTimeout: NodeJS.Timeout | null = null;
// Private constructor to enforce singleton pattern
private constructor() {}
/**
* Get the singleton instance
* Register the HTTP server instance to be shut down later.
*/
public static getInstance(): ShutdownManager {
if (!ShutdownManager.instance) {
ShutdownManager.instance = new ShutdownManager();
}
return ShutdownManager.instance;
}
/**
* Register server instances that need to be gracefully closed during shutdown
*/
public register(httpServer: Server, hocuspocusServer: Hocuspocus): void {
register({ httpServer }: { httpServer: HttpServer }) {
this.httpServer = httpServer;
this.hocuspocusServer = hocuspocusServer;
logger.info("ShutdownManager registered with server instances");
}
/**
* Check if a shutdown is in progress
* Register process termination signal handlers.
*/
public isShutdownInProgress(): boolean {
return this.isShuttingDown || isGlobalShutdownInProgress;
}
registerTerminationHandlers(): void {
const gracefulTermination = this.getGracefulTerminationHandler();
process.on("SIGTERM", gracefulTermination);
process.on("SIGINT", gracefulTermination);
/**
* Initiate graceful shutdown sequence
* @param reason Reason for shutdown
* @param exitCode Process exit code (default: 0)
*/
public async shutdown(reason: string, exitCode = 0): Promise<void> {
// Prevent multiple shutdown attempts
if (this.isShuttingDown || isGlobalShutdownInProgress) {
logger.warn("Shutdown already in progress, ignoring additional shutdown request");
return;
}
process.on("uncaughtException", (error) => {
logger.error("Uncaught exception:", error);
this.isShuttingDown = true;
isGlobalShutdownInProgress = true;
this.exitCode = exitCode;
handleError(error, {
errorType: "internal",
component: "process",
operation: "uncaughtException",
extraContext: { source: "uncaughtException" },
});
});
logger.info(`Initiating graceful shutdown: ${reason}`);
// Create a timeout to force exit if shutdown takes too long
this.forceExitTimeout = setTimeout(() => {
logger.error("Forcing termination after timeout - some connections may not have closed gracefully.");
process.exit(1);
}, serverConfig.terminationTimeout || 10000); // Default to 10 seconds if not configured
try {
// Close components in order: Redis, HocusPocus, HTTP server
await this.closeRedisConnections();
await this.closeHocusPocusServer();
await this.closeHttpServer();
// Wait a bit to allow handles to close
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log("All components shut down successfully");
} catch (error) {
console.error("Error during graceful shutdown:", error);
} finally {
// Clear timeout if we've made it this far
if (this.forceExitTimeout) {
clearTimeout(this.forceExitTimeout);
}
// Give a small delay before exiting to ensure all handles are closed
setTimeout(() => {
console.info(`Exiting process with code ${this.exitCode}`);
process.exit(this.exitCode);
}, 100);
}
}
/**
* Close Redis connections
*/
private async closeRedisConnections(): Promise<void> {
console.info("Closing Redis connections...");
try {
const redisManager = RedisManager.getInstance();
const redisClient = redisManager.getClient();
if (redisClient) {
redisClient.disconnect();
await redisClient.quit();
console.info("Redis connections closed successfully");
} else {
console.info("No Redis connections to close");
}
} catch (error) {
console.error("Error closing Redis connections:", error);
}
}
/**
* Close HocusPocus server
*/
private async closeHocusPocusServer(): Promise<void> {
console.info("Shutting down HocusPocus server...");
try {
if (this.hocuspocusServer) {
await this.hocuspocusServer.destroy();
console.info("HocusPocus server shut down successfully");
} else {
console.info("No HocusPocus server to shut down");
}
} catch (error) {
console.error("Error shutting down HocusPocus server:", error);
}
}
/**
* Close HTTP server
*/
private async closeHttpServer(): Promise<void> {
logger.info("Closing HTTP server...");
return new Promise<void>((resolve) => {
if (!this.httpServer) {
console.info("No HTTP server to close");
resolve();
return;
}
// Close all connections
this.httpServer.closeAllConnections?.();
this.httpServer.close((error) => {
if (error) {
console.error("Error closing HTTP server:", error);
} else {
console.info("HTTP server closed successfully");
}
resolve();
process.on("unhandledRejection", (reason) => {
logger.error("Unhandled rejection:", reason);
handleError(reason, {
errorType: "internal",
component: "process",
operation: "unhandledRejection",
extraContext: { source: "unhandledRejection" },
});
});
}
/**
* Returns a handler that gracefully shuts down the HTTP server.
*/
private getGracefulTerminationHandler(): () => Promise<void> {
return async () => {
logger.info("Signal received, shutting down HTTP server");
await this.shutdown();
};
}
/**
* Gracefully shuts down the registered HTTP server.
*/
async shutdown(message?: string, exitCode = 0): Promise<void> {
logger.error(`Initiating graceful shutdown ${`${message ? `with message: ${message}` : ""}`}`);
if (this.httpServer) {
this.httpServer.closeAllConnections?.();
await new Promise<void>((resolve) => {
this.httpServer?.close(() => resolve());
});
}
process.exit(exitCode);
}
}
export const shutdownManager = new ShutdownManager();
+115
View File
@@ -0,0 +1,115 @@
import * as Y from "yjs";
/**
* Recursively finds all XML elements in the tree that match the given criteria
*
* @param node The root node to start searching from (Y.XmlFragment or Y.XmlElement)
* @param nodeName The node name to match (e.g. "pageEmbedComponent")
* @param attributeName The attribute name to match
* @param attributeValue The attribute value to match or "*" for any value
* @returns An array of objects containing the matched node and its path information
*/
export function findAllElementsRecursive(
node: Y.XmlFragment | Y.XmlElement,
nodeName: string,
attributeName: string,
attributeValue: string,
path: string[] = []
): Array<{
node: Y.XmlElement;
parent: Y.XmlFragment | Y.XmlElement;
indexInParent: number;
path: string[];
}> {
const results: Array<{
node: Y.XmlElement;
parent: Y.XmlFragment | Y.XmlElement;
indexInParent: number;
path: string[];
}> = [];
const children = node.toArray();
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (child instanceof Y.XmlElement) {
// Calculate the path to this node
const nodePath = [...path];
if (child.nodeName) {
nodePath.push(`${child.nodeName}[${i}]`);
}
// Check if the current element matches the criteria
if (child.nodeName === nodeName) {
const attrValue = child.getAttribute(attributeName);
// Match if the attribute value is the wildcard "*" or if it matches exactly
if (attributeValue === "*" || attrValue === attributeValue) {
results.push({
node: child,
parent: node,
indexInParent: i,
path: nodePath,
});
}
}
// Recursively search in this element's children
const nestedResults = findAllElementsRecursive(child, nodeName, attributeName, attributeValue, nodePath);
results.push(...nestedResults);
}
}
return results;
}
/**
* Get a string representation of the path to a node for logging purposes
*
* @param pathArray The path array from findAllElementsRecursive
* @returns A string representation of the path
*/
export function getPathString(pathArray: string[]): string {
if (pathArray.length === 0) {
return "root";
}
return pathArray.join(" > ");
}
/**
* Insert a new node after a specific target node in the XML tree
*
* @param parent The parent node containing the target node
* @param targetIndex The index of the target node in the parent
* @param newNode The new node to insert
*/
export function insertNodeAfter(
parent: Y.XmlFragment | Y.XmlElement,
targetIndex: number,
newNode: Y.XmlElement
): void {
parent.insert(targetIndex + 1, [newNode]);
}
/**
* Replace a node in the XML tree with a new node
*
* @param parent The parent node containing the target node
* @param targetIndex The index of the target node in the parent
* @param newNode The new node to replace the target node with
*/
export function replaceNode(parent: Y.XmlFragment | Y.XmlElement, targetIndex: number, newNode: Y.XmlElement): void {
parent.delete(targetIndex, 1);
parent.insert(targetIndex, [newNode]);
}
/**
* Delete a node from the XML tree
*
* @param parent The parent node containing the target node
* @param targetIndex The index of the target node in the parent
*/
export function deleteNode(parent: Y.XmlFragment | Y.XmlElement, targetIndex: number): void {
parent.delete(targetIndex, 1);
}
+2
View File
@@ -24,6 +24,8 @@ const envSchema = z.object({
// Graceful termination timeout
SHUTDOWN_TIMEOUT: z.string().default("10000").transform(Number),
// Live server secret key
LIVE_SERVER_SECRET_KEY: z.string(),
});
// Validate the environment variables
@@ -123,11 +123,16 @@ export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
titleHTML: string;
} => {
// encode binary description data
const base64Data = convertBinaryDataToBase64String(description);
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, description);
const title = yDoc.getXmlFragment("title");
const titleJSON = yXmlFragmentToProseMirrorRootNode(title, documentEditorSchema).toJSON();
// @ts-expect-error tiptap types are incorrect
const titleHTML = extractTextFromHTML(generateHTML(titleJSON, DOCUMENT_EDITOR_EXTENSIONS));
// convert to JSON
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(type, documentEditorSchema).toJSON();
@@ -139,6 +144,7 @@ export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
titleHTML,
};
};
@@ -188,3 +194,9 @@ export const convertHTMLDocumentToAllFormats = (args: TConvertHTMLDocumentToAllF
return allFormats;
};
export const extractTextFromHTML = (html: string): string => {
// Use a regex to extract text between tags
const textMatch = html.replace(/<[^>]*>/g, "");
return textMatch || "";
};