[WIKI-680] feat: Live Server Refactor to Sync with CE repo (#4144)

* feat: live server refactor

fix: code refactor

fix: lint errors

* fix: code refactor

* fix: controller dependency fixes

* fix: cors setup

* fix: env variable validation

* fix: trime cors orgins

* fix: extensions hoscuspos server

* fix: remove unused utils

* chore: code refactor

* chore: code refactor

* fix: code refactor

* fix: code refactor

* fix: adding teamspace service

* Add strong typing to improve type safety

* fix: remove unused packages

* fix: live server explicit dependency while setting up routes

* fix: remove unused deps and made initialization explicit

* fix: database types and reuse functions

* fix: all the code migrated to new format

* fix: sub page fetching errors

* fix: types fixed

* fix: all typescript errors resolved

* fix: auth and stateless types

* fix: parity with ce

* fix: build and dev scripts

* fix: build scripts

* fix: registerControllers instance removed from silo

* fix: broken lock file

* fix: lint skip

* fix: api requests to wrong endpoint

* fix: title sync extension usage

---------

Co-authored-by: Palanikannan M <[email protected]>
This commit is contained in:
sriram veeraghanta
2025-09-30 19:30:02 +05:30
committed by GitHub
co-authored by Palanikannan M
parent 63b8032516
commit d72444b005
101 changed files with 5441 additions and 8227 deletions
+6 -20
View File
@@ -4,13 +4,11 @@
"license": "AGPL-3.0",
"description": "A realtime collaborative server powers Plane's rich text editor",
"main": "./dist/start.js",
"module": "./dist/start.mjs",
"types": "./dist/start.d.ts",
"private": true,
"type": "module",
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"build": "tsc --noEmit && tsdown",
"dev": "tsdown --watch --onSuccess \"node --env-file=.env dist/start.js\"",
"start": "node --env-file=.env dist/start.js",
"check:lint": "eslint . --max-warnings 53",
"check:types": "tsc --noEmit",
@@ -22,6 +20,7 @@
"keywords": [],
"author": "",
"dependencies": {
"@dotenvx/dotenvx": "^1.49.0",
"@hocuspocus/extension-database": "^2.15.0",
"@hocuspocus/extension-logger": "^2.15.0",
"@hocuspocus/extension-redis": "^2.15.0",
@@ -32,30 +31,21 @@
"@plane/editor": "workspace:*",
"@plane/logger": "workspace:*",
"@plane/types": "workspace:*",
"@sentry/core": "^9.5.0",
"@sentry/node": "^9.5.0",
"@sentry/profiling-node": "^9.5.0",
"@tiptap/core": "^2.22.3",
"@tiptap/html": "^2.22.3",
"axios": "catalog:",
"compression": "1.8.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"@dotenvx/dotenvx": "^1.47.6",
"express": "^4.21.2",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
"ioredis": "^5.4.1",
"morgan": "1.10.1",
"pino-http": "^10.3.0",
"pino-pretty": "^11.2.2",
"reflect-metadata": "^0.2.2",
"uuid": "catalog:",
"ws": "^8.18.3",
"y-prosemirror": "^1.2.15",
"y-protocols": "^1.0.6",
"y-prosemirror": "^1.3.7",
"yjs": "^13.6.20",
"zod": "^3.24.2"
"zod": "^3.25.76"
},
"devDependencies": {
"@plane/eslint-config": "workspace:*",
@@ -66,12 +56,8 @@
"@types/express": "^4.17.23",
"@types/express-ws": "^3.0.5",
"@types/node": "^20.14.9",
"@types/pino-http": "^5.8.4",
"@types/uuid": "^9.0.1",
"@types/ws": "^8.5.10",
"concurrently": "^9.0.1",
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
"@types/ws": "^8.18.1",
"tsdown": "catalog:",
"typescript": "catalog:",
"ws": "^8.18.3"
@@ -1,13 +1,12 @@
import { DirectConnection, Hocuspocus } from "@hocuspocus/server";
import { Response } from "express";
import type { Response } from "express";
import { v4 as uuidv4 } from "uuid";
import * as Y from "yjs";
import { TPage } from "@plane/types";
import { getDocumentHandler } from "@/core/handlers/page-handlers";
import { manualLogger } from "@/core/helpers/logger";
import { HocusPocusServerContext } from "@/core/types/common";
import { DocumentProcessor } from "@/plane-live/lib/document-processor";
import type { Doc } from "yjs";
import { logger } from "@plane/logger";
import { type TPage } from "@plane/types";
import { DocumentProcessor } from "@/lib/document-processor/document-processor";
import { getPageService } from "@/services/page/handler";
import { HocusPocusServerContext } from "@/types";
/**
* Metadata for a stored connection
@@ -88,7 +87,7 @@ export class ServerAgentManager {
// Set up periodic cleanup of unused connections
this.startConnectionCleanup();
manualLogger.info("ServerAgentManager initialized");
logger.info("ServerAgentManager initialized");
return this;
}
@@ -106,7 +105,6 @@ export class ServerAgentManager {
// 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();
@@ -137,7 +135,7 @@ export class ServerAgentManager {
return connectionData;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
manualLogger.error(`Failed to create connection for document ${documentId}: ${errorMessage}`);
logger.error(`Failed to create connection for document ${documentId}: ${errorMessage}`);
throw new ServerAgentManagerError(`Failed to create connection: ${errorMessage}`);
}
}
@@ -145,14 +143,14 @@ export class ServerAgentManager {
/**
* Execute a transaction on a document
* @param {string} documentId - The document ID
* @param {(doc: Y.Doc) => void | Promise<void>} transactionFn - The transaction function
* @param {(doc: 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>,
transactionFn: (_doc: Doc) => void | Promise<void>,
context: Partial<HocusPocusServerContext>,
res?: Response
): Promise<boolean> {
@@ -169,14 +167,11 @@ export class ServerAgentManager {
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
manualLogger.error(
{
documentId,
error: error instanceof Error ? error.stack : errorMessage,
context,
},
`Transaction error for document ${documentId}: ${errorMessage}`
);
logger.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);
@@ -205,13 +200,9 @@ export class ServerAgentManager {
if (!context.documentType) {
return;
}
const documentHandler = getDocumentHandler(context.documentType);
if (documentHandler && documentHandler.fetchSubPages) {
subPagesFromBackend = await documentHandler.fetchSubPages({
context,
pageId,
});
}
const service = getPageService(context.documentType, context);
subPagesFromBackend = await service.fetchSubPageDetails(pageId);
// Process the document using our extensible system
DocumentProcessor.process({
@@ -242,10 +233,9 @@ export class ServerAgentManager {
if (!document) return;
try {
manualLogger.info(`notified transaction success for ${documentId}:`);
// res.status(200).json({ success: true });
logger.info(`notified transaction success for ${documentId}:`);
} catch (error) {
manualLogger.error(error, `Error notifying transaction success for ${documentId}:`);
logger.error(`Error notifying transaction success for ${documentId}:`, error);
}
}
@@ -272,7 +262,7 @@ export class ServerAgentManager {
// })
// );
} catch (error) {
manualLogger.error(error, `Error notifying transaction failure for ${documentId}:`);
logger.error(`Error notifying transaction failure for ${documentId}:`, error);
}
}
@@ -291,10 +281,10 @@ export class ServerAgentManager {
try {
connectionData.connection.disconnect();
this.connections.delete(documentId);
manualLogger.info(`Released connection for document: ${documentId}`);
logger.info(`Released connection for document: ${documentId}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
manualLogger.error(error, `Error releasing connection for document ${documentId}: ${errorMessage}`);
logger.error(`Error releasing connection for document ${documentId}: ${errorMessage}`, error);
// We still want to remove it from our map even if disconnect fails
this.connections.delete(documentId);
}
@@ -334,7 +324,7 @@ export class ServerAgentManager {
this.cleanupInterval = setInterval(() => {
this.cleanupUnusedConnections().catch((error) => {
manualLogger.error(error, "Error during connection cleanup:");
logger.error("Error during connection cleanup:", error);
});
}, CLEANUP_INTERVAL);
}
@@ -355,12 +345,12 @@ export class ServerAgentManager {
// 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}`);
logger.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`);
logger.info(`Cleaned up ${documentsToCleanup.length} connections with no clients`);
}
}
@@ -375,7 +365,7 @@ export class ServerAgentManager {
}
if (!this.hasClientConnections(documentId)) {
manualLogger.info(`No clients left for document ${documentId}, releasing agent connection`);
logger.info(`No clients left for document ${documentId}, releasing agent connection`);
await this.releaseConnection(documentId);
return true;
}
@@ -400,7 +390,7 @@ export class ServerAgentManager {
try {
await serverAgentManager.checkAndReleaseIfNoClients(documentName);
} catch (error) {
manualLogger.error(error, `Error checking client connections for ${documentName}:`);
logger.error(`Error checking client connections for ${documentName}:`, error);
}
}, 100); // Small delay to ensure connection state is updated
},
@@ -441,7 +431,7 @@ export class ServerAgentManager {
await this.releaseConnection(documentId);
}
manualLogger.info("ServerAgentManager shut down successfully");
logger.info("ServerAgentManager shut down successfully");
}
}
@@ -1,96 +0,0 @@
import type { Hocuspocus } from "@hocuspocus/server";
import type { Request } from "express";
import type { WebSocket as WS } from "ws";
import { Controller, WebSocket } from "@plane/decorators";
import { logger } from "@plane/logger";
import Errors from "@/core/helpers/error-handling/error-factory";
import { ErrorCategory } from "@/core/helpers/error-handling/error-handler";
@Controller("/collaboration")
export class CollaborationController {
private metrics = {
errors: 0,
};
constructor(private readonly hocusPocusServer: Hocuspocus) {}
@WebSocket("/")
handleConnection(ws: WS, req: Request) {
const clientInfo = {
ip: req.ip,
userAgent: req.get("user-agent"),
requestId: req.id || crypto.randomUUID(),
};
try {
// Initialize the connection with Hocuspocus
this.hocusPocusServer.handleConnection(ws, req);
// Set up error handling for the connection
ws.on("error", (error) => {
this.handleConnectionError(error, clientInfo, ws);
});
} catch (error) {
this.handleConnectionError(error, clientInfo, ws);
}
}
private handleConnectionError(error: unknown, clientInfo: Record<string, any>, ws: WS) {
// Convert to AppError if needed
const appError = Errors.convertError(error instanceof Error ? error : new Error(String(error)), {
context: {
...clientInfo,
component: "WebSocketConnection",
},
});
// Log at appropriate level based on error category
if (appError.category === ErrorCategory.OPERATIONAL) {
logger.info(`WebSocket operational error: ${appError.message}`, {
error: appError,
clientInfo,
});
} else {
logger.error(`WebSocket error: ${appError.message}`, {
error: appError,
clientInfo,
stack: appError.stack,
});
}
// Alert if error threshold is reached
if (this.metrics.errors % 10 === 0) {
logger.warn(`High WebSocket error rate detected: ${this.metrics.errors} total errors`);
}
// Try to send error to client before closing
try {
if (ws.readyState === ws.OPEN) {
ws.send(
JSON.stringify({
type: "error",
message: appError.category === ErrorCategory.OPERATIONAL ? appError.message : "Internal server error",
})
);
}
} catch (_sendError) {
// Ignore send errors at this point
}
// Close with informative message if connection is still open
if (ws.readyState === ws.OPEN) {
ws.close(
1011,
appError.category === ErrorCategory.OPERATIONAL
? `Error: ${appError.message}. Reconnect with exponential backoff.`
: "Internal server error. Please retry in a few moments."
);
}
}
getErrorMetrics() {
return {
errors: this.metrics.errors,
};
}
}
@@ -1,125 +0,0 @@
import type { Request, Response } from "express";
import { z } from "zod";
// helpers
import { Controller, Post } from "@plane/decorators";
import { convertHTMLDocumentToAllFormats } from "@plane/editor";
// logger
import { logger } from "@plane/logger";
import { handleError } from "@/core/helpers/error-handling/error-factory";
// types
import { AppError } from "@/core/helpers/error-handling/error-handler";
import { TConvertDocumentRequestBody } from "@/core/types/common";
// Define the schema with more robust validation
const convertDocumentSchema = z.object({
description_html: z
.string()
.min(1, "HTML content cannot be empty")
.refine((html) => html.trim().length > 0, "HTML content cannot be just whitespace")
.refine((html) => html.includes("<") && html.includes(">"), "Content must be valid HTML"),
variant: z.enum(["rich", "document"]),
});
@Controller("/convert-document")
export class DocumentController {
private metrics = {
conversions: 0,
errors: 0,
};
@Post("/")
async convertDocument(req: Request, res: Response) {
const requestId = req.id || crypto.randomUUID();
const clientInfo = {
ip: req.ip,
userAgent: req.get("user-agent"),
requestId,
};
try {
// Validate request body
const validatedData = convertDocumentSchema.parse(req.body as TConvertDocumentRequestBody);
const { description_html, variant } = validatedData;
// Log validated data
logger.info("Validated document conversion request", {
...clientInfo,
variant,
contentLength: description_html.length,
});
// Process document conversion
const { description, description_binary } = convertHTMLDocumentToAllFormats({
document_html: description_html,
variant,
});
// Update metrics
this.metrics.conversions++;
// Log successful conversion
logger.info("Document conversion successful", {
...clientInfo,
variant,
outputLength: description_html.length,
});
// Return successful response
res.status(200).json({
description,
description_binary,
});
} catch (error) {
// Update error metrics
this.metrics.errors++;
let appError: AppError;
if (error instanceof z.ZodError) {
// Handle validation errors
appError = handleError(error, {
errorType: "unprocessable-entity",
message: "Invalid request data",
component: "document-conversion-controller",
operation: "convertDocument",
extraContext: {
...clientInfo,
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: "document-conversion-controller",
operation: "convertDocument",
extraContext: clientInfo,
});
}
// Log the error
logger.error("Document conversion failed", {
error: appError,
status: appError.status,
context: appError.context,
});
res.status(appError.status).json({
message: appError.message,
status: appError.status,
context: appError.context,
});
}
}
getMetrics() {
return {
conversions: this.metrics.conversions,
errors: this.metrics.errors,
};
}
}
-1
View File
@@ -1 +0,0 @@
export * from "./register";
@@ -1,26 +0,0 @@
import { BasePageHandler } from "@/core/document-types/base-page/handlers";
import { ProjectPageService } from "@/core/services/project-page.service";
import { HocusPocusServerContext } from "@/core/types/common";
export interface ProjectPageConfig {
projectId: string | undefined;
workspaceSlug: string | undefined;
}
const projectPageService = new ProjectPageService();
export class ProjectPageHandler extends BasePageHandler<ProjectPageService, ProjectPageConfig> {
protected documentType = "project_page";
constructor() {
super(projectPageService);
}
protected getConfig(context: HocusPocusServerContext): ProjectPageConfig {
return {
projectId: context.projectId,
workspaceSlug: context.workspaceSlug,
};
}
}
export const projectPageHandler = new ProjectPageHandler();
@@ -1,5 +0,0 @@
import { projectPageHandler } from "./project-page-handler";
export function initializeDocumentHandlers() {
projectPageHandler.register();
}
-1
View File
@@ -1 +0,0 @@
export type TAdditionalDocumentTypes = string;
-77
View File
@@ -1,77 +0,0 @@
import compression from "compression";
import cookieParser from "cookie-parser";
import cors from "cors";
import express from "express";
import helmet from "helmet";
import { logger } from "@plane/logger";
import { logger as loggerMiddleware } from "@/core/helpers/logger";
import { env } from "@/env";
/**
* Configure server middleware
* @param app Express application
*/
export function configureServerMiddleware(app: express.Application): void {
// Security middleware
app.use(helmet());
// CORS configuration
configureCors(app);
// Compression middleware
app.use(
compression({
level: env.COMPRESSION_LEVEL,
threshold: env.COMPRESSION_THRESHOLD,
}) as unknown as express.RequestHandler
);
// Cookie parsing
app.use(cookieParser());
// Logging middleware
app.use(loggerMiddleware);
// Body parsing middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
}
/**
* Configure CORS
* @param app Express application
*/
function configureCors(app: express.Application): void {
const corsOrigins = env.CORS_ALLOWED_ORIGINS;
if (corsOrigins === "*") {
logger.info("Enabling CORS for all origins");
app.use(
cors({
origin: true,
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "x-api-key"],
})
);
} else {
const origins = corsOrigins?.split(",").map((origin) => origin.trim()) || [];
logger.info(`Enabling CORS for specific origins: ${origins.join(", ")}`);
app.use(
cors({
origin: origins,
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "x-api-key"],
})
);
}
}
/**
* Server configuration
*/
export const serverConfig = {
port: env.PORT,
basePath: env.LIVE_BASE_PATH,
terminationTimeout: env.SHUTDOWN_TIMEOUT,
};
@@ -8,15 +8,10 @@ import {
createRealtimeEvent,
type TDocumentEventsClient,
} from "@plane/editor/lib";
// decorators
// utilities
import { serverAgentManager } from "@/core/agents/server-agent";
import { manualLogger } from "@/core/helpers/logger";
import { findAllElementsRecursive, insertNodeAfter, deleteNode } from "@/core/utilities/xml-tree-utils";
// logger
// agents
// broadcast
import { broadcastMessageToPage } from "@/ee/lib/utils/broadcast-message";
import { logger } from "@plane/logger";
import { serverAgentManager } from "@/agents/server-agent";
import { findAllElementsRecursive, insertNodeAfter, deleteNode } from "@/utils";
import { broadcastMessageToPage } from "@/utils/broadcast-message";
// (Optional) additional types used in your controller
interface ConnectionContext {
@@ -81,7 +76,7 @@ export class BroadcastController {
this.handleSubPage(payload, connectionContext);
}
} catch (error) {
manualLogger.error(error, "Error in broadcast handler:");
logger.error("Error in broadcast handler:", error);
if (!res.headersSent) {
res.status(500).json({ error: "Internal server error" });
}
@@ -110,7 +105,7 @@ export class BroadcastController {
try {
broadcastMessageToPage(serverAgentManager, pageId, metadata);
} catch (error) {
manualLogger.error(error, `Error broadcasting to page ${pageId}:`);
logger.error(`Error broadcasting to page ${pageId}:`, error);
}
});
}
@@ -169,7 +164,7 @@ export class BroadcastController {
{ workspaceSlug: context.workspaceSlug || "" }
)
.catch((error) => {
manualLogger.error(error, "Error handling duplicated action:");
logger.error(error, "Error handling duplicated action:");
});
}
@@ -208,7 +203,7 @@ export class BroadcastController {
{ workspaceSlug: context.workspaceSlug || "" }
)
.catch((error) => {
manualLogger.error(error, "Error handling deleted action:");
logger.error(error, "Error handling deleted action:");
});
}
@@ -0,0 +1,33 @@
import type { Hocuspocus } from "@hocuspocus/server";
import type { Request } from "express";
import type WebSocket from "ws";
// plane imports
import { Controller, WebSocket as WSDecorator } from "@plane/decorators";
import { logger } from "@plane/logger";
@Controller("/collaboration")
export class CollaborationController {
[key: string]: unknown;
private readonly hocusPocusServer: Hocuspocus;
constructor(hocusPocusServer: Hocuspocus) {
this.hocusPocusServer = hocusPocusServer;
}
@WSDecorator("/")
handleConnection(ws: WebSocket, req: Request) {
try {
// Initialize the connection with Hocuspocus
this.hocusPocusServer.handleConnection(ws, req);
// Set up error handling for the connection
ws.on("error", (error: Error) => {
logger.error("WebSocket connection error:", error);
ws.close(1011, "Internal server error");
});
} catch (error) {
logger.error("WebSocket connection error:", error);
ws.close(1011, "Internal server error");
}
}
}
@@ -1,7 +1,7 @@
import type { Request, Response } from "express";
import { Controller, Get } from "@plane/decorators";
// services
import { ContentAPI } from "@/ee/services/content.service";
import { ContentAPI } from "@/services/content.service";
// Error interface for better type safety
type APIError = {
@@ -0,0 +1,37 @@
import type { Request, Response } from "express";
// plane imports
import { Controller, Post } from "@plane/decorators";
// utils
import { convertHTMLDocumentToAllFormats } from "@plane/editor";
import { logger } from "@plane/logger";
// types
import type { TConvertDocumentRequestBody } from "@/types";
@Controller("/convert-document")
export class ConvertDocumentController {
@Post("/")
handleConvertDocument(req: Request, res: Response) {
const { description_html, variant } = req.body as TConvertDocumentRequestBody;
try {
if (typeof description_html !== "string" || variant === undefined) {
res.status(400).json({
message: "Missing required fields",
});
return;
}
const { description, description_binary } = convertHTMLDocumentToAllFormats({
document_html: description_html,
variant,
});
res.status(200).json({
description,
description_binary,
});
} catch (error) {
logger.error("Error in /convert-document endpoint:", error);
res.status(500).json({
message: `Internal server error.`,
});
}
}
}
@@ -0,0 +1,63 @@
import type { Request, Response } from "express";
import { z } from "zod";
// helpers
import { Controller, Post } from "@plane/decorators";
import { convertHTMLDocumentToAllFormats } from "@plane/editor";
// logger
import { logger } from "@plane/logger";
import { type TConvertDocumentRequestBody } from "@/types";
// Define the schema with more robust validation
const convertDocumentSchema = z.object({
description_html: z
.string()
.min(1, "HTML content cannot be empty")
.refine((html) => html.trim().length > 0, "HTML content cannot be just whitespace")
.refine((html) => html.includes("<") && html.includes(">"), "Content must be valid HTML"),
variant: z.enum(["rich", "document"]),
});
@Controller("/convert-document")
export class DocumentController {
@Post("/")
async convertDocument(req: Request, res: Response) {
try {
// Validate request body
const validatedData = convertDocumentSchema.parse(req.body as TConvertDocumentRequestBody);
const { description_html, variant } = validatedData;
// Process document conversion
const { description, description_binary } = convertHTMLDocumentToAllFormats({
document_html: description_html,
variant,
});
// Return successful response
res.status(200).json({
description,
description_binary,
});
} catch (error) {
if (error instanceof z.ZodError) {
const validationErrors = error.errors.map((err) => ({
path: err.path.join("."),
message: err.message,
}));
logger.error("Error in /convert-document endpoint: Validation error", {
validationErrors,
});
return res.status(500).json({
message: `Internal server error.`,
context: {
validationErrors,
},
});
} else {
logger.error("Error in /convert-document endpoint:", error);
return res.status(500).json({
message: `Internal server error.`,
});
}
}
}
}
@@ -1,5 +1,6 @@
import type { Request, Response } from "express";
import { Controller, Get } from "@plane/decorators";
import { env } from "@/env";
@Controller("/health")
export class HealthController {
@@ -8,7 +9,7 @@ export class HealthController {
res.status(200).json({
status: "OK",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION || "1.0.0",
version: env.APP_VERSION,
});
}
}
@@ -2,10 +2,10 @@ import axios from "axios";
import type { Request, Response } from "express";
import { Controller, Get } from "@plane/decorators";
// services
import { handleAuthentication } from "@/core/lib/authentication";
import { IframelyAPI } from "@/ee/services/iframely.service";
// helpers
import { env } from "@/env";
import { handleAuthentication } from "@/lib/auth";
import { IframelyAPI } from "@/services/iframely.service";
@Controller("/iframely")
export class IframelyController {
@@ -56,7 +56,6 @@ export class IframelyController {
await handleAuthentication({
cookie,
userId,
workspaceSlug,
});
} catch (_error) {
// handleAuthentication throws errors for unauthorized access
@@ -1,16 +1,18 @@
// Export all controllers from this barrel file
import { BroadcastController } from "./broadcast.controller";
import { CollaborationController } from "./collaboration.controller";
import { ConvertDocumentController } from "./convert-document.controller";
import { DocumentController } from "./document.controller";
import { HealthController } from "./health.controller";
import { LiveDocumentController } from "./live-document.controller";
export const CONTROLLERS = {
export const CONTROLLERS = [
// Core system controllers (health checks, status endpoints)
CORE: [HealthController],
HealthController,
// Document management controllers
DOCUMENT: [DocumentController, LiveDocumentController],
// WebSocket controllers for real-time functionality
WEBSOCKET: [CollaborationController],
};
DocumentController,
LiveDocumentController,
ConvertDocumentController,
// websocket
CollaborationController,
BroadcastController,
];
@@ -5,14 +5,12 @@ import { Controller, Get } from "@plane/decorators";
// Server agent
import { getAllDocumentFormatsFromDocumentEditorBinaryData } from "@plane/editor/lib";
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 { manualLogger } from "@/core/helpers/logger";
import { HocusPocusServerContext } from "@/core/types/common";
import { logger } from "@plane/logger";
import { serverAgentManager } from "@/agents/server-agent";
import { env } from "@/env";
import { HocusPocusServerContext } from "@/types";
// Types
@@ -27,8 +25,6 @@ const getLiveDocumentValuesSchema = z.object({
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({
@@ -37,7 +33,6 @@ export class LiveDocumentController {
context: {
component: "get-live-document-values-controller",
operation: "getLiveDocumentValues",
requestId,
},
});
}
@@ -100,7 +95,7 @@ export class LiveDocumentController {
res.status(200).json(documentLoaded);
} catch (error) {
// Error during server agent connection or conversion
manualLogger.error(error, `Error processing document ${documentId}:`);
logger.error(`Error processing document ${documentId}:`, error);
res.status(400).json({
loaded: false,
@@ -108,39 +103,27 @@ export class LiveDocumentController {
});
}
} 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,
})),
const validationErrors = error.errors.map((err) => ({
path: err.path.join("."),
message: err.message,
}));
logger.error("Validation error", {
validationErrors,
});
return res.status(500).json({
message: `Internal server error.`,
context: {
validationErrors,
},
});
} else {
logger.error("Error in /live-document endpoint:", error);
// Handle other errors
appError = handleError(error, {
errorType: "internal",
message: "Internal server error",
component: "get-live-document-values-controller",
operation: "getLiveDocumentValues",
extraContext: { requestId },
return res.status(500).json({
message: `Internal server error.`,
});
}
res.status(appError.status).json({
message: appError.message,
status: appError.status,
context: appError.context,
});
}
}
}
@@ -1,4 +0,0 @@
import { CONTROLLERS } from "@/plane-live/controllers";
// Helper to get all REST controllers
export const getAllControllers: any = () => [...CONTROLLERS.CORE, ...CONTROLLERS.DOCUMENT, ...CONTROLLERS.WEBSOCKET];
@@ -1,235 +0,0 @@
import {
getAllDocumentFormatsFromDocumentEditorBinaryData,
getBinaryDataFromDocumentEditorHTMLString,
} from "@plane/editor/lib";
import { logger } from "@plane/logger";
import { TPage } from "@plane/types";
import { handlerFactory } from "@/core/handlers/page-handlers/handler-factory";
import { BasePageService } from "@/core/services/base-page.service";
import { HocusPocusServerContext } from "@/core/types/common";
import { DocumentHandler, HandlerDefinition } from "@/core/types/document-handler";
/**
* 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 } = getAllDocumentFormatsFromDocumentEditorBinaryData(
updatedDescription,
true
);
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 updatePageProperties({
context,
pageId,
data,
abortSignal,
}: {
pageId: string;
data: Partial<TPage>;
abortSignal?: AbortSignal;
context: HocusPocusServerContext;
}): Promise<void> {
const { cookie } = context;
const config = this.getConfig(context);
if (!pageId) return;
await this.service.updatePageProperties({
config,
pageId,
data,
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 = getBinaryDataFromDocumentEditorHTMLString(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),
updatePageProperties: this.updatePageProperties.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);
}
}
@@ -1,21 +0,0 @@
import { BasePageHandler } from "@/core/document-types/base-page/handlers";
import { ServerAgentService } from "@/core/services/server-agent.service";
import { HocusPocusServerContext } from "@/core/types/common";
type ServerAgentConfig = Record<string, unknown>;
const serverAgentService = new ServerAgentService();
export class ServerAgentHandler extends BasePageHandler<ServerAgentService, ServerAgentConfig> {
protected documentType = "server_agent";
constructor() {
super(serverAgentService);
}
protected getConfig(_context: HocusPocusServerContext): ServerAgentConfig {
return {};
}
}
export const serverAgentHandler = new ServerAgentHandler();
@@ -1,21 +0,0 @@
import { BasePageHandler } from "@/core/document-types/base-page/handlers";
import { SyncAgentService } from "@/core/services/sync-agent.service";
import { HocusPocusServerContext } from "@/core/types/common";
type SyncAgentConfig = Record<string, unknown>;
const syncAgentService = new SyncAgentService();
export class SyncAgentHandler extends BasePageHandler<SyncAgentService, SyncAgentConfig> {
protected documentType = "sync_agent";
constructor() {
super(syncAgentService);
}
protected getConfig(_context: HocusPocusServerContext): SyncAgentConfig {
return {};
}
}
export const syncAgentHandler = new SyncAgentHandler();
-131
View File
@@ -1,131 +0,0 @@
import { Database } from "@hocuspocus/extension-database";
import { storePayload } from "@hocuspocus/server";
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { catchAsync } from "@/core/helpers/error-handling/error-handler";
import { type HocusPocusServerContext, type TDocumentTypes } from "@/core/types/common";
import { getDocumentHandler } from "../handlers/page-handlers";
import { extractTextFromHTML } from "./title-update/title-utils";
export const createDatabaseExtension = () => {
return new Database({
fetch: handleFetch,
store: handleStore as (data: storePayload) => Promise<void>,
});
};
const handleFetch = async ({
context,
documentName,
}: {
context: HocusPocusServerContext;
documentName: string;
requestParameters: URLSearchParams;
}) => {
const { documentType } = context;
const pageId = documentName as TDocumentTypes;
let fetchedData = null;
fetchedData = await catchAsync(
async () => {
if (!documentType) {
handleError(null, {
errorType: "bad-request",
message: "Document type is required",
component: "database-extension",
operation: "fetch",
extraContext: { pageId },
throw: true,
});
}
const documentHandler = getDocumentHandler(documentType);
fetchedData = await documentHandler.fetch({
context: context as HocusPocusServerContext,
pageId,
});
if (!fetchedData) {
handleError(null, {
errorType: "not-found",
message: `Failed to fetch document: ${pageId}`,
component: "database-extension",
operation: "fetch",
extraContext: { documentType, pageId },
throw: true,
});
}
return fetchedData;
},
{
params: { pageId, documentType: context.documentType },
extra: { operation: "fetch" },
},
{ rethrow: true }
)();
return fetchedData;
};
const handleStore = async ({
context,
state,
documentName,
document,
}: Partial<storePayload> & {
context: HocusPocusServerContext;
documentName: string;
}) => {
const pageId = documentName;
if (!context) {
console.error("Context is undefined in handleStore for document:", pageId);
return;
}
await catchAsync(
async () => {
if (!state) {
handleError(null, {
errorType: "bad-request",
message: "Loaded binary state is required",
component: "database-extension",
operation: "store",
extraContext: { pageId },
throw: true,
});
}
let title = "";
if (document) {
title = extractTextFromHTML(document?.getXmlFragment("title")?.toJSON());
}
const { documentType } = context as HocusPocusServerContext;
if (!documentType) {
handleError(null, {
errorType: "bad-request",
message: "Document type is required",
component: "database-extension",
operation: "store",
extraContext: { pageId },
throw: true,
});
}
const documentHandler = getDocumentHandler(documentType);
await documentHandler.store({
context: context as HocusPocusServerContext,
pageId,
state,
title,
});
},
{
params: {
pageId,
documentType: context?.documentType || "unknown",
},
extra: { operation: "store" },
},
{ rethrow: true }
)();
};
-31
View File
@@ -1,31 +0,0 @@
// hocuspocus extensions and core
import { Logger } from "@hocuspocus/extension-logger";
import { Extension } from "@hocuspocus/server";
// plane logger
import { logger } from "@plane/logger";
// extensions
import { createDatabaseExtension } from "@/core/extensions/database";
import { setupRedisExtension } from "@/core/extensions/setup-redis";
// title sync extension
import { TitleSyncExtension } from "./title-sync";
export const getExtensions = async (): Promise<Extension[]> => {
const extensions: Extension[] = [
new Logger({
onChange: false,
log: (message) => {
logger.info(message);
},
}),
createDatabaseExtension(),
new TitleSyncExtension(),
];
// Add Redis extensions if Redis is available
const redisExtensions = await setupRedisExtension();
if (redisExtensions) {
extensions.push(redisExtensions);
}
return extensions;
};
@@ -1,32 +0,0 @@
// core helpers and utilities
import { RedisManager } from "@/core/lib/redis-manager";
import { shutdownManager } from "../shutdown-manager";
import { CustomHocuspocusRedisExtension } from "./redis";
/**
* 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();
};
@@ -1,29 +0,0 @@
import { Request, Response } from "express";
import { convertHTMLDocumentToAllFormats } from "@plane/editor";
import { manualLogger } from "@/core/helpers/logger";
import { TConvertDocumentRequestBody } from "@/core/types/common";
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, "Error in /convert-document endpoint:");
res.status(500).send({
message: `Internal server error. ${error}`,
});
}
};
-1
View File
@@ -1 +0,0 @@
export { handleConvertDocument } from "./convert-document.handler";
@@ -1,34 +0,0 @@
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
*/
register(definition: HandlerDefinition): void {
this.handlers.push(definition);
// 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: Partial<HocusPocusServerContext>): DocumentHandler {
// Find the first handler whose selector returns true
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;
}
}
// Create the singleton instance
export const handlerFactory = new DocumentHandlerFactory();
@@ -1,26 +0,0 @@
import { handlerFactory } from "@/core/handlers/page-handlers/handler-factory";
import { HocusPocusServerContext, TDocumentTypes } from "@/core/types/common";
import { DocumentHandler } from "@/core/types/document-handler";
/**
* Get a document handler based on the provided context criteria
* @param documentType The primary document type
* @param additionalContext Optional additional context criteria
* @returns The appropriate document handler
*/
export function getDocumentHandler(
documentType: TDocumentTypes,
additionalContext?: Omit<HocusPocusServerContext, "documentType">
): DocumentHandler {
// Create a context object with all criteria
const context: Partial<HocusPocusServerContext> = {
documentType: documentType,
...additionalContext,
};
// Use the factory to get the appropriate handler
return handlerFactory.getHandler(context);
}
// Export the factory for direct access if needed
export { handlerFactory };
@@ -1,276 +0,0 @@
import { AppError, HttpStatusCode, ErrorCategory } from "./error-handler";
/**
* Map of error types to their corresponding factory functions
* This ensures that error types and their implementations stay in sync
*/
interface ErrorFactory {
statusCode: number;
category: ErrorCategory;
defaultMessage: string;
createError: (message?: string, context?: Record<string, any>) => AppError;
}
const ERROR_FACTORIES = {
"bad-request": {
statusCode: HttpStatusCode.BAD_REQUEST,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Bad Request",
createError: (message = "Bad Request", context?) =>
new AppError(message, HttpStatusCode.BAD_REQUEST, ErrorCategory.OPERATIONAL, context),
},
unauthorized: {
statusCode: HttpStatusCode.UNAUTHORIZED,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Unauthorized",
createError: (message = "Unauthorized", context?) =>
new AppError(message, HttpStatusCode.UNAUTHORIZED, ErrorCategory.OPERATIONAL, context),
},
forbidden: {
statusCode: HttpStatusCode.FORBIDDEN,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Forbidden",
createError: (message = "Forbidden", context?) =>
new AppError(message, HttpStatusCode.FORBIDDEN, ErrorCategory.OPERATIONAL, context),
},
"not-found": {
statusCode: HttpStatusCode.NOT_FOUND,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Resource not found",
createError: (message = "Resource not found", context?) =>
new AppError(message, HttpStatusCode.NOT_FOUND, ErrorCategory.OPERATIONAL, context),
},
conflict: {
statusCode: HttpStatusCode.CONFLICT,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Resource conflict",
createError: (message = "Resource conflict", context?) =>
new AppError(message, HttpStatusCode.CONFLICT, ErrorCategory.OPERATIONAL, context),
},
"unprocessable-entity": {
statusCode: HttpStatusCode.UNPROCESSABLE_ENTITY,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Unprocessable Entity",
createError: (message = "Unprocessable Entity", context?) =>
new AppError(message, HttpStatusCode.UNPROCESSABLE_ENTITY, ErrorCategory.OPERATIONAL, context),
},
"too-many-requests": {
statusCode: HttpStatusCode.TOO_MANY_REQUESTS,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Too many requests",
createError: (message = "Too many requests", context?) =>
new AppError(message, HttpStatusCode.TOO_MANY_REQUESTS, ErrorCategory.OPERATIONAL, context),
},
internal: {
statusCode: HttpStatusCode.INTERNAL_SERVER,
category: ErrorCategory.PROGRAMMING,
defaultMessage: "Internal Server Error",
createError: (message = "Internal Server Error", context?) =>
new AppError(message, HttpStatusCode.INTERNAL_SERVER, ErrorCategory.PROGRAMMING, context),
},
"service-unavailable": {
statusCode: HttpStatusCode.SERVICE_UNAVAILABLE,
category: ErrorCategory.SYSTEM,
defaultMessage: "Service Unavailable",
createError: (message = "Service Unavailable", context?) =>
new AppError(message, HttpStatusCode.SERVICE_UNAVAILABLE, ErrorCategory.SYSTEM, context),
},
fatal: {
statusCode: HttpStatusCode.INTERNAL_SERVER,
category: ErrorCategory.FATAL,
defaultMessage: "Fatal Error",
createError: (message = "Fatal Error", context?) =>
new AppError(message, HttpStatusCode.INTERNAL_SERVER, ErrorCategory.FATAL, context),
},
} satisfies Record<string, ErrorFactory>;
// Create the type from the keys of the error factories map
export type ErrorType = keyof typeof ERROR_FACTORIES;
// -------------------------------------------------------------------------
// Primary public API - Recommended for most use cases
// -------------------------------------------------------------------------
/**
* Base options for handleError function
*/
type BaseErrorHandlerOptions = {
// Error classification options
errorType?: ErrorType;
message?: string;
// Context information
component: string;
operation: string;
extraContext?: Record<string, any>;
// Behavior options
rethrowIfAppError?: boolean;
};
/**
* Options for throwing variant of handleError - discriminated by throw: true
*/
export type ThrowingOptions = BaseErrorHandlerOptions & {
throw: true;
};
/**
* Options for non-throwing variant of handleError - default behavior
*/
export type NonThrowingOptions = BaseErrorHandlerOptions;
/**
* Unified error handler that encapsulates common error handling patterns
*
* @param error The error to handle
* @param options Configuration options with throw: true to throw the error instead of returning it
* @returns Never returns - always throws
* @example
* // Throwing version
* handleError(error, {
* errorType: 'not-found',
* component: 'user-service',
* operation: 'getUserById',
* throw: true
* });
*/
export function handleError(error: unknown, options: ThrowingOptions): never;
/**
* Unified error handler that encapsulates common error handling patterns
*
* @param error The error to handle
* @param options Configuration options (non-throwing by default)
* @returns The AppError instance
* @example
* // Non-throwing version (default)
* const appError = handleError(error, {
* errorType: 'not-found',
* component: 'user-service',
* operation: 'getUserById'
* });
* return { error: appError.output() };
*/
export function handleError(error: unknown, options: NonThrowingOptions): AppError;
/**
* Implementation of handleError that handles both throwing and non-throwing cases
*/
export function handleError(error: unknown, options: ThrowingOptions | NonThrowingOptions): AppError | never {
// Only throw if throw is explicitly true
const shouldThrow = (options as ThrowingOptions).throw === true;
// If the error is already an AppError and we want to rethrow it as is
if (options.rethrowIfAppError !== false && error instanceof AppError) {
if (shouldThrow) {
throw error;
}
return error;
}
// Format the error message
const errorMessage = options.message
? error instanceof Error
? `${options.message}: ${error.message}`
: error
? `${options.message}: ${String(error)}`
: options.message
: error instanceof Error
? error.message
: error
? String(error)
: "Unknown error occurred";
// Build context object
const context = {
component: options.component,
operation: options.operation,
originalError: error,
...(options.extraContext || {}),
};
// Create the appropriate error type using our factory map
const errorType = options.errorType || "internal";
const factory = ERROR_FACTORIES[errorType];
if (!factory) {
// If no factory found, default to internal error
return ERROR_FACTORIES.internal.createError(errorMessage, context);
}
// Create the error with the factory
const appError = factory.createError(errorMessage, context);
// If we should throw, do so now
if (shouldThrow) {
throw appError;
}
return appError;
}
/**
* Utility function to convert errors or enhance existing AppErrors
*/
export const convertError = (
error: Error,
options?: {
statusCode?: number;
message?: string;
category?: ErrorCategory;
context?: Record<string, any>;
}
): AppError => {
if (error instanceof AppError) {
// If it's already an AppError and no overrides, return as is
if (!options?.statusCode && !options?.message && !options?.category) {
return error;
}
// Create a new AppError with the original as context
return new AppError(
options?.message || error.message,
options?.statusCode || error.status,
options?.category || error.category,
{
...(error.context || {}),
...(options?.context || {}),
originalError: error,
}
);
}
// Determine the appropriate error type based on status code
let errorType: ErrorType = "internal";
if (options?.statusCode) {
// Find the error type that matches the status code
const entry = Object.entries(ERROR_FACTORIES).find(([_, factory]) => factory.statusCode === options.statusCode);
if (entry) {
errorType = entry[0] as ErrorType;
}
}
// Return a new AppError using the factory
return handleError(error, {
errorType: errorType,
message: options?.message,
component: options?.context?.component || "unknown",
operation: options?.context?.operation || "convert-error",
extraContext: options?.context,
});
};
/**
* Check if an error is an AppError
*/
export const isAppError = (err: any, statusCode?: number): boolean => {
return err instanceof AppError && (!statusCode || err.status === statusCode);
};
// Export only the public API
export default {
handleError,
convertError,
isAppError,
};
@@ -1,392 +0,0 @@
import { ErrorRequestHandler, Request, Response, NextFunction } from "express";
import { logger } from "@plane/logger";
import { env } from "@/env";
import { manualLogger } from "../logger";
import { handleError } from "./error-factory";
import { ErrorContext, reportError } from "./error-reporting";
/**
* HTTP Status Codes
*/
export enum HttpStatusCode {
// 2xx Success
OK = 200,
CREATED = 201,
ACCEPTED = 202,
NO_CONTENT = 204,
// 4xx Client Errors
BAD_REQUEST = 400,
UNAUTHORIZED = 401,
FORBIDDEN = 403,
NOT_FOUND = 404,
METHOD_NOT_ALLOWED = 405,
CONFLICT = 409,
GONE = 410,
UNPROCESSABLE_ENTITY = 422,
TOO_MANY_REQUESTS = 429,
// 5xx Server Errors
INTERNAL_SERVER = 500,
NOT_IMPLEMENTED = 501,
BAD_GATEWAY = 502,
SERVICE_UNAVAILABLE = 503,
GATEWAY_TIMEOUT = 504,
}
/**
* Error categories to classify errors
*/
export enum ErrorCategory {
OPERATIONAL = "operational", // Expected errors that are part of normal operation (e.g. validation failures)
PROGRAMMING = "programming", // Unexpected errors that indicate bugs (e.g. null references)
SYSTEM = "system", // System errors (e.g. out of memory, connection failures)
FATAL = "fatal", // Severe errors that should crash the app (e.g. unrecoverable state)
}
/**
* Base Application Error Class
* All custom errors extend this class
*/
export class AppError extends Error {
readonly status: number;
readonly category: ErrorCategory;
readonly context?: Record<string, any>;
readonly isOperational: boolean; // Kept for backward compatibility
constructor(
message: string,
status: number = HttpStatusCode.INTERNAL_SERVER,
category: ErrorCategory = ErrorCategory.PROGRAMMING,
context?: Record<string, any>
) {
super(message);
// Set error properties
this.name = this.constructor.name;
this.status = status;
this.category = category;
this.isOperational = category === ErrorCategory.OPERATIONAL;
this.context = context;
// Capture stack trace, excluding the constructor call from the stack
Error.captureStackTrace(this, this.constructor);
// Automatically report the error (unless it's being constructed by the error utilities)
if (!context?.skipReporting) {
this.report();
}
}
/**
* Creates a formatted representation of the error
*/
output() {
return {
statusCode: this.status,
payload: {
statusCode: this.status,
error: this.getErrorName(),
message: this.message,
category: this.category,
},
headers: {},
};
}
/**
* Gets a descriptive name for the error based on status code
*/
private getErrorName(): string {
const statusCodes: Record<number, string> = {
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
409: "Conflict",
410: "Gone",
422: "Unprocessable Entity",
429: "Too Many Requests",
500: "Internal Server Error",
501: "Not Implemented",
502: "Bad Gateway",
503: "Service Unavailable",
504: "Gateway Timeout",
};
return statusCodes[this.status] || "Unknown Error";
}
/**
* Reports the error to logging and monitoring systems
*/
private report(): void {
// Different logging based on error category
if (this.category === ErrorCategory.OPERATIONAL) {
manualLogger.error(
{
errorName: this.name,
errorStatus: this.status,
errorCategory: this.category,
context: this.context,
},
`Operational error: ${this.message}`
);
} else if (this.category === ErrorCategory.FATAL) {
manualLogger.error(
{
errorName: this.name,
errorStatus: this.status,
errorCategory: this.category,
stack: this.stack,
context: this.context,
},
`FATAL error: ${this.message}`
);
} else {
manualLogger.error(
{
errorName: this.name,
errorStatus: this.status,
errorCategory: this.category,
stack: this.stack,
context: this.context,
},
`${this.category} error: ${this.message}`
);
}
}
}
export class FatalError extends AppError {
constructor(message: string, context?: Record<string, any>) {
super(message, HttpStatusCode.INTERNAL_SERVER, ErrorCategory.FATAL, context);
}
}
/**
* Main Express error handler middleware
*/
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
// Already sent response, let default Express error handler deal with it
if (res.headersSent) {
return next(err);
}
// Convert to AppError if it's not already one
const error = handleError(err, {
component: "express",
operation: "error-handler",
extraContext: {
originalError: err,
url: req.originalUrl,
method: req.method,
},
});
// Normalize status code
const statusCode = error.status;
// Set the response status
res.status(statusCode);
// Set any custom headers if provided in the error object
if (err.headers && typeof err.headers === "object") {
Object.entries(err.headers).forEach(([key, value]) => {
res.set(key, value as string);
});
}
// Prepare error response
const errorResponse: {
error: {
message: string;
status: number;
stack?: string;
};
} = {
error: {
message:
error.category === ErrorCategory.OPERATIONAL || env.NODE_ENV !== "production"
? error.message
: "An unexpected error occurred",
status: statusCode,
},
};
// Add stack trace in non-production environments
if (env.NODE_ENV !== "production") {
errorResponse.error.stack = error.stack;
}
// Send the response
res.json(errorResponse);
// For fatal errors, log but NEVER terminate the app
if (error.category === ErrorCategory.FATAL) {
logger.error(`FATAL ERROR OCCURRED BUT APP WILL CONTINUE RUNNING: ${error.message}`);
}
};
type AsyncRequestHandler = (req: Request, res: Response, next: NextFunction) => Promise<unknown>;
export const asyncHandler = (fn: AsyncRequestHandler) => {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch((error) => {
// Convert to AppError if needed and pass to Express error middleware
const appError = handleError(error, {
errorType: "internal",
component: "express",
operation: "route-handler",
extraContext: {
url: req.originalUrl,
method: req.method,
body: req.body,
query: req.query,
params: req.params,
},
});
next(appError);
});
};
};
export interface CatchAsyncOptions<T, E = Error> {
/** Default value to return in case of error, null by default */
defaultValue?: T | null;
/** Whether to report non-AppErrors automatically */
reportErrors?: boolean;
/** Whether to rethrow the error after handling it */
rethrow?: boolean;
/** Custom error transformer function */
transformError?: (error: unknown) => E;
/** Custom error handler function that runs before standard handling */
onError?: (error: unknown) => void | Promise<void>;
/** Custom handler for specific error types */
errorHandlers?: {
[key: string]: (error: any) => T | null | Promise<T>;
};
}
export const catchAsync = <T, E = Error>(
fn: () => Promise<T>,
context?: ErrorContext,
options: CatchAsyncOptions<T, E> = {}
): (() => Promise<T | null>) => {
const { defaultValue = null, onError, rethrow = false } = options;
return async () => {
try {
return await fn();
} catch (error) {
// Apply custom error handler if provided
if (onError) {
await Promise.resolve(onError(error));
}
reportError(error, context);
if (rethrow) {
// Use handleError to ensure consistent error handling when rethrowing
handleError(error, {
component: context?.extra?.component || "unknown",
operation: context?.extra?.operation || "unknown",
extraContext: {
...context,
...(error instanceof AppError ? error.context : {}),
originalError: error,
},
throw: true,
});
}
return defaultValue;
}
};
};
/**
* Set up global error handlers for uncaught exceptions and unhandled rejections
* @param gracefulTerminationHandler Function to call for graceful termination
*/
export const setupGlobalErrorHandlers = (gracefulTerminationHandler: () => Promise<void>): void => {
// Handle promise rejections
process.on("unhandledRejection", (reason: unknown) => {
logger.error("Unhandled Promise Rejection", { reason });
// Convert to AppError and handle
const appError = handleError(reason, {
errorType: "internal",
message: reason instanceof Error ? reason.message : String(reason),
component: "process",
operation: "unhandledRejection",
extraContext: { source: "unhandledRejection" },
});
// Log the error but never terminate
logger.error(`Unhandled rejection caught and contained: ${appError.message}`);
});
// Handle exceptions
process.on("uncaughtException", (error: Error) => {
logger.error("Uncaught Exception", {
error: error.message,
stack: error.stack,
});
// Convert to AppError if needed
const appError = handleError(error, {
errorType: "internal",
component: "process",
operation: "uncaughtException",
extraContext: {
source: "uncaughtException",
},
});
// Log the error but never terminate
logger.warn(`Uncaught exception contained: ${appError.message}`);
});
// Handle termination signals
process.on("SIGTERM", () => {
logger.info("SIGTERM received. Starting graceful termination...");
gracefulTerminationHandler();
});
process.on("SIGINT", () => {
logger.info("SIGINT received. Starting graceful termination...");
gracefulTerminationHandler();
});
};
/**
* Configure error handling middleware for the Express app
* @param app Express application instance
*/
export function configureErrorHandlers(app: any): void {
// Global error handling middleware
app.use(errorHandler);
// 404 handler must be last
app.use((_req: Request, _res: Response, next: NextFunction) => {
next(
handleError(null, {
errorType: "not-found",
message: "Resource not found",
component: "express",
operation: "route-handler",
extraContext: { path: _req.path },
throw: true,
})
);
});
}
@@ -1,45 +0,0 @@
import { logger } from "@plane/logger";
import { handleError } from "./error-factory";
import { AppError } from "./error-handler";
export interface ErrorContext {
url?: string;
method?: string;
body?: any;
query?: any;
params?: any;
extra?: Record<string, any>;
}
/**
* Utility function to report errors that aren't instances of AppError
* AppError instances automatically report themselves on creation
* Only use this for external errors that don't use our error system
*/
export const reportError = (error: Error | unknown, context?: ErrorContext): void => {
if (error instanceof AppError) {
// if it's an app error, don't report it as it's already been reported
return;
}
logger.error(`External error: ${error instanceof Error ? error.stack || error.message : String(error)}`, {
error,
context,
});
};
export const handleFatalError = (error: Error | unknown, context?: ErrorContext): void => {
// Convert to fatal AppError
const fatalError = handleError(error, {
errorType: "fatal",
message: error instanceof Error ? error.message : String(error),
component: context?.extra?.component || "system",
operation: context?.extra?.operation || "fatal-error-handler",
extraContext: {
...context,
originalError: error,
},
});
process.emit("uncaughtException", fatalError);
};
@@ -1,158 +0,0 @@
import { handleError } from "./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(null, {
errorType: "bad-request",
message: message || `${this.name} is required`,
component: "validator",
operation: "required",
throw: true,
});
}
return this;
}
/**
* Ensures a value is a string
*/
string(message?: string): Validator<T> {
if (typeof this.data !== "string") {
throw handleError(null, {
errorType: "bad-request",
message: message || `${this.name} must be a string`,
component: "validator",
operation: "string",
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(null, {
errorType: "bad-request",
message: message || `${this.name} cannot be empty`,
component: "validator",
operation: "notEmpty",
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(null, {
errorType: "bad-request",
message: message || `${this.name} must be a valid number`,
component: "validator",
operation: "number",
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(null, {
errorType: "bad-request",
message: message || `${this.name} must be a non-empty array`,
component: "validator",
operation: "nonEmptyArray",
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(null, {
errorType: "bad-request",
message: message || `${this.name} has an invalid format`,
component: "validator",
operation: "match",
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(null, {
errorType: "bad-request",
message: message || `${this.name} must be one of: ${allowedValues.join(", ")}`,
component: "validator",
operation: "oneOf",
throw: true,
});
}
return this;
}
/**
* Custom validation function
*/
custom(validationFn: (value: T) => boolean, message?: string): Validator<T> {
if (!validationFn(this.data)) {
throw handleError(null, {
errorType: "bad-request",
message: message || `${this.name} is invalid`,
component: "validator",
operation: "custom",
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;
-39
View File
@@ -1,39 +0,0 @@
import { pinoHttp } from "pino-http";
const transport = {
target: "pino-pretty",
options: {
colorize: true,
},
};
const hooks = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
logMethod(inputArgs: any, method: any): any {
if (inputArgs.length >= 2) {
const arg1 = inputArgs.shift();
const arg2 = inputArgs.shift();
return method.apply(this, [arg2, arg1, ...inputArgs]);
}
return method.apply(this, inputArgs);
},
};
export const logger = pinoHttp({
level: "info",
transport: transport,
hooks: hooks,
serializers: {
req(req) {
return `${req.method} ${req.url}`;
},
res(res) {
return `${res.statusCode} ${res?.statusMessage || ""}`;
},
responseTime(time) {
return `${time}ms`;
},
},
});
export const manualLogger: typeof logger.logger = logger.logger;
-20
View File
@@ -1,20 +0,0 @@
import { getSchema } from "@tiptap/core";
// plane editor
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
export const DOCUMENT_EDITOR_EXTENSIONS = [
...CoreEditorExtensionsWithoutProps,
...DocumentEditorExtensionsWithoutProps,
];
export const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
/**
* 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 || "";
};
-151
View File
@@ -1,151 +0,0 @@
import { IncomingHttpHeaders } from "http";
import { Server } from "@hocuspocus/server";
import { v4 as uuidv4 } from "uuid";
// editor types
import { EventToPayloadMap, TUserDetails, createRealtimeEvent } from "@plane/editor";
import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib";
// extensions
import { getExtensions } from "@/core/extensions/index";
// error handling
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { catchAsync } from "@/core/helpers/error-handling/error-handler";
// lib
import { handleAuthentication } from "@/core/lib/authentication";
// types
import { TDocumentTypes, type HocusPocusServerContext } from "@/core/types/common";
// server agent
import { serverAgentManager } from "./agents/server-agent";
export const getHocusPocusServer = async () => {
const extensions = await getExtensions();
const serverName = process.env.HOSTNAME || uuidv4();
const server = Server.configure({
name: serverName,
onAuthenticate: async ({
requestHeaders,
requestParameters,
context,
token,
}: {
requestHeaders: IncomingHttpHeaders;
context: HocusPocusServerContext;
requestParameters: URLSearchParams;
token: string;
}) => {
return catchAsync(
async () => {
let cookie: string | undefined = undefined;
let userId: string | undefined = undefined;
// 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) {
console.error("Token parsing failed, using request headers:", error);
} finally {
if (!cookie) {
cookie = requestHeaders.cookie?.toString();
}
}
if (!cookie || !userId) {
handleError(null, {
errorType: "unauthorized",
message: "Credentials not provided",
component: "hocuspocus",
operation: "authenticate",
extraContext: { tokenProvided: !!token },
throw: true,
});
}
context.documentType = requestParameters.get("documentType")?.toString() as TDocumentTypes;
context.cookie = cookie ?? requestParameters.get("cookie") ?? "";
context.userId = userId;
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,
userId: context.userId,
workspaceSlug: context.workspaceSlug,
});
},
{ extra: { operation: "authenticate" } },
{ rethrow: true }
)();
},
onStateless: async ({ payload, document, connection }) => {
return catchAsync(
async () => {
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: [...extensions],
debounce: 10000,
});
return server;
};
-52
View File
@@ -1,52 +0,0 @@
import { handleError } from "@/core/helpers/error-handling/error-factory";
// services
import { UserService } from "@/core/services/user.service";
const userService = new UserService();
type Props = {
cookie: string;
userId: string;
workspaceSlug: string;
};
export const handleAuthentication = async (props: Props) => {
const { cookie, userId, workspaceSlug } = props;
// fetch current user info
let response;
try {
response = await userService.currentUser(cookie);
} catch (error) {
handleError(error, {
errorType: "unauthorized",
message: "Failed to authenticate user",
component: "authentication",
operation: "fetch-current-user",
extraContext: {
userId,
workspaceSlug,
},
throw: true,
});
}
if (response.id !== userId) {
handleError(null, {
errorType: "unauthorized",
message: "Authentication failed: Token doesn't match the current user.",
component: "authentication",
operation: "validate-user",
extraContext: {
userId,
workspaceSlug,
},
throw: true,
});
}
return {
user: {
id: response.id,
name: response.display_name,
},
};
};
-135
View File
@@ -1,135 +0,0 @@
import { Redis } from "ioredis";
import { logger } from "@plane/logger";
import { getRedisUrl } from "@/core/lib/utils/redis-url";
import { shutdownManager } from "@/core/shutdown-manager";
interface RedisError extends Error {
code?: string;
}
export class RedisManager {
private static instance: RedisManager;
private client: Redis | null = null;
private hasEverConnected = false;
private readonly maxReconnectAttempts = 3;
// Private constructor to enforce singleton pattern
private constructor() {}
public static getInstance(): RedisManager {
if (!RedisManager.instance) {
RedisManager.instance = new RedisManager();
}
return RedisManager.instance;
}
public getClient(): Redis | null {
return this.client;
}
public async connect(): Promise<Redis | null> {
const redisUrl = getRedisUrl();
if (!redisUrl) {
shutdownManager.shutdown("Redis URL is not set, shutting down", 1);
return null;
}
this.client = new Redis(redisUrl, {
retryStrategy: (times: number): number | null => {
if (!this.hasEverConnected) {
// If we've never connected successfully, don't retry
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.`);
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
}
logger.warn(`Redis connection lost. Attempting to reconnect (#${times}) in 1000 ms...`);
return 1000; // wait 1 second between attempts
}
},
});
// Set up event handlers
this.client.on("connect", () => {
logger.info("Redis: connecting...");
});
this.client.on("ready", () => {
if (!this.hasEverConnected) {
logger.info("Redis: initial connection established and ready ✅");
} else {
logger.info("Redis: reconnected and ready ✅");
}
this.hasEverConnected = true;
});
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 && 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);
}
});
this.client.on("close", () => {
logger.warn("Redis connection closed.");
});
this.client.on("reconnecting", (delay: number) => {
logger.info(`Redis: reconnecting in ${delay} ms...`);
});
// Wait for connection to be ready or fail
return new Promise<Redis | null>((resolve) => {
if (!this.client) {
resolve(null);
return;
}
this.client.once("ready", () => {
resolve(this.client);
});
this.client.once("error", () => {
// The retryStrategy will handle this, we just need to resolve with null
// if initial connection fails
if (!this.hasEverConnected) {
resolve(null);
}
});
});
}
public getStatus(): "connected" | "connecting" | "disconnected" | "not-configured" {
if (!this.client) return "not-configured";
const status = this.client.status;
if (status === "ready") return "connected";
if (status === "connect" || status === "reconnecting") return "connecting";
return "disconnected";
}
}
-15
View File
@@ -1,15 +0,0 @@
export function getRedisUrl() {
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 "";
}
@@ -1,161 +0,0 @@
// types
import type { TDocumentPayload, TPage } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/core/services/api.service";
import { env } from "@/env";
// 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 IUpdatePagePropertiesParams<TConfig extends Record<string, any> = Record<string, any>> =
IBasePageParams<TConfig> & {
data: Partial<TPage>;
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);
}
/**
* 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) => {
throw error?.response?.data;
});
}
/**
* 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;
});
}
/**
* Updates the title of a page
*/
async updatePageProperties<TConfig extends Record<string, any>>(params: IUpdatePagePropertiesParams<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, unknown>>(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;
});
}
}
@@ -1,19 +0,0 @@
// 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}`;
}
}
@@ -1,14 +0,0 @@
// services
import { BasePageService, IBasePageParams } from "@/core/services/base-page.service";
export type ServerAgentConfig = Record<string, unknown>;
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}`;
}
}
@@ -1,20 +0,0 @@
// services
import { BasePageService, IBasePageParams, IUpdateDescriptionParams } from "@/core/services/base-page.service";
export type SyncAgentConfig = Record<string, unknown>;
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
}
}
-74
View File
@@ -1,74 +0,0 @@
import { Server as HttpServer } from "http";
import { logger } from "@plane/logger";
import { handleError } from "./helpers/error-handling/error-factory";
/**
* Handles graceful shutdown and process signal management for the HTTP server.
*/
class ShutdownManager {
private httpServer: HttpServer | null = null;
/**
* Register the HTTP server instance to be shut down later.
*/
register({ httpServer }: { httpServer: HttpServer }) {
this.httpServer = httpServer;
}
/**
* Register process termination signal handlers.
*/
registerTerminationHandlers(): void {
const gracefulTermination = this.getGracefulTerminationHandler();
process.on("SIGTERM", gracefulTermination);
process.on("SIGINT", gracefulTermination);
process.on("uncaughtException", (error) => {
logger.error("Uncaught exception:", error);
handleError(error, {
errorType: "internal",
component: "process",
operation: "uncaughtException",
extraContext: { source: "uncaughtException" },
});
});
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();
-20
View File
@@ -1,20 +0,0 @@
// types
import { TAdditionalDocumentTypes } from "@/plane-live/types/common";
export type TDocumentTypes = "project_page" | TAdditionalDocumentTypes;
export type HocusPocusServerContext = {
cookie: string;
projectId?: string;
teamspaceId?: string;
workspaceSlug?: string;
documentType: TDocumentTypes;
userId: string;
agentId: string;
parentId: string | undefined;
};
export type TConvertDocumentRequestBody = {
description_html: string;
variant: "rich" | "document";
};
-83
View File
@@ -1,83 +0,0 @@
import { TPage } from "@plane/types";
import { HocusPocusServerContext } from "@/core/types/common";
/**
* Parameters for document fetch operations
*/
export interface DocumentFetchParams {
context: HocusPocusServerContext;
pageId: string;
}
/**
* Parameters for document store operations
*/
export interface DocumentStoreParams {
context: HocusPocusServerContext;
pageId: string;
state: Uint8Array;
title: string;
}
export interface DocumentTitleFetchParams {
pageId: string;
context: HocusPocusServerContext;
}
export interface PagePropertiesUpdateParams {
context: HocusPocusServerContext;
pageId: string;
data: Partial<TPage>;
abortSignal?: AbortSignal;
}
export interface DocumentSubPagesFetchParams {
pageId: string;
context: HocusPocusServerContext;
}
/**
* Interface defining a document handler
*/
export interface DocumentHandler {
/**
* Fetch a document
*/
fetch: (params: DocumentFetchParams) => Promise<any>;
/**
* Store a document
*/
store: (params: DocumentStoreParams) => Promise<void>;
/**
* Fetch title
*/
fetchTitle?: (params: DocumentTitleFetchParams) => Promise<string | undefined>;
/**
* Update page properties
*/
updatePageProperties?: (params: PagePropertiesUpdateParams) => Promise<void>;
fetchSubPages?: (params: DocumentSubPagesFetchParams) => Promise<TPage[] | undefined>;
}
/**
* Handler selector function type - determines if a handler should be used based on context
*/
export type HandlerSelector = (context: Partial<HocusPocusServerContext>) => boolean;
/**
* Handler definition combining a selector and implementation
*/
export interface HandlerDefinition {
selector: HandlerSelector;
handler: DocumentHandler;
priority: number; // Higher number means higher priority
}
/**
* Type for a handler registration function
*/
export type RegisterHandler = (definition: HandlerDefinition) => void;
-15
View File
@@ -1,15 +0,0 @@
import { CONTROLLERS as CEControllers } from "@/ce/controllers";
import { BroadcastController } from "./broadcast.controller";
import { ContentController } from "./content.controller";
import { IframelyController } from "./iframely.controller";
export const CONTROLLERS = {
// Core system controllers (health checks, status endpoints)
CORE: [...CEControllers.CORE, IframelyController, ContentController],
// Document management controllers
DOCUMENT: [...CEControllers.DOCUMENT],
// WebSocket controllers for real-time functionality
WEBSOCKET: [...CEControllers.WEBSOCKET, BroadcastController],
};
-1
View File
@@ -1 +0,0 @@
export * from "./register";
@@ -1,9 +0,0 @@
import { projectPageHandler } from "@/ce/document-types/project-page-handler";
import { teamspacePageHandler } from "./teamspace-page-handler";
import { workspacePageHandler } from "./workspace-page-handler";
export function initializeDocumentHandlers() {
projectPageHandler.register();
workspacePageHandler.register();
teamspacePageHandler.register();
}
@@ -1,28 +0,0 @@
import { BasePageHandler } from "@/core/document-types/base-page/handlers";
import { HocusPocusServerContext } from "@/core/types/common";
import { TeamspacePageService } from "@/ee/services/teamspace-page.service";
export interface TeamspacePageConfig {
teamspaceId: string | undefined;
workspaceSlug: string | undefined;
}
const teamspacePageService = new TeamspacePageService();
export class TeamspacePageHandler extends BasePageHandler<TeamspacePageService, TeamspacePageConfig> {
protected documentType = "teamspace_page";
constructor() {
super(teamspacePageService);
}
protected getConfig(context: HocusPocusServerContext): TeamspacePageConfig {
return {
teamspaceId: context.teamspaceId,
workspaceSlug: context.workspaceSlug,
};
}
}
// Create singleton instance
export const teamspacePageHandler = new TeamspacePageHandler();
@@ -1,26 +0,0 @@
import { BasePageHandler } from "@/core/document-types/base-page/handlers";
import { HocusPocusServerContext } from "@/core/types/common";
import { WorkspacePageService } from "@/ee/services/workspace-page.service";
export interface WorkspacePageConfig {
workspaceSlug: string | undefined;
}
const workspacePageService = new WorkspacePageService();
export class WorkspacePageHandler extends BasePageHandler<WorkspacePageService, WorkspacePageConfig> {
protected documentType = "workspace_page";
constructor() {
super(workspacePageService);
}
protected getConfig(context: HocusPocusServerContext): WorkspacePageConfig {
return {
workspaceSlug: context.workspaceSlug,
};
}
}
// Create singleton instance
export const workspacePageHandler = new WorkspacePageHandler();
@@ -1,14 +0,0 @@
// services
import { BasePageService, IBasePageParams } from "@/core/services/base-page.service";
import { TeamspacePageConfig } from "../document-types/teamspace-page-handler";
export class TeamspacePageService extends BasePageService {
/**
* Gets the base URL path for workspace pages
*/
protected getBasePath<TConfig extends TeamspacePageConfig>(params: IBasePageParams<TConfig>): string {
const { pageId, config } = params;
const { workspaceSlug, teamspaceId } = config;
return `/api/workspaces/${workspaceSlug}/teamspaces/${teamspaceId}/pages/${pageId}`;
}
}
@@ -1,14 +0,0 @@
// services
import { BasePageService, IBasePageParams } from "@/core/services/base-page.service";
import { WorkspacePageConfig } from "../document-types/workspace-page-handler";
export class WorkspacePageService extends BasePageService {
/**
* Gets the base URL path for workspace pages
*/
protected getBasePath<TConfig extends WorkspacePageConfig>(params: IBasePageParams<TConfig>): string {
const { pageId, config } = params;
const { workspaceSlug } = config;
return `/api/workspaces/${workspaceSlug}/pages/${pageId}`;
}
}
+16 -30
View File
@@ -1,52 +1,38 @@
import * as dotenvx from "@dotenvx/dotenvx";
import * as dotenv from "@dotenvx/dotenvx";
import { z } from "zod";
// Load environment variables from .env file
dotenvx.config();
dotenv.config();
// Define environment schema with validation
// Environment variable validation
const envSchema = z.object({
// Server configuration
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.string().default("3000").transform(Number),
LIVE_BASE_PATH: z.string().default("/live"),
APP_VERSION: z.string().default("1.0.0"),
HOSTNAME: z.string().optional(),
PORT: z.string().default("3000"),
API_BASE_URL: z.string().url("API_BASE_URL must be a valid URL"),
// CORS configuration
CORS_ALLOWED_ORIGINS: z.string().default("*"),
// Live running location
LIVE_BASE_PATH: z.string().default("/live"),
// Compression options
COMPRESSION_LEVEL: z.string().default("6").transform(Number),
COMPRESSION_THRESHOLD: z.string().default("5000").transform(Number),
// Sentry configuration
LIVE_SENTRY_DSN: z.string().optional(),
LIVE_SENTRY_RELEASE_VERSION: z.string().optional(),
// Hocuspocus server configuration
HOCUSPOCUS_URL: z.string().optional(),
HOCUSPOCUS_USERNAME: z.string().optional(),
HOCUSPOCUS_PASSWORD: z.string().optional(),
// Graceful termination timeout
SHUTDOWN_TIMEOUT: z.string().default("10000").transform(Number),
// Live server secret key
// secret
LIVE_SERVER_SECRET_KEY: z.string(),
// Redis configuration
REDIS_HOST: z.string().optional(),
REDIS_PORT: z.string().default("6379").transform(Number),
REDIS_URL: z.string().optional(),
// Iframely configuration
IFRAMELY_URL: z.string(),
});
// Validate the environment variables
function validateEnv() {
const validateEnv = () => {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error("❌ Invalid environment variables:", JSON.stringify(result.error.format(), null, 4));
process.exit(1);
}
return result.data;
}
};
// Export the validated environment
export const env = validateEnv();
+61
View File
@@ -0,0 +1,61 @@
import { Database as HocuspocusDatabase } from "@hocuspocus/extension-database";
// utils
import {
getAllDocumentFormatsFromDocumentEditorBinaryData,
getBinaryDataFromDocumentEditorHTMLString,
} from "@plane/editor";
// logger
import { logger } from "@plane/logger";
// lib
import { getPageService } from "@/services/page/handler";
// type
import type { FetchPayloadWithContext, StorePayloadWithContext } from "@/types";
const fetchDocument = async ({ context, documentName: pageId }: FetchPayloadWithContext) => {
try {
const service = getPageService(context.documentType, context);
// fetch details
const response = await service.fetchDescriptionBinary(pageId);
const binaryData = new Uint8Array(response);
// if binary data is empty, convert HTML to binary data
if (binaryData.byteLength === 0) {
const pageDetails = await service.fetchDetails(pageId);
const convertedBinaryData = getBinaryDataFromDocumentEditorHTMLString(pageDetails.description_html ?? "<p></p>");
if (convertedBinaryData) {
return convertedBinaryData;
}
}
// return binary data
return binaryData;
} catch (error) {
logger.error("Error in fetching document", error);
throw error;
}
};
const storeDocument = async ({ context, state: pageBinaryData, documentName: pageId }: StorePayloadWithContext) => {
try {
const service = getPageService(context.documentType, context);
// convert binary data to all formats
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
pageBinaryData,
true
);
// create payload
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
return service.updateDescriptionBinary(pageId, payload);
} catch (error) {
logger.error("Error in updating document:", error);
throw error;
}
};
export class Database extends HocuspocusDatabase {
constructor() {
super({ fetch: fetchDocument, store: storeDocument });
}
}
+6
View File
@@ -0,0 +1,6 @@
import { Database } from "./database";
import { Logger } from "./logger";
import { Redis } from "./redis";
import { TitleSyncExtension } from "./title-sync";
export const getExtensions = () => [new Logger(), new Database(), new Redis(), new TitleSyncExtension()];
+13
View File
@@ -0,0 +1,13 @@
import { Logger as HocuspocusLogger } from "@hocuspocus/extension-logger";
import { logger } from "@plane/logger";
export class Logger extends HocuspocusLogger {
constructor() {
super({
onChange: false,
log: (message) => {
logger.info(message);
},
});
}
}
@@ -1,7 +1,21 @@
import { Redis } from "@hocuspocus/extension-redis";
import { Redis as HocuspocusRedis } from "@hocuspocus/extension-redis";
import { OutgoingMessage } from "@hocuspocus/server";
// redis
import { redisManager } from "@/redis";
const getRedisClient = () => {
const redisClient = redisManager.getClient();
if (!redisClient) {
throw new Error("Redis client not initialized");
}
return redisClient;
};
export class Redis extends HocuspocusRedis {
constructor() {
super({ redis: getRedisClient() });
}
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);
@@ -1,16 +1,14 @@
// hocuspocus
import { Extension, Hocuspocus, Document } from "@hocuspocus/server";
import type { Extension, Hocuspocus, Document } from "@hocuspocus/server";
import { TiptapTransformer } from "@hocuspocus/transformer";
import * as Y from "yjs";
// types
// 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 { HocusPocusServerContext } from "@/core/types/common";
import { broadcastMessageToPage } from "@/ee/lib/utils/broadcast-message";
import { getPageService } from "@/services/page/handler";
import type { HocusPocusServerContext, OnLoadDocumentPayloadWithContext } from "@/types";
import { generateTitleProsemirrorJson } from "@/utils";
import { broadcastMessageToPage } from "@/utils/broadcast-message";
import { TitleUpdateManager } from "./title-update/title-update-manager";
import { extractTextFromHTML } from "./title-update/title-utils";
@@ -27,16 +25,14 @@ export class TitleSyncExtension implements Extension {
/**
* Handle document loading - migrate old titles if needed
*/
async onLoadDocument({ context, document }: { context: HocusPocusServerContext; document: Document }) {
async onLoadDocument({ context, document, documentName }: OnLoadDocumentPayloadWithContext) {
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,
});
const service = getPageService(context.documentType, context);
// const title = await service.fe
const title = (await service.fetchDetails?.(documentName)).name;
if (title == null) return;
const titleField = TiptapTransformer.toYdoc(
generateTitleProsemirrorJson(title),
@@ -64,10 +60,8 @@ export class TitleSyncExtension implements Extension {
context: HocusPocusServerContext;
instance: Hocuspocus;
}) {
const documentHandler = getDocumentHandler(context.documentType);
// Create a title update manager for this document
const updateManager = new TitleUpdateManager(documentName, context, documentHandler);
const updateManager = new TitleUpdateManager(documentName, context);
// Store the manager
this.titleUpdateManagers.set(documentName, updateManager);
@@ -1,6 +1,6 @@
import { logger } from "@plane/logger";
import { HocusPocusServerContext } from "@/core/types/common";
import { DocumentHandler } from "@/core/types/document-handler";
import { getPageService } from "@/services/page/handler";
import type { HocusPocusServerContext } from "@/types";
import { DebounceManager } from "./debounce";
/**
@@ -10,22 +10,15 @@ import { DebounceManager } from "./debounce";
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
) {
constructor(documentName: string, context: HocusPocusServerContext, wait: number = 5000) {
this.documentName = documentName;
this.context = context;
this.documentHandler = documentHandler;
// Set up debounce manager with logging
this.debounceManager = new DebounceManager({
@@ -49,15 +42,14 @@ export class TitleUpdateManager {
* Update the title - will be called by the debounce manager
*/
private async updateTitle(title: string, signal?: AbortSignal): Promise<void> {
if (!this.documentHandler.updatePageProperties) {
const service = getPageService(this.context.documentType, this.context);
if (!service.updatePageProperties) {
logger.warn(`No updateTitle method found for document ${this.documentName}`);
return;
}
try {
await this.documentHandler.updatePageProperties({
context: this.context,
pageId: this.documentName,
await service.updatePageProperties(this.documentName, {
data: { name: title },
abortSignal: signal,
});
+63
View File
@@ -0,0 +1,63 @@
import { Server, Hocuspocus } from "@hocuspocus/server";
import { v4 as uuidv4 } from "uuid";
// env
import { env } from "@/env";
// extensions
import { getExtensions } from "@/extensions";
// lib
import { onAuthenticate } from "@/lib/auth";
import { onStateless } from "@/lib/stateless";
export class HocusPocusServerManager {
private static instance: HocusPocusServerManager | null = null;
private server: Hocuspocus | null = null;
// server options
private serverName = 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;
}
/**
* Initialize and configure the HocusPocus server
*/
public async initialize(): Promise<Hocuspocus> {
if (this.server) {
return this.server;
}
this.server = Server.configure({
name: this.serverName,
onAuthenticate,
onStateless,
extensions: getExtensions(),
debounce: 10000,
});
return this.server;
}
/**
* Get the configured server instance
*/
public getServer(): Hocuspocus | null {
return this.server;
}
/**
* Reset the singleton instance (useful for testing)
*/
public static resetInstance(): void {
HocusPocusServerManager.instance = null;
}
}
+84
View File
@@ -0,0 +1,84 @@
// plane imports
import type { IncomingHttpHeaders } from "http";
import type { TUserDetails } from "@plane/editor";
import { logger } from "@plane/logger";
// services
import { UserService } from "@/services/user.service";
// types
import type { HocusPocusServerContext, TDocumentTypes } from "@/types";
/**
* Authenticate the user
* @param requestHeaders - The request headers
* @param context - The context
* @param token - The token
* @returns The authenticated user
*/
export const onAuthenticate = async ({
requestHeaders,
requestParameters,
context,
token,
}: {
requestHeaders: IncomingHttpHeaders;
context: HocusPocusServerContext;
requestParameters: URLSearchParams;
token: string;
}) => {
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
logger.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.cookie = cookie ?? requestParameters.get("cookie") ?? "";
context.documentType = requestParameters.get("documentType")?.toString() as TDocumentTypes;
context.projectId = requestParameters.get("projectId");
context.userId = userId;
context.workspaceSlug = requestParameters.get("workspaceSlug");
context.parentId = requestParameters.get("parentPageId");
context.teamspaceId = requestParameters.get("teamspaceId");
return await handleAuthentication({
cookie: context.cookie,
userId: context.userId,
});
};
export const handleAuthentication = async ({ cookie, userId }: { cookie: string; userId: string }) => {
// fetch current user info
try {
const userService = new UserService();
const user = await userService.currentUser(cookie);
if (user.id !== userId) {
throw new Error("Authentication unsuccessful!");
}
return {
user: {
id: user.id,
name: user.display_name,
},
};
} catch (_error) {
throw Error("Authentication unsuccessful!");
}
};
@@ -1,6 +1,6 @@
import * as Y from "yjs";
import { findAllElementsRecursive, deleteNode } from "@/core/utilities/xml-tree-utils";
import { DocumentAction } from "@/ee/types/common";
import { type DocumentAction } from "@/types/document-action";
import { deleteNode, findAllElementsRecursive } from "@/utils";
import { ActionRegistry } from "../registries/action-registry";
// Define standard actions
@@ -1,5 +1,5 @@
import { TPage } from "@plane/types";
import { ActionCondition } from "@/plane-live/types/common";
import { type ActionCondition } from "@/types/document-action";
import { ConditionRegistry } from "../registries/condition-registry";
export const isDeletedAndInDocument: ActionCondition = {
@@ -4,17 +4,18 @@ import * as Y from "yjs";
import { createRealtimeEvent } from "@plane/editor";
import type { TPage } from "@plane/types";
// core
import { getDocumentHandler } from "@/core/handlers/page-handlers";
import { HocusPocusServerContext } from "@/core/types/common";
import { findAllElementsRecursive } from "@/core/utilities/xml-tree-utils";
import { getPageService } from "@/services/page/handler";
import { HocusPocusServerContext } from "@/types";
import { findAllElementsRecursive } from "@/utils";
// local imports
import { broadcastMessageToPage } from "@/utils/broadcast-message";
import { ActionRegistry } from "./registries/action-registry";
import { ConditionRegistry } from "./registries/condition-registry";
import { RuleRegistry } from "./registries/rule-registry";
// regiter the rules, actions and conditions
import "./rules/standard-rule";
import "./actions/standard-action";
import "./conditions/standard-condition";
import { broadcastMessageToPage } from "./utils/broadcast-message";
type TProcesArgs = {
xmlFragment: Y.XmlFragment;
@@ -113,14 +114,12 @@ export class DocumentProcessor {
return contentPageEmbed?.index !== subPage.index;
});
if (isChanged) {
const documentHandler = getDocumentHandler(context.documentType);
const service = getPageService(context.documentType, context);
contentPageEmbeds.forEach((contentPageEmbed) => {
if (contentPageEmbed.id && documentHandler && documentHandler.updatePageProperties) {
if (contentPageEmbed.id) {
const updatedSortOrder = (contentPageEmbed.index + 1) * 1000;
documentHandler
.updatePageProperties({
context,
pageId: contentPageEmbed.id,
service
.updatePageProperties(contentPageEmbed.id, {
data: { sort_order: updatedSortOrder },
})
.then(() => {
@@ -1,4 +1,4 @@
import { DocumentAction } from "@/ee/types/common";
import { type DocumentAction } from "@/types/document-action";
/**
* Registry of actions that can be performed on the document
@@ -1,4 +1,4 @@
import { ActionCondition } from "@/plane-live/types/common";
import { type ActionCondition } from "@/types/document-action";
/**
* Registry of conditions that can be used to determine if an action should be applied
@@ -1,4 +1,4 @@
import { ActionRule } from "@/plane-live/types/common";
import { type ActionRule } from "@/types/document-action";
/**
* Registry of rules that determine which actions to apply based on conditions
+69
View File
@@ -0,0 +1,69 @@
import { onStatelessPayload } from "@hocuspocus/server";
import { TDocumentEventsServer, EventToPayloadMap, createRealtimeEvent } from "@plane/editor";
import { DocumentCollaborativeEvents } from "@plane/editor/lib";
import { serverAgentManager } from "@/agents/server-agent";
/**
* Broadcast the client event to all the clients so that they can update their state
* @param param0
*/
export const onStateless = async ({ payload, document, connection }: onStatelessPayload) => {
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;
}
}
};
+211
View File
@@ -0,0 +1,211 @@
import Redis from "ioredis";
import { logger } from "@plane/logger";
import { env } from "./env";
export class RedisManager {
private static instance: RedisManager;
private redisClient: Redis | null = null;
private isConnected: boolean = false;
private connectionPromise: Promise<void> | null = null;
private constructor() {}
public static getInstance(): RedisManager {
if (!RedisManager.instance) {
RedisManager.instance = new RedisManager();
}
return RedisManager.instance;
}
public async initialize(): Promise<void> {
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 = env.REDIS_URL;
const redisHost = env.REDIS_HOST;
const redisPort = env.REDIS_PORT;
if (redisUrl) {
return redisUrl;
}
if (redisHost && redisPort && !Number.isNaN(Number(redisPort))) {
return `redis://${redisHost}:${redisPort}`;
}
return "";
}
private async connect(): Promise<void> {
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<void> {
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<boolean> {
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<string | null> {
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<boolean> {
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<boolean> {
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<boolean> {
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();
-27
View File
@@ -1,27 +0,0 @@
import * as Sentry from "@sentry/node";
import { nodeProfilingIntegration } from "@sentry/profiling-node";
import { logger } from "@plane/logger";
import { env } from "@/env";
export const initializeSentry = () => {
if (!env.LIVE_SENTRY_DSN) {
logger.warn("Sentry DSN not configured");
return;
}
logger.info(`Initializing Sentry | Version:${env.LIVE_SENTRY_RELEASE_VERSION}`);
Sentry.init({
dsn: env.LIVE_SENTRY_DSN,
integrations: [Sentry.httpIntegration(), Sentry.expressIntegration(), nodeProfilingIntegration()],
tracesSampleRate: 1.0,
profilesSampleRate: 1.0,
environment: env.NODE_ENV,
release: env.LIVE_SENTRY_RELEASE_VERSION,
});
};
export const captureException = (err: Error, context?: Record<string, any>) => {
Sentry.captureException(err, context);
};
export const SentryInstance = Sentry;
+100 -145
View File
@@ -1,169 +1,124 @@
import http from "http";
import type { Hocuspocus } from "@hocuspocus/server";
import express from "express";
import type { Application, Request, Router } from "express";
import { Server as HttpServer } from "http";
import { type Hocuspocus } from "@hocuspocus/server";
import compression from "compression";
import cors from "cors";
import express, { Express, Request, Response, Router } from "express";
import expressWs from "express-ws";
import type * as ws from "ws";
import helmet from "helmet";
// plane imports
import { registerControllers } from "@plane/decorators";
import { logger } from "@plane/logger";
// Core functionality
import { serverConfig, configureServerMiddleware } from "@/config/server-config";
// server agent
import { serverAgentManager } from "@/core/agents/server-agent";
import { getAllControllers } from "@/core/controller-registry";
import { serverAgentHandler } from "@/core/document-types/server-agent-handlers";
import { syncAgentHandler } from "@/core/document-types/sync-agent-handlers";
// Error handling
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { configureErrorHandlers } from "@/core/helpers/error-handling/error-handler";
import { getHocusPocusServer } from "@/core/hocuspocus-server";
// Redis manager
import { RedisManager } from "@/core/lib/redis-manager";
// Shutdown manager
import { shutdownManager } from "@/core/shutdown-manager";
// lib
import { initializeDocumentHandlers } from "@/plane-live/document-types";
import { initializeSentry } from "@/sentry-config";
import { registerController } from "@plane/decorators";
import { logger, loggerMiddleware } from "@plane/logger";
// controllers
import { CONTROLLERS } from "@/controllers";
// env
import { env } from "@/env";
// hocuspocus server
import { HocusPocusServerManager } from "@/hocuspocus";
// redis
import { redisManager } from "@/redis";
import { serverAgentManager } from "./agents/server-agent";
// WebSocket router type definition
interface WebSocketRouter extends Router {
// eslint-disable-next-line no-unused-vars
ws: (_path: string, _handler: (ws: ws.WebSocket, req: Request) => void) => void;
}
/**
* Main server class for the application
*/
export class Server {
private readonly app: Application;
private readonly port: number;
private httpServer: http.Server | null = null;
private hocusPocusServer!: Hocuspocus;
private redisManager: RedisManager;
private app: Express;
private router: Router;
private hocuspocusServer: Hocuspocus | undefined;
private httpServer: HttpServer | undefined;
/**
* Creates an instance of the server class.
* @param port Optional port number, defaults to environment configuration
*/
constructor(port?: number) {
constructor() {
this.app = express();
this.port = port || serverConfig.port;
this.redisManager = RedisManager.getInstance();
// Initialize express-ws after Express setup
expressWs(this.app as any);
configureServerMiddleware(this.app);
expressWs(this.app);
this.setupMiddleware();
this.router = express.Router();
this.app.set("port", env.PORT || 3000);
this.app.use(env.LIVE_BASE_PATH, this.router);
}
/**
* Get the Express application instance
* Useful for testing
*/
getApp(): Application {
return this.app;
}
/**
* Initialize the server with all required components
* @returns The server instance for chaining
*/
async initialize() {
public async initialize(): Promise<void> {
try {
// Initialize core services
await this.initializeServices();
await redisManager.initialize();
logger.info("Redis setup completed");
const manager = HocusPocusServerManager.getInstance();
this.hocuspocusServer = await manager.initialize();
logger.info("HocusPocus setup completed");
// Set up routes
await this.setupRoutes();
// sentry
initializeSentry();
// Set up error handlers
logger.info("Setting up error handlers");
configureErrorHandlers(this.app);
return this;
this.setupRoutes(this.hocuspocusServer);
this.setupNotFoundHandler();
serverAgentManager.initialize(this.hocuspocusServer);
} catch (error) {
logger.error("Failed to initialize server:", error);
// This will always throw (never returns) - TypeScript correctly infers this
handleError(error, {
errorType: "internal",
component: "server",
operation: "initialize",
throw: true,
});
logger.error("Failed to initialize live server dependencies:", error);
throw error;
}
}
/**
* Initialize core services
*/
private async initializeServices() {
logger.info("Initializing Redis connection...");
await this.redisManager.connect();
// Initialize the Hocuspocus server
this.hocusPocusServer = await getHocusPocusServer();
// Initialize the server agent manager with the Hocuspocus server
serverAgentManager.initialize(this.hocusPocusServer);
serverAgentHandler.register();
syncAgentHandler.register();
// initialize all document handlers
initializeDocumentHandlers();
private setupMiddleware() {
// Security middleware
this.app.use(helmet());
// Middleware for response compression
this.app.use(compression({ level: env.COMPRESSION_LEVEL, threshold: env.COMPRESSION_THRESHOLD }));
// Logging middleware
this.app.use(loggerMiddleware);
// Body parsing middleware
this.app.use(express.json());
this.app.use(express.urlencoded({ extended: true }));
// cors middleware
this.setupCors();
}
/**
* Set up API routes and WebSocket endpoints
*/
private async setupRoutes() {
try {
const router = express.Router() as WebSocketRouter;
private setupCors() {
const allowedOrigins =
env.CORS_ALLOWED_ORIGINS === "*" ? "*" : env.CORS_ALLOWED_ORIGINS.split(",").map((s) => s.trim());
this.app.use(
cors({
origin: allowedOrigins,
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "x-api-key"],
})
);
}
// Get all controller classes
const controllers = getAllControllers();
// Register controllers with our simplified approach
// Pass the hocuspocus server as a dependency to the controllers that need it
registerControllers(router, controllers, [this.hocusPocusServer]);
// Mount the router on the base path
this.app.use(serverConfig.basePath, router);
} catch (error) {
handleError(error, {
errorType: "internal",
component: "server",
operation: "setupRoutes",
throw: true,
private setupNotFoundHandler() {
this.app.use((_req: Request, res: Response) => {
res.status(404).json({
message: "Not Found",
});
});
}
private setupRoutes(hocuspocusServer: Hocuspocus) {
CONTROLLERS.forEach((controller) => registerController(this.router, controller, [hocuspocusServer]));
}
public listen() {
this.httpServer = this.app
.listen(this.app.get("port"), () => {
logger.info(`Plane Live server has started at port ${this.app.get("port")}`);
})
.on("error", (err) => {
logger.error("Failed to start server:", err);
throw err;
});
}
public async destroy() {
if (this.hocuspocusServer) {
await this.hocuspocusServer.destroy();
logger.info("HocusPocus server closed gracefully.");
}
}
/**
* Start the server
* @returns HTTP Server instance
*/
async start() {
try {
this.httpServer = this.app.listen(this.port, () => {
logger.info(`Plane Live server has started at port ${this.port}`);
});
await redisManager.disconnect();
logger.info("Redis connection closed gracefully.");
if (this.httpServer) {
shutdownManager.register({ httpServer: this.httpServer });
shutdownManager.registerTerminationHandlers();
}
} catch (error) {
handleError(error, {
errorType: "service-unavailable",
component: "server",
operation: "start",
extraContext: { port: this.port },
throw: true,
if (this.httpServer) {
await new Promise<void>((resolve, reject) => {
this.httpServer!.close((err) => {
if (err) {
reject(err);
} else {
logger.info("Express server closed gracefully.");
resolve();
}
});
});
}
}
@@ -1,23 +1,28 @@
import { config } from "@dotenvx/dotenvx";
import axios, { AxiosInstance } from "axios";
config();
export const API_BASE_URL = process.env.API_BASE_URL ?? "";
import { env } from "@/env";
export abstract class APIService {
protected baseURL: string;
private axiosInstance: AxiosInstance;
private header: Record<string, string> = {};
constructor(baseURL: string) {
this.baseURL = baseURL;
constructor(baseURL?: string) {
this.baseURL = baseURL || env.API_BASE_URL;
this.axiosInstance = axios.create({
baseURL,
baseURL: this.baseURL,
withCredentials: true,
timeout: 20000,
});
}
setHeader(key: string, value: string) {
this.header[key] = value;
}
getHeader() {
return this.header;
}
get(url: string, params = {}, config = {}) {
return this.axiosInstance.get(url, {
...params,
@@ -1,6 +1,6 @@
// services
import { API_BASE_URL, APIService } from "@/core/services/api.service";
import { env } from "@/env";
import { APIService } from "./api.service";
// Base params interface for content operations
type ContentParams = {
@@ -9,8 +9,8 @@ type ContentParams = {
};
export class ContentService extends APIService {
constructor(baseURL: string = API_BASE_URL) {
super(baseURL);
constructor() {
super();
}
/**
@@ -1,9 +1,9 @@
// services
import { IframelyResponse } from "@plane/types";
import { APIService } from "@/core/services/api.service";
// types
// helpers
import { env } from "@/env";
import { APIService } from "./api.service";
const IFRAMELY_URL = env.IFRAMELY_URL ?? "";
export class IframelyService extends APIService {
@@ -0,0 +1,16 @@
import { env } from "@/env";
import { PageService } from "./extended.service";
export class AgentPageService extends PageService {
protected basePath: string;
constructor() {
super();
// validate cookie
if (!env.LIVE_SERVER_SECRET_KEY) throw new Error("live server secret key is required.");
// set secret key
this.setHeader("live-server-secret-key", env.LIVE_SERVER_SECRET_KEY);
// set base path
this.basePath = `/api`;
}
}
@@ -0,0 +1,99 @@
import { TPage } from "@plane/types";
// services
import { APIService } from "../api.service";
export type TPageDescriptionPayload = {
description_binary: string;
description_html: string;
description: object;
};
export abstract class PageCoreService extends APIService {
protected abstract basePath: string;
constructor() {
super();
}
async fetchDetails(pageId: string): Promise<TPage> {
return this.get(`${this.basePath}/pages/${pageId}/`, {
headers: this.getHeader(),
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async fetchDescriptionBinary(pageId: string): Promise<any> {
return this.get(`${this.basePath}/pages/${pageId}/description/`, {
headers: {
...this.getHeader(),
"Content-Type": "application/octet-stream",
},
responseType: "arraybuffer",
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
/**
* Updates the title of a page
*/
async updatePageProperties(
pageId: string,
params: { data: Partial<TPage>; abortSignal?: AbortSignal }
): Promise<TPage> {
const { data, 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.basePath}/pages/${pageId}`, data, {
headers: this.getHeader(),
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);
}
}
}
async updateDescriptionBinary(pageId: string, data: TPageDescriptionPayload): Promise<any> {
return this.patch(`${this.basePath}/pages/${pageId}/description/`, data, {
headers: this.getHeader(),
})
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
}
@@ -0,0 +1,22 @@
import { PageCoreService } from "./core.service";
/**
* This is the extended service for the page service.
* It extends the core service and adds additional functionality.
* Implementation for this is found in the enterprise repository.
*/
export abstract class PageService extends PageCoreService {
constructor() {
super();
}
public async fetchSubPageDetails(pageId: string) {
return this.get(`${this.basePath}/pages/${pageId}/sub-pages/`, {
headers: this.getHeader(),
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
+37
View File
@@ -0,0 +1,37 @@
import type { HocusPocusServerContext, TDocumentTypes } from "@/types";
// services
import { AgentPageService } from "./agent.service";
import { ProjectPageService } from "./project-page.service";
import { TeamspacePageService } from "./teamspace-page.service";
import { WorkspacePageService } from "./workspace-page.service";
export const getPageService = (documentType: TDocumentTypes, context: HocusPocusServerContext) => {
if (documentType === "project_page") {
return new ProjectPageService({
workspaceSlug: context.workspaceSlug,
projectId: context.projectId,
cookie: context.cookie,
});
}
if (documentType === "teamspace_page") {
return new TeamspacePageService({
workspaceSlug: context.workspaceSlug,
teamspaceId: context.teamspaceId,
cookie: context.cookie,
});
}
if (documentType === "workspace_page") {
return new WorkspacePageService({
workspaceSlug: context.workspaceSlug,
cookie: context.cookie,
});
}
if (documentType === "server_agent" || documentType === "sync_agent") {
return new AgentPageService();
}
throw new Error(`Invalid document type ${documentType} provided.`);
};
@@ -0,0 +1,24 @@
import { PageService } from "./extended.service";
interface ProjectPageServiceParams {
workspaceSlug: string | null;
projectId: string | null;
cookie: string | null;
[key: string]: unknown;
}
export class ProjectPageService extends PageService {
protected basePath: string;
constructor(params: ProjectPageServiceParams) {
super();
const { workspaceSlug, projectId } = params;
if (!workspaceSlug || !projectId) throw new Error("Missing required fields.");
// validate cookie
if (!params.cookie) throw new Error("Cookie is required.");
// set cookie
this.setHeader("Cookie", params.cookie);
// set base path
this.basePath = `/api/workspaces/${workspaceSlug}/projects/${projectId}`;
}
}
@@ -0,0 +1,23 @@
import { PageService } from "./extended.service";
interface TeamspacePageServiceParams {
workspaceSlug: string | null;
teamspaceId: string | null;
cookie: string | null;
[key: string]: unknown;
}
export class TeamspacePageService extends PageService {
protected basePath: string;
constructor(params: TeamspacePageServiceParams) {
super();
const { workspaceSlug, teamspaceId } = params;
if (!workspaceSlug || !teamspaceId) throw new Error("Missing required fields.");
// validate cookie
if (!params.cookie) throw new Error("Cookie is required.");
// set cookie
this.setHeader("Cookie", params.cookie);
// set base path
this.basePath = `/api/workspaces/${workspaceSlug}/teamspaces/${teamspaceId}`;
}
}
@@ -0,0 +1,22 @@
import { PageService } from "./extended.service";
interface WorkspacePageServiceParams {
workspaceSlug: string | null;
cookie: string | null;
}
export class WorkspacePageService extends PageService {
protected basePath: string;
constructor(params: WorkspacePageServiceParams) {
super();
const { workspaceSlug } = params;
if (!workspaceSlug) throw new Error("Missing required fields.");
// validate cookie
if (!params.cookie) throw new Error("Cookie is required.");
// set cookie
this.setHeader("Cookie", params.cookie);
// set base path
this.basePath = `/api/workspaces/${workspaceSlug}`;
}
}
@@ -1,11 +1,11 @@
// types
import type { IUser } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/core/services/api.service";
import { APIService } from "@/services/api.service";
export class UserService extends APIService {
constructor() {
super(API_BASE_URL);
super();
}
currentUserConfig() {
+34 -28
View File
@@ -1,37 +1,43 @@
import { logger } from "@plane/logger";
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { env } from "./env";
import { Server } from "./server";
/**
* The main entry point for the application
* Starts the server and handles any startup errors
*/
const startServer = async () => {
let server: Server;
async function startServer() {
server = new Server();
try {
// Log server startup details
logger.info(`Starting Plane Live server in ${env.NODE_ENV} environment`);
// Initialize and start the server
const server = await new Server().initialize();
await server.start();
logger.info(`Server running at base path: ${env.LIVE_BASE_PATH}`);
await server.initialize();
server.listen();
} catch (error) {
logger.error("Failed to start server:", error);
// Create an AppError but DON'T exit
handleError(error, {
errorType: "internal",
component: "startup",
operation: "startServer",
extraContext: { environment: env.NODE_ENV },
});
// Continue running even if startup had issues
logger.warn("Server encountered errors during startup but will continue running");
process.exit(1);
}
};
}
// Start the server
startServer();
// Graceful shutdown on unhandled rejection
process.on("unhandledRejection", async (err: Error) => {
logger.error(`UNHANDLED REJECTION! 💥 Shutting down...`, err);
try {
if (server) {
await server.destroy();
}
} finally {
logger.info("Exiting process...");
process.exit(1);
}
});
// Graceful shutdown on uncaught exception
process.on("uncaughtException", async (err: Error) => {
logger.error(`UNCAUGHT EXCEPTION! 💥 Shutting down...`, err);
try {
if (server) {
await server.destroy();
}
} finally {
logger.info("Exiting process...");
process.exit(1);
}
});
+32
View File
@@ -0,0 +1,32 @@
import type { fetchPayload, onLoadDocumentPayload, storePayload } from "@hocuspocus/server";
export type TConvertDocumentRequestBody = {
description_html: string;
variant: "rich" | "document";
};
export interface OnLoadDocumentPayloadWithContext extends onLoadDocumentPayload {
context: HocusPocusServerContext;
}
export interface FetchPayloadWithContext extends fetchPayload {
context: HocusPocusServerContext;
}
export interface StorePayloadWithContext extends storePayload {
context: HocusPocusServerContext;
}
export type TDocumentTypes = "project_page" | "sync_agent" | "server_agent" | "workspace_page" | "teamspace_page";
// Additional Hocuspocus types that are not exported from the main package
export type HocusPocusServerContext = {
projectId: string | null;
cookie: string;
documentType: TDocumentTypes;
workspaceSlug: string | null;
userId: string;
agentId?: string;
parentId: string | null;
teamspaceId: string | null;
};
@@ -1,7 +1,7 @@
import { Hocuspocus } from "@hocuspocus/server";
import { BroadcastedEvent } from "@plane/editor";
import { ServerAgentManager } from "@/core/agents/server-agent";
import { CustomHocuspocusRedisExtension } from "@/core/extensions/redis";
import { type ServerAgentManager } from "@/agents/server-agent";
import { Redis } from "@/extensions/redis";
export const broadcastMessageToPage = (
instance: Hocuspocus | ServerAgentManager,
@@ -15,9 +15,7 @@ export const broadcastMessageToPage = (
console.error("HocusPocus server not available or initialized");
return false;
}
const redisExtension = hocuspocusServer.configuration.extensions.find(
(ext) => ext instanceof CustomHocuspocusRedisExtension
) as CustomHocuspocusRedisExtension | undefined;
const redisExtension = hocuspocusServer.configuration.extensions.find((ext) => ext instanceof Redis);
if (redisExtension) {
redisExtension.broadcastToDocument(documentName, eventData);
+2
View File
@@ -0,0 +1,2 @@
export * from "./document";
export * from "./xml-tree";
+3 -2
View File
@@ -19,8 +19,9 @@
"inlineSources": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"sourceRoot": "/"
"sourceRoot": "/",
"types": ["node"]
},
"include": ["src/**/*.ts", "tsdown.config.ts"],
"exclude": ["./dist", "./build", "./node_modules"]
"exclude": ["./dist", "./build", "./node_modules", "**/*.d.ts"]
}
-28
View File
@@ -1,28 +0,0 @@
import { RequestHandler, Router } from "express";
type HttpMethod = "get" | "post" | "put" | "delete" | "patch" | "options" | "head";
export function registerControllers(router: Router, Controller: any) {
const instance = new Controller();
const baseRoute = Reflect.getMetadata("baseRoute", Controller) as string;
Object.getOwnPropertyNames(Controller.prototype).forEach((methodName) => {
if (methodName === "constructor") return; // Skip the constructor
const method = Reflect.getMetadata("method", instance, methodName) as HttpMethod;
const route = Reflect.getMetadata("route", instance, methodName) as string;
const middlewares = (Reflect.getMetadata("middlewares", instance, methodName) as RequestHandler[]) || [];
if (method && route) {
const handler = instance[methodName as keyof typeof instance] as unknown;
if (typeof handler === "function") {
(router[method] as (path: string, ...handlers: RequestHandler[]) => void)(
`${baseRoute}${route}`,
...middlewares,
handler.bind(instance)
);
}
}
});
}
-1
View File
@@ -1,4 +1,3 @@
export * from "./controller";
export * from "./decorators";
export * from "./errors";
export * from "./throws";
+5 -2
View File
@@ -5,7 +5,7 @@ import express, { Application, Request, Response, NextFunction } from "express";
// lib
import expressWinston from "express-winston";
import { registerControllers } from "@plane/decorators";
import { registerController } from "@plane/decorators";
import { logger } from "@plane/logger";
import AsanaController from "@/apps/asana-importer/controllers";
// controllers
@@ -138,7 +138,10 @@ export default class Server {
...Server.CONTROLLERS.APPS,
];
registerControllers(router, allControllers, []);
allControllers.forEach((controller) => {
registerController(router, controller);
});
this.app.use(env.SILO_BASE_PATH || "/", router);
}
+1 -1
View File
@@ -13,7 +13,7 @@
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"check:lint": "eslint . --max-warnings 1",
"check:lint": "eslint . --max-warnings 2",
"check:types": "tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
+32 -30
View File
@@ -3,46 +3,48 @@ import type { WebSocket } from "ws";
import "reflect-metadata";
type HttpMethod = "get" | "post" | "put" | "delete" | "patch" | "options" | "head" | "ws";
export type HttpMethod = "get" | "post" | "put" | "delete" | "patch" | "options" | "head" | "ws";
interface ControllerInstance {
[key: string]: unknown;
}
type ControllerInstance = {
[key: string]: any;
};
interface ControllerConstructor {
new (...args: unknown[]): ControllerInstance;
export type ControllerConstructor = {
new (...args: any[]): ControllerInstance;
prototype: ControllerInstance;
}
};
export function registerControllers(
export function registerController(
router: Router,
controllers: ControllerConstructor[],
dependencies: any[] = []
Controller: ControllerConstructor,
dependencies: unknown[] = []
): void {
controllers.forEach((Controller) => {
// Create the controller instance with dependencies
const instance = new Controller(...dependencies);
// Create the controller instance with dependencies
const instance = new Controller(...dependencies);
// Determine if it's a WebSocket controller or REST controller by checking
// if it has any methods with the "ws" method metadata
const isWebsocket = Object.getOwnPropertyNames(Controller.prototype).some((methodName) => {
if (methodName === "constructor") return false;
return Reflect.getMetadata("method", instance, methodName) === "ws";
});
if (isWebsocket) {
// Register as WebSocket controller
// Pass the existing instance with dependencies to avoid creating a new instance without them
registerWebSocketController(router, Controller, instance);
} else {
// Register as REST controller - doesn't accept an instance parameter
registerRestController(router, Controller);
}
// Determine if it's a WebSocket controller or REST controller by checking
// if it has any methods with the "ws" method metadata
const isWebsocket = Object.getOwnPropertyNames(Controller.prototype).some((methodName) => {
if (methodName === "constructor") return false;
return Reflect.getMetadata("method", instance, methodName) === "ws";
});
if (isWebsocket) {
// Register as WebSocket controller
// Pass the existing instance with dependencies to avoid creating a new instance without them
registerWebSocketController(router, Controller, instance);
} else {
// Register as REST controller with the existing instance
registerRestController(router, Controller, instance);
}
}
function registerRestController(router: Router, Controller: ControllerConstructor): void {
const instance = new Controller();
function registerRestController(
router: Router,
Controller: ControllerConstructor,
existingInstance?: ControllerInstance
): void {
const instance = existingInstance || new Controller();
const baseRoute = Reflect.getMetadata("baseRoute", Controller) as string;
Object.getOwnPropertyNames(Controller.prototype).forEach((methodName) => {
+3 -13
View File
@@ -1,13 +1,3 @@
// Export individual decorators
export { Controller, Middleware } from "./rest";
export { Get, Post, Put, Patch, Delete } from "./rest";
export { WebSocket } from "./websocket";
export { registerControllers } from "./controller";
// Also provide namespaced exports for better organization
import * as RestDecorators from "./rest";
import * as WebSocketDecorators from "./websocket";
// Named namespace exports
export const Rest = RestDecorators;
export const WebSocketNS = WebSocketDecorators;
export * from "./controller";
export * from "./rest";
export * from "./websocket";

Some files were not shown because too many files have changed in this diff Show More