Compare commits

...
41 changed files with 535 additions and 1249 deletions
@@ -1,58 +0,0 @@
import { PageService } from "@/core/services/page.service";
import { transformHTMLToBinary } from "./transformers";
import { getAllDocumentFormatsFromBinaryData } from "@/core/helpers/page";
const pageService = new PageService();
/**
* Fetches the binary description data for a project page
* Falls back to HTML transformation if binary is not available
*/
export const fetchPageDescriptionBinary = async (
params: URLSearchParams,
pageId: string,
cookie: string | undefined
) => {
const workspaceSlug = params.get("workspaceSlug")?.toString();
const projectId = params.get("projectId")?.toString();
if (!workspaceSlug || !projectId || !cookie) return null;
const response = await pageService.fetchDescriptionBinary(workspaceSlug, projectId, pageId, cookie);
const binaryData = new Uint8Array(response);
if (binaryData.byteLength === 0) {
const binary = await transformHTMLToBinary(workspaceSlug, projectId, pageId, cookie);
if (binary) {
return binary;
}
}
return binaryData;
};
/**
* Updates the description of a project page
*/
export const updatePageDescription = async (
params: URLSearchParams | undefined,
pageId: string,
updatedDescription: Uint8Array,
cookie: string | undefined
) => {
if (!(updatedDescription instanceof Uint8Array)) {
throw new Error("Invalid updatedDescription: must be an instance of Uint8Array");
}
const workspaceSlug = params?.get("workspaceSlug")?.toString();
const projectId = params?.get("projectId")?.toString();
if (!workspaceSlug || !projectId || !cookie) return;
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromBinaryData(updatedDescription);
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
await pageService.updateDescription(workspaceSlug, projectId, pageId, payload, cookie);
};
@@ -1,3 +1,2 @@
export * from "./handlers"
export * from "./transformers";
export * from "./project-page-handler";
@@ -5,7 +5,11 @@ import {
HandlerDefinition,
} from "@/core/types/document-handler";
import { handlerFactory } from "@/core/handlers/document-handlers/handler-factory";
import { fetchPageDescriptionBinary, updatePageDescription } from "./handlers";
import { PageService } from "@/services/page.service";
import { transformHTMLToBinary } from "./transformers";
import { getAllDocumentFormatsFromBinaryData } from "@/core/helpers/page";
const pageService = new PageService();
/**
* Handler for "project_page" document type
@@ -16,7 +20,21 @@ export const projectPageHandler: DocumentHandler = {
*/
fetch: async ({ pageId, params, context }: DocumentFetchParams) => {
const { cookie } = context;
return await fetchPageDescriptionBinary(params, pageId, cookie);
const workspaceSlug = params.get("workspaceSlug")?.toString();
const projectId = params.get("projectId")?.toString();
if (!workspaceSlug || !projectId || !cookie) return null;
const response = await pageService.fetchDescriptionBinary(workspaceSlug, projectId, pageId, cookie);
const binaryData = new Uint8Array(response);
if (binaryData.byteLength === 0) {
const binary = await transformHTMLToBinary(workspaceSlug, projectId, pageId, cookie);
if (binary) {
return binary;
}
}
return binaryData;
},
/**
@@ -24,7 +42,22 @@ export const projectPageHandler: DocumentHandler = {
*/
store: async ({ pageId, state, params, context }: DocumentStoreParams) => {
const { cookie } = context;
await updatePageDescription(params, pageId, state, cookie);
if (!(state instanceof Uint8Array)) {
throw new Error("Invalid state: must be an instance of Uint8Array");
}
const workspaceSlug = params?.get("workspaceSlug")?.toString();
const projectId = params?.get("projectId")?.toString();
if (!workspaceSlug || !projectId || !cookie) return;
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromBinaryData(state);
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
await pageService.updateDescription(workspaceSlug, projectId, pageId, payload, cookie);
},
};
@@ -1,4 +1,4 @@
import { PageService } from "@/core/services/page.service";
import { PageService } from "@/services/page.service";
import { getBinaryDataFromHTMLString } from "@/core/helpers/page";
import { logger } from "@plane/logger";
@@ -24,4 +24,3 @@ export const transformHTMLToBinary = async (
throw error;
}
};
-25
View File
@@ -1,25 +0,0 @@
// types
import { AppError, catchAsync } from "@/core/helpers/error-handling/error-handler";
import { TDocumentTypes } from "@/core/types/common";
interface TArgs {
cookie: string;
documentType: TDocumentTypes;
pageId: string;
params: URLSearchParams;
}
export const fetchDocument = async (args: TArgs): Promise<Uint8Array | null> => {
const { cookie, documentType, pageId, params } = args;
if (!documentType) {
throw new AppError("Document type is required");
}
if (!pageId) {
throw new AppError("Page ID is required");
}
return null
};
-15
View File
@@ -1,15 +0,0 @@
// types
import { TDocumentTypes } from "@/core/types/common";
type TArgs = {
cookie: string | undefined;
documentType: TDocumentTypes | undefined;
pageId: string;
params: URLSearchParams;
updatedDescription: Uint8Array;
}
export const updateDocument = async (args: TArgs): Promise<void> => {
const { documentType } = args;
throw Error(`Update failed: Invalid document type ${documentType} provided.`);
}
-66
View File
@@ -1,66 +0,0 @@
import { env } from "@/env";
import compression from "compression";
import helmet from "helmet";
import cors from "cors";
import cookieParser from "cookie-parser";
import express from "express";
import { logger } from "@plane/logger";
import { logger as loggerMiddleware } from "@/core/helpers/logger";
/**
* 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 origins = env.CORS_ALLOWED_ORIGINS?.split(",").map((origin) => origin.trim()) || [];
for (const origin of origins) {
logger.info(`Adding CORS allowed origin: ${origin}`);
app.use(
cors({
origin,
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,
};
@@ -1,9 +1,9 @@
import type { Request } from "express";
import type { WebSocket as WS } from "ws";
import type { Hocuspocus } from "@hocuspocus/server";
import { ErrorCategory } from "@/core/helpers/error-handling/error-handler";
import { ErrorCategory } from "@/lib/error-handling/error-handler";
import { logger } from "@plane/logger";
import Errors from "@/core/helpers/error-handling/error-factory";
import Errors from "@/lib/error-handling/error-factory";
import { Controller, WebSocket } from "@plane/decorators";
@Controller("/collaboration")
@@ -5,12 +5,12 @@ import { convertHTMLDocumentToAllFormats } from "@/core/helpers/convert-document
// types
import { TConvertDocumentRequestBody } from "@/core/types/common";
// decorators
import { CatchErrors } from "@/lib/decorators";
import { CatchErrors } from "@/lib/error";
// logger
import { logger } from "@plane/logger";
import { Controller, Post } from "@plane/decorators";
import { AppError } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { AppError } from "@/lib/error-handling/error-handler";
import { handleError } from "@/lib/error-handling/error-factory";
// Define the schema with more robust validation
const convertDocumentSchema = z.object({
@@ -1,4 +1,4 @@
import { CatchErrors } from "@/lib/decorators";
import { CatchErrors } from "@/lib/error";
import { Controller, Get } from "@plane/decorators";
import type { Request, Response } from "express";
+7
View File
@@ -0,0 +1,7 @@
import { HealthController } from "./health.controller";
import { DocumentController } from "./document.controller";
import { CollaborationController } from "./collaboration.controller";
export const REST_CONTROLLERS = [HealthController, DocumentController];
export const WEBSOCKET_CONTROLLERS = [CollaborationController];
-21
View File
@@ -1,21 +0,0 @@
import { HealthController } from "@/core/controllers/health.controller";
import { DocumentController } from "@/core/controllers/document.controller";
import { CollaborationController } from "@/core/controllers/collaboration.controller";
/**
* Controller registry exports
* Simple grouped arrays of controller classes for better organization
*/
export const CONTROLLERS = {
// Core system controllers (health checks, status endpoints)
CORE: [HealthController],
// Document management controllers
DOCUMENT: [DocumentController],
// WebSocket controllers for real-time functionality
WEBSOCKET: [CollaborationController],
};
// Helper to get all REST controllers
export const getAllControllers = () => [...CONTROLLERS.CORE, ...CONTROLLERS.DOCUMENT, ...CONTROLLERS.WEBSOCKET];
-4
View File
@@ -1,4 +0,0 @@
// Export all controllers from this barrel file
export { HealthController } from "./health.controller";
export { CollaborationController } from "./collaboration.controller";
export { DocumentController } from "./document.controller";
+5 -5
View File
@@ -1,6 +1,6 @@
import { Database } from "@hocuspocus/extension-database";
import { catchAsync } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { catchAsync } from "@/lib/error-handling/error-handler";
import { handleError } from "@/lib/error-handling/error-factory";
import { getDocumentHandler } from "../handlers/document-handlers";
import { type HocusPocusServerContext, type TDocumentTypes } from "@/core/types/common";
@@ -37,7 +37,7 @@ const handleFetch = async ({
});
}
const documentHandler = getDocumentHandler(documentType);
const documentHandler = getDocumentHandler(documentType, context);
fetchedData = await documentHandler.fetch({
context: context as HocusPocusServerContext,
pageId,
@@ -100,7 +100,7 @@ const handleStore = async ({
});
}
const documentHandler = getDocumentHandler(documentType);
const documentHandler = getDocumentHandler(documentType, context);
await documentHandler.store({
context: context as HocusPocusServerContext,
pageId,
@@ -113,4 +113,4 @@ const handleStore = async ({
extra: { operation: "store" },
}
)();
};
};
+5 -23
View File
@@ -1,34 +1,16 @@
import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis";
// core helpers and utilities
import { logger } from "@plane/logger";
import { RedisManager } from "@/core/lib/redis-manager";
import { getRedisClient } from "@/core/lib/redis-manager";
/**
* Sets up the Redis extension for HocusPocus using the RedisManager singleton
* @returns Promise that resolves to a Redis extension array
*/
export const setupRedisExtension = async () => {
const redisManager = RedisManager.getInstance();
export const setupRedisExtension = () => {
// Wait for Redis connection
const redisClient = await redisManager.connect();
if (redisClient) {
return new HocusPocusRedis({
redis: redisClient,
});
} else {
logger.warn(
"Redis connection failed, continuing without Redis extension (you won't be able to sync data between multiple plane live servers)"
);
}
return new HocusPocusRedis({
redis: getRedisClient(),
});
};
/**
* 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,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;
+77 -71
View File
@@ -1,4 +1,4 @@
import { handleError } from "./error-handling/error-factory";
import { handleError } from "../../lib/error-handling/error-factory";
/**
* A simple validation utility that integrates with our error system.
@@ -18,11 +18,11 @@ export class Validator<T> {
required(message?: string): Validator<T> {
if (this.data === undefined || this.data === null) {
throw handleError(new ValidationError(this.name, message || `${this.name} is required`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateRequired',
errorType: "bad-request",
component: "validation",
operation: "validateRequired",
extraContext: { field: this.name },
throw: true
throw: true,
});
}
return this;
@@ -34,11 +34,11 @@ export class Validator<T> {
string(message?: string): Validator<T> {
if (typeof this.data !== "string") {
throw handleError(new ValidationError(this.name, message || `${this.name} must be a string`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateString',
errorType: "bad-request",
component: "validation",
operation: "validateString",
extraContext: { field: this.name },
throw: true
throw: true,
});
}
return this;
@@ -50,11 +50,11 @@ export class Validator<T> {
notEmpty(message?: string): Validator<T> {
if (typeof this.data === "string" && this.data.trim() === "") {
throw handleError(new ValidationError(this.name, message || `${this.name} cannot be empty`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateNonEmptyString',
errorType: "bad-request",
component: "validation",
operation: "validateNonEmptyString",
extraContext: { field: this.name },
throw: true
throw: true,
});
}
return this;
@@ -66,11 +66,11 @@ export class Validator<T> {
number(message?: string): Validator<T> {
if (typeof this.data !== "number" || isNaN(this.data)) {
throw handleError(new ValidationError(this.name, message || `${this.name} must be a valid number`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateNumber',
errorType: "bad-request",
component: "validation",
operation: "validateNumber",
extraContext: { field: this.name },
throw: true
throw: true,
});
}
return this;
@@ -82,11 +82,11 @@ export class Validator<T> {
nonEmptyArray(message?: string): Validator<T> {
if (!Array.isArray(this.data) || this.data.length === 0) {
throw handleError(new ValidationError(this.name, message || `${this.name} must be a non-empty array`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateArray',
errorType: "bad-request",
component: "validation",
operation: "validateArray",
extraContext: { field: this.name },
throw: true
throw: true,
});
}
return this;
@@ -98,11 +98,11 @@ export class Validator<T> {
match(regex: RegExp, message?: string): Validator<T> {
if (typeof this.data !== "string" || !regex.test(this.data)) {
throw handleError(new ValidationError(this.name, message || `${this.name} has an invalid format`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateFormat',
errorType: "bad-request",
component: "validation",
operation: "validateFormat",
extraContext: { field: this.name, format: regex.toString() },
throw: true
throw: true,
});
}
return this;
@@ -113,13 +113,16 @@ export class Validator<T> {
*/
oneOf(allowedValues: any[], message?: string): Validator<T> {
if (!allowedValues.includes(this.data)) {
throw handleError(new ValidationError(this.name, message || `${this.name} must be one of: ${allowedValues.join(", ")}`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateEnum',
extraContext: { field: this.name, allowedValues },
throw: true
});
throw handleError(
new ValidationError(this.name, message || `${this.name} must be one of: ${allowedValues.join(", ")}`),
{
errorType: "bad-request",
component: "validation",
operation: "validateEnum",
extraContext: { field: this.name, allowedValues },
throw: true,
}
);
}
return this;
}
@@ -130,11 +133,11 @@ export class Validator<T> {
custom(validationFn: (value: T) => boolean, message?: string): Validator<T> {
if (!validationFn(this.data)) {
throw handleError(new ValidationError(this.name, message || `${this.name} is invalid`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateCustom',
errorType: "bad-request",
component: "validation",
operation: "validateCustom",
extraContext: { field: this.name },
throw: true
throw: true,
});
}
return this;
@@ -158,7 +161,10 @@ export const validate = <T>(data: T, name?: string): Validator<T> => {
export default validate;
export class ValidationError extends Error {
constructor(public name: string, message: string) {
constructor(
public name: string,
message: string
) {
super(message);
this.name = name;
}
@@ -167,23 +173,23 @@ export class ValidationError extends Error {
export const validateRequired = (value: any, name: string, message?: string) => {
if (value === undefined || value === null) {
throw handleError(new ValidationError(name, message || `${name} is required`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateRequired',
errorType: "bad-request",
component: "validation",
operation: "validateRequired",
extraContext: { field: name },
throw: true
throw: true,
});
}
};
export const validateString = (value: any, name: string, message?: string) => {
if (typeof value !== 'string') {
if (typeof value !== "string") {
throw handleError(new ValidationError(name, message || `${name} must be a string`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateString',
errorType: "bad-request",
component: "validation",
operation: "validateString",
extraContext: { field: name },
throw: true
throw: true,
});
}
};
@@ -191,23 +197,23 @@ export const validateString = (value: any, name: string, message?: string) => {
export const validateNonEmptyString = (value: string, name: string, message?: string) => {
if (!value.trim()) {
throw handleError(new ValidationError(name, message || `${name} cannot be empty`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateNonEmptyString',
errorType: "bad-request",
component: "validation",
operation: "validateNonEmptyString",
extraContext: { field: name },
throw: true
throw: true,
});
}
};
export const validateNumber = (value: any, name: string, message?: string) => {
if (typeof value !== 'number' || isNaN(value)) {
if (typeof value !== "number" || isNaN(value)) {
throw handleError(new ValidationError(name, message || `${name} must be a valid number`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateNumber',
errorType: "bad-request",
component: "validation",
operation: "validateNumber",
extraContext: { field: name },
throw: true
throw: true,
});
}
};
@@ -215,11 +221,11 @@ export const validateNumber = (value: any, name: string, message?: string) => {
export const validateArray = (value: any, name: string, message?: string) => {
if (!Array.isArray(value) || value.length === 0) {
throw handleError(new ValidationError(name, message || `${name} must be a non-empty array`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateArray',
errorType: "bad-request",
component: "validation",
operation: "validateArray",
extraContext: { field: name },
throw: true
throw: true,
});
}
};
@@ -227,11 +233,11 @@ export const validateArray = (value: any, name: string, message?: string) => {
export const validateFormat = (value: string, name: string, format: RegExp, message?: string) => {
if (!format.test(value)) {
throw handleError(new ValidationError(name, message || `${name} has an invalid format`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateFormat',
errorType: "bad-request",
component: "validation",
operation: "validateFormat",
extraContext: { field: name, format: format.toString() },
throw: true
throw: true,
});
}
};
@@ -239,11 +245,11 @@ export const validateFormat = (value: string, name: string, format: RegExp, mess
export const validateEnum = (value: any, name: string, allowedValues: any[], message?: string) => {
if (!allowedValues.includes(value)) {
throw handleError(new ValidationError(name, message || `${name} must be one of: ${allowedValues.join(", ")}`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateEnum',
errorType: "bad-request",
component: "validation",
operation: "validateEnum",
extraContext: { field: name, allowedValues },
throw: true
throw: true,
});
}
};
@@ -251,11 +257,11 @@ export const validateEnum = (value: any, name: string, allowedValues: any[], mes
export const validateCustom = (value: any, name: string, validator: (value: any) => boolean, message?: string) => {
if (!validator(value)) {
throw handleError(new ValidationError(name, message || `${name} is invalid`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateCustom',
errorType: "bad-request",
component: "validation",
operation: "validateCustom",
extraContext: { field: name },
throw: true
throw: true,
});
}
};
-100
View File
@@ -1,100 +0,0 @@
import { Server } from "@hocuspocus/server";
import { v4 as uuidv4 } from "uuid";
import { IncomingHttpHeaders } from "http";
// lib
import { handleAuthentication } from "@/core/lib/authentication";
// extensions
import { getExtensions } from "@/core/extensions/index";
import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib";
// editor types
import { TUserDetails } from "@plane/editor";
// types
import { TDocumentTypes, type HocusPocusServerContext } from "@/core/types/common";
// error handling
import { catchAsync } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
export const getHocusPocusServer = async () => {
const extensions = await getExtensions();
const serverName = process.env.HOSTNAME || uuidv4();
return Server.configure({
name: serverName,
onAuthenticate: async ({
requestHeaders,
context,
requestParameters,
// user id used as token for authentication
token,
}: {
requestHeaders: IncomingHttpHeaders;
context: HocusPocusServerContext; // Better than 'any', still allows property assignment
requestParameters: URLSearchParams;
token: string;
}) => {
// need to rethrow all errors since hocuspocus needs to know to stop
// further propagation of events to other document lifecycle
return catchAsync(
async () => {
let cookie: string | undefined = undefined;
let userId: string | undefined = undefined;
// Extract cookie (fallback to request headers) and userId from token (for scenarios where
// the cookies are not passed in the request headers)
try {
const parsedToken = JSON.parse(token) as TUserDetails;
userId = parsedToken.id;
cookie = parsedToken.cookie;
} catch (error) {
// If token parsing fails, fallback to request headers
console.error("Token parsing failed, using request headers:", error);
} finally {
// If cookie is still not found, fallback to request headers
if (!cookie) {
cookie = requestHeaders.cookie?.toString();
}
}
if (!cookie || !userId) {
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() as string;
return await handleAuthentication({
cookie: context.cookie,
userId: context.userId,
workspaceSlug: context.workspaceSlug,
});
},
{ extra: { operation: "authenticate" } },
{
rethrow: true,
}
)();
},
onStateless: async ({ payload, document }) => {
return catchAsync(
async () => {
// broadcast the client event (derived from the server event) to all the clients so that they can update their state
const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
if (response) {
document.broadcastStateless(response);
}
},
{ extra: { operation: "stateless", payload } }
);
},
extensions,
debounce: 1000,
});
};
+2 -2
View File
@@ -1,6 +1,6 @@
// services
import { UserService } from "@/core/services/user.service";
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { UserService } from "@/services/user.service";
import { handleError } from "@/lib/error-handling/error-factory";
const userService = new UserService();
+1 -1
View File
@@ -1,7 +1,7 @@
// helpers
import { getAllDocumentFormatsFromBinaryData, getBinaryDataFromHTMLString } from "@/core/helpers/page";
// services
import { PageService } from "@/core/services/page.service";
import { PageService } from "@/services/page.service";
const pageService = new PageService();
export const updatePageDescription = async (
+46 -111
View File
@@ -1,127 +1,62 @@
import { Redis } from "ioredis";
import { logger } from "@plane/logger";
import { getRedisUrl } from "@/core/lib/utils/redis-url";
import { ShutdownManager } from "@/core/shutdown-manager";
// Define Redis error interface to handle specific error properties
interface RedisError extends Error {
code?: string;
}
let redisClient: Redis | null = null;
export class RedisManager {
private static instance: RedisManager;
private client: Redis | null = null;
private hasEverConnected = false;
private readonly maxReconnectAttempts = 3;
export async function initializeRedis(): Promise<Redis> {
const redisUrl = getRedisUrl();
// Private constructor to enforce singleton pattern
private constructor() {}
public static getInstance(): RedisManager {
if (!RedisManager.instance) {
RedisManager.instance = new RedisManager();
}
return RedisManager.instance;
if (!redisUrl) {
logger.error("Redis URL is not configured. Please set REDIS_URL environment variable.");
process.exit(1);
}
public getClient(): Redis | null {
return this.client;
}
public async connect(): Promise<Redis | null> {
const redisUrl = getRedisUrl();
if (!redisUrl) {
logger.warn(
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)"
);
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)"
);
return null;
} else {
// Once connected at least once, try a few times before giving up
if (times > this.maxReconnectAttempts) {
logger.error(`Exceeded ${this.maxReconnectAttempts} Redis reconnect attempts. Shutting down the server.`);
// Use ShutdownManager to gracefully terminate the server
const shutdownManager = ShutdownManager.getInstance();
shutdownManager.shutdown("Redis connection lost and could not be recovered", 1);
return null; // This will never be reached due to shutdown, but needed for type safety
}
logger.warn(`Redis connection lost. Attempting to reconnect (#${times}) in 1000 ms...`);
return 1000; // wait 1 second between attempts
}
},
try {
redisClient = new Redis(redisUrl);
redisClient.on("error", (error) => {
logger.error("Redis connection error:", error);
process.exit(1);
});
// 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) => {
if (
error?.code === "ENOTFOUND" ||
error.message.includes("WRONGPASS") ||
error.message.includes("NOAUTH") ||
error.message.includes("ECONNREFUSED")
) {
if (this.client) this.client.disconnect();
}
logger.warn("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);
// Wait for the connection to be ready
await new Promise<void>((resolve, reject) => {
redisClient!.on("ready", () => {
logger.info("Redis connection established successfully");
resolve();
});
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);
}
redisClient!.on("error", (error) => {
reject(error);
});
});
}
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";
return redisClient;
} catch (error) {
logger.error("Failed to initialize Redis:", error);
process.exit(1);
}
}
export function getRedisClient(): Redis {
if (!redisClient) {
throw new Error("Redis client not initialized. Call initializeRedis() first.");
}
return redisClient;
}
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 "";
}
-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 "";
}
-84
View File
@@ -1,84 +0,0 @@
// server
import { Server } from "http";
// hocuspocus server
import type { Hocuspocus } from "@hocuspocus/server";
// logger
import { logger } from "@plane/logger";
// config
// error handling
import { handleError } from "@/core/helpers/error-handling/error-factory";
// shutdown manager
import { ShutdownManager } from "@/core/shutdown-manager";
/**
* ProcessManager handles graceful process termination and resource cleanup
*/
export class ProcessManager {
private readonly hocusPocusServer: Hocuspocus;
private readonly httpServer: Server;
private shutdownManager: ShutdownManager;
/**
* Initialize the process manager
* @param hocusPocusServer Hocuspocus server instance
* @param httpServer HTTP server instance
*/
constructor(hocusPocusServer: Hocuspocus, httpServer: Server) {
this.hocusPocusServer = hocusPocusServer;
this.httpServer = httpServer;
this.shutdownManager = ShutdownManager.getInstance();
}
/**
* Register process termination signal handlers
*/
registerTerminationHandlers(): void {
const gracefulTermination = this.getGracefulTerminationHandler();
// Handle process signals
process.on("SIGTERM", gracefulTermination);
process.on("SIGINT", gracefulTermination);
// Handle uncaught exceptions - create AppError but DON'T terminate
process.on("uncaughtException", (error) => {
logger.error("Uncaught exception:", error);
// Create AppError to track the issue but don't terminate
handleError(error, {
errorType: "internal",
component: "process",
operation: "uncaughtException",
extraContext: { source: "uncaughtException" },
});
});
// Handle unhandled promise rejections - create AppError but DON'T terminate
process.on("unhandledRejection", (reason) => {
logger.error("Unhandled rejection:", reason);
// Create AppError to track the issue but don't terminate
handleError(reason, {
errorType: "internal",
component: "process",
operation: "unhandledRejection",
extraContext: { source: "unhandledRejection" },
});
});
}
/**
* Get the graceful termination handler
* @returns Termination function
*/
private getGracefulTerminationHandler(): () => Promise<void> {
return async () => {
// Check if ShutdownManager is already handling a shutdown
if (this.shutdownManager.isShutdownInProgress()) {
logger.info("Shutdown already in progress via ShutdownManager, deferring to its process");
return;
}
logger.info("Signal received, delegating to ShutdownManager for graceful termination");
await this.shutdownManager.shutdown("Process termination signal received", 1);
};
}
}
-168
View File
@@ -1,168 +0,0 @@
import { Server as HttpServer } from "http";
import { Hocuspocus } from "@hocuspocus/server";
import { logger } from "@plane/logger";
import { RedisManager } from "@/core/lib/redis-manager";
// config
import { serverConfig } from "@/config/server-config";
import { Server } from "http";
// Global flag to prevent duplicate shutdown sequences
let isGlobalShutdownInProgress = false;
/**
* ShutdownManager - Handles graceful shutdown of all server components
*
* Implements the singleton pattern to ensure only one shutdown sequence
* can be initiated throughout the application.
*/
export class ShutdownManager {
private static instance: ShutdownManager;
private httpServer: HttpServer | null = null;
private hocuspocusServer: Hocuspocus | null = null;
private isShuttingDown = false;
private exitCode = 0;
private forceExitTimeout: NodeJS.Timeout | null = null;
// Private constructor to enforce singleton pattern
private constructor() {}
/**
* Get the singleton instance
*/
public static getInstance(): ShutdownManager {
if (!ShutdownManager.instance) {
ShutdownManager.instance = new ShutdownManager();
}
return ShutdownManager.instance;
}
/**
* Register server instances that need to be gracefully closed during shutdown
*/
public register(httpServer: Server, hocuspocusServer: Hocuspocus): void {
this.httpServer = httpServer;
this.hocuspocusServer = hocuspocusServer;
logger.info("ShutdownManager registered with server instances");
}
/**
* Check if a shutdown is in progress
*/
public isShutdownInProgress(): boolean {
return this.isShuttingDown || isGlobalShutdownInProgress;
}
/**
* Initiate graceful shutdown sequence
* @param reason Reason for shutdown
* @param exitCode Process exit code (default: 0)
*/
public async shutdown(reason: string, exitCode = 0): Promise<void> {
// Prevent multiple shutdown attempts
if (this.isShuttingDown || isGlobalShutdownInProgress) {
logger.warn("Shutdown already in progress, ignoring additional shutdown request");
return;
}
this.isShuttingDown = true;
isGlobalShutdownInProgress = true;
this.exitCode = exitCode;
logger.info(`Initiating graceful shutdown: ${reason}`);
// Create a timeout to force exit if shutdown takes too long
this.forceExitTimeout = setTimeout(() => {
logger.error("Forcing termination after timeout - some connections may not have closed gracefully.");
process.exit(1);
}, serverConfig.terminationTimeout || 10000); // Default to 10 seconds if not configured
try {
// Close components in order: Redis, HocusPocus, HTTP server
await this.closeRedisConnections();
await this.closeHocusPocusServer();
await this.closeHttpServer();
// Wait a bit to allow handles to close
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log("All components shut down successfully");
} catch (error) {
console.error("Error during graceful shutdown:", error);
} finally {
// Clear timeout if we've made it this far
if (this.forceExitTimeout) {
clearTimeout(this.forceExitTimeout);
}
// Give a small delay before exiting to ensure all handles are closed
setTimeout(() => {
console.info(`Exiting process with code ${this.exitCode}`);
process.exit(this.exitCode);
}, 100);
}
}
/**
* Close Redis connections
*/
private async closeRedisConnections(): Promise<void> {
console.info("Closing Redis connections...");
try {
const redisManager = RedisManager.getInstance();
const redisClient = redisManager.getClient();
if (redisClient) {
redisClient.disconnect();
await redisClient.quit();
console.info("Redis connections closed successfully");
} else {
console.info("No Redis connections to close");
}
} catch (error) {
console.error("Error closing Redis connections:", error);
}
}
/**
* Close HocusPocus server
*/
private async closeHocusPocusServer(): Promise<void> {
console.info("Shutting down HocusPocus server...");
try {
if (this.hocuspocusServer) {
await this.hocuspocusServer.destroy();
console.info("HocusPocus server shut down successfully");
} else {
console.info("No HocusPocus server to shut down");
}
} catch (error) {
console.error("Error shutting down HocusPocus server:", error);
}
}
/**
* Close HTTP server
*/
private async closeHttpServer(): Promise<void> {
logger.info("Closing HTTP server...");
return new Promise<void>((resolve) => {
if (!this.httpServer) {
console.info("No HTTP server to close");
resolve();
return;
}
// Close all connections
this.httpServer.closeAllConnections?.();
this.httpServer.close((error) => {
if (error) {
console.error("Error closing HTTP server:", error);
} else {
console.info("HTTP server closed successfully");
}
resolve();
});
});
}
}
+145
View File
@@ -0,0 +1,145 @@
import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib";
import { Logger } from "@hocuspocus/extension-logger";
import { Database } from "@hocuspocus/extension-database";
import { Redis } from "@hocuspocus/extension-redis";
import { Hocuspocus } from "@hocuspocus/server";
import { v4 as uuidv4 } from "uuid";
import { getRedisClient } from "./redis";
import { UserService } from "./services/user.service";
// import { handleError } from "@/core/helpers/error-handling/error-factory";
// import { TDocumentTypes } from "@/core/types/common";
// import { handleAuthentication } from "@/core/lib/authentication";
// import { IncomingHttpHeaders } from "http";
export const createHocusPocus = () => {
const serverName = process.env.HOSTNAME || uuidv4();
return new Hocuspocus({
name: serverName,
onAuthenticate: onAuthenticate(),
onStateless: onStateless(),
extensions: [
new Logger(),
new Database({
fetch: handleDataFetch,
store: handleDataStore,
}),
new Redis({ redis: getRedisClient() }),
],
debounce: 1000,
});
};
const validateToken = async (token: string | undefined) => {
try {
if (!token) {
throw new Error("Token not provided");
}
const userService = new UserService();
const response = await userService.currentUser(token);
return response;
} catch (error) {
return null;
}
};
const onAuthenticate = () => {
return async ({ token }: { token: string | undefined }) => {
const user = await validateToken(token);
if (!user) {
throw new Error("Invalid token");
}
return user;
};
};
const onStateless = () => {
return async ({ payload, document }: any) => {
// broadcast the client event (derived from the server event) to all the clients so that they can update their state
const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
if (response) {
document.broadcastStateless(response);
}
};
};
const handleDataFetch = async (data: any) => {
try {
const { context, documentName, requestParameters } = data;
console.log("handleDataFetch", context);
console.log("handleDataFetch", documentName);
console.log("handleDataFetch", requestParameters);
return documentName; // TODO: remove this once the API integration is done
// fetch the data using page service
// const pageService = new PageService();
// const page = await pageService.getPage(documentName);
// return page;
} catch (error) {
console.error("handleDataFetch", error);
throw error;
}
};
const handleDataStore = async (data: any) => {
try {
const { context, documentName, requestParameters } = data;
console.log("handleDataStore", context);
console.log("handleDataStore", documentName);
console.log("handleDataStore", requestParameters);
return documentName; // TODO: remove this once the API integration is done
// store the data using page service
// const pageService = new PageService();
// const page = await pageService.updatePage(documentName, requestParameters);
// return page;
} catch (error) {
console.error("handleDataStore", error);
throw error;
}
};
// async ({
// token,
// requestParameters,
// requestHeaders,
// }: {
// token: string;
// requestParameters: URLSearchParams;
// requestHeaders: IncomingHttpHeaders;
// }) => {
// let cookie: string | undefined = undefined;
// let userId: string | undefined = undefined;
// try {
// const parsedToken = JSON.parse(token) as { id: string; cookie: string };
// 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,
// });
// }
// const documentType = requestParameters.get("documentType")?.toString() as TDocumentTypes;
// const workspaceSlug = requestParameters.get("workspaceSlug")?.toString() as string;
// return await handleAuthentication({
// cookie,
// userId,
// workspaceSlug,
// });
// }
-32
View File
@@ -1,32 +0,0 @@
import { Router } from "express";
import { registerControllers as registerRestControllers, registerWebSocketControllers } from "@plane/decorators";
import "reflect-metadata";
/**
* Register all controllers from the controllers array
* @param router Express router to register routes on
* @param controllers Array of controller classes to register
* @param dependencies Array of dependencies to pass to controllers
*/
export function registerControllers(router: Router, controllers: any[], dependencies: any[] = []): void {
controllers.forEach((Controller) => {
// 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
registerWebSocketControllers(router, Controller, instance);
} else {
// Register as REST controller - doesn't accept an instance parameter
registerRestControllers(router, Controller);
}
});
}
-21
View File
@@ -1,21 +0,0 @@
import "reflect-metadata";
import { asyncHandler } from "@/core/helpers/error-handling/error-handler";
/**
* Decorator to wrap controller methods with error handling
* This automatically catches and processes all errors using our error handling system
*/
export const CatchErrors = (): MethodDecorator => {
return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
// Only apply to methods that are not WebSocket handlers
const isWebSocketHandler = Reflect.getMetadata("method", target, propertyKey) === "ws";
if (typeof originalMethod === "function" && !isWebSocketHandler) {
descriptor.value = asyncHandler(originalMethod);
}
return descriptor;
};
};
@@ -152,11 +152,13 @@ export function handleError(error: unknown, options: ThrowingOptions): never;
* });
* return { error: appError.output() };
*/
// eslint-disable-next-line no-redeclare
export function handleError(error: unknown, options: NonThrowingOptions): AppError;
/**
* Implementation of handleError that handles both throwing and non-throwing cases
*/
// eslint-disable-next-line no-redeclare
export function handleError(error: unknown, options: ThrowingOptions | NonThrowingOptions): AppError | never {
// Only throw if throw is explicitly true
const shouldThrow = (options as ThrowingOptions).throw === true;
@@ -4,7 +4,7 @@ import { env } from "@/env";
import { logger } from "@plane/logger";
import { handleError } from "./error-factory";
import { ErrorContext, reportError } from "./error-reporting";
import { manualLogger } from "../logger";
import { manualLogger } from "../../core/helpers/logger";
/**
* HTTP Status Codes
+62
View File
@@ -0,0 +1,62 @@
import { Redis } from "ioredis";
import { logger } from "@plane/logger";
let redisClient: Redis | null = null;
export async function initializeRedis(): Promise<Redis> {
const redisUrl = getRedisUrl();
if (!redisUrl) {
logger.error("Redis URL is not configured. Please set REDIS_URL environment variable.");
process.exit(1);
}
try {
redisClient = new Redis(redisUrl);
redisClient.on("error", (error) => {
logger.error("Redis connection error:", error);
process.exit(1);
});
// Wait for the connection to be ready
await new Promise<void>((resolve, reject) => {
redisClient!.on("ready", () => {
logger.info("Redis connection established successfully");
resolve();
});
redisClient!.on("error", (error) => {
reject(error);
});
});
return redisClient;
} catch (error) {
logger.error("Failed to initialize Redis:", error);
process.exit(1);
}
}
export function getRedisClient(): Redis {
if (!redisClient) {
throw new Error("Redis client not initialized. Call initializeRedis() first.");
}
return redisClient;
}
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 "";
}
+97 -139
View File
@@ -1,161 +1,119 @@
import express from "express";
import type { Application, Request, Router } from "express";
import cookieParser from "cookie-parser";
import cors from "cors";
import express, { Application } from "express";
import expressWs from "express-ws";
import type * as ws from "ws";
import type { Hocuspocus } from "@hocuspocus/server";
import helmet from "helmet";
import path from "path";
import { Hocuspocus } from "@hocuspocus/server";
// controllers
// Environment and configuration
import { serverConfig, configureServerMiddleware } from "./config/server-config";
// Core functionality
import { getHocusPocusServer } from "@/core/hocuspocus-server";
import { ProcessManager } from "@/core/process-manager";
import { ShutdownManager } from "@/core/shutdown-manager";
import { registerControllers } from "./lib/controller.utils";
// Redis manager
import { RedisManager } from "@/core/lib/redis-manager";
// Logging
import { initializeRedis } from "./core/lib/redis-manager";
import { logger } from "@plane/logger";
import { createHocusPocus } from "./hocuspocus";
// Error handling
import { configureErrorHandlers } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { getAllControllers } from "./core/controller-registry";
import { registerWebSocketController, registerController } from "@plane/decorators";
import { REST_CONTROLLERS, WEBSOCKET_CONTROLLERS } from "./controllers";
// WebSocket router type definition
interface WebSocketRouter extends Router {
ws: (_path: string, _handler: (ws: ws.WebSocket, req: Request) => void) => void;
}
export default class Server {
app: Application;
PORT: number;
BASE_PATH: string;
CORS_ALLOWED_ORIGINS: string;
hocuspocusServer: Hocuspocus | null = null;
private httpServer: any;
/**
* Main server class for the application
*/
export class Server {
private readonly app: Application;
private readonly port: number;
private hocusPocusServer!: Hocuspocus;
private redisManager: RedisManager;
/**
* Creates an instance of the server class.
* @param port Optional port number, defaults to environment configuration
*/
constructor(port?: number) {
constructor() {
this.PORT = parseInt(process.env.PORT || "3000");
this.BASE_PATH = process.env.LIVE_BASE_PATH || "/";
this.CORS_ALLOWED_ORIGINS = process.env.CORS_ALLOWED_ORIGINS || "*";
// Initialize express app
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);
// Security middleware
this.app.use(helmet());
// cors
this.setupCors();
// Cookie parsing
this.app.use(cookieParser());
// Body parsing middleware
this.app.use(express.json());
this.app.use(express.urlencoded({ extended: true }));
// static files
this.app.use(express.static(path.join(__dirname, "public")));
// setup redis
initializeRedis();
// setup hocuspocus server
this.hocuspocusServer = createHocusPocus();
// setup controllers
this.setupControllers();
}
/**
* Get the Express application instance
* Useful for testing
*/
getApp(): Application {
return this.app;
private setupCors() {
const origins = this.CORS_ALLOWED_ORIGINS.split(",").map((origin) => origin.trim());
this.app.use(
cors({
origin: origins,
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "x-api-key"],
})
);
}
/**
* Initialize the server with all required components
* @returns The server instance for chaining
*/
async initialize() {
try {
// Initialize core services
await this.initializeServices();
private setupControllers() {
const router = express.Router();
// Set up routes
await this.setupRoutes();
REST_CONTROLLERS.forEach((controller: any) => {
registerController(router, controller, [this.hocuspocusServer]);
});
// Set up error handlers
logger.info("Setting up error handlers");
configureErrorHandlers(this.app);
WEBSOCKET_CONTROLLERS.forEach((controller: any) => {
registerWebSocketController(router, controller, [this.hocuspocusServer]);
});
return this;
} catch (error) {
logger.error("Failed to initialize server:", error);
this.app.use(this.BASE_PATH, router);
}
// This will always throw (never returns) - TypeScript correctly infers this
handleError(error, {
errorType: "internal",
component: "server",
operation: "initialize",
throw: true,
});
start() {
this.httpServer = this.app.listen(this.PORT, () => {
console.log(`Plane Live server has started at port ${this.PORT}`);
});
// Setup graceful shutdown
process.on("SIGTERM", () => this.shutdown("Received SIGTERM"));
process.on("SIGINT", () => this.shutdown("Received SIGINT"));
process.on("uncaughtException", (error) => {
logger.error("Uncaught exception:", error);
});
// Handle unhandled promise rejections - create AppError but DON'T terminate
process.on("unhandledRejection", (error) => {
logger.error("Unhandled rejection:", error);
});
}
private async shutdown(error: string): Promise<void> {
logger.info(`Initiating graceful shutdown: ${error}`);
if (!this.httpServer) {
logger.info("No HTTP server to close");
return;
}
}
/**
* Initialize core services
*/
private async initializeServices() {
logger.info("Initializing Redis connection...");
await this.redisManager.connect();
// Close all existing connections
this.httpServer.closeAllConnections?.();
// Initialize the Hocuspocus server
this.hocusPocusServer = await getHocusPocusServer();
}
/**
* Set up API routes and WebSocket endpoints
*/
private async setupRoutes() {
try {
const router = express.Router() as WebSocketRouter;
// 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,
// Close the server
return new Promise<void>((resolve) => {
this.httpServer.close((error: Error | undefined) => {
if (error) {
logger.error("Error closing HTTP server:", error);
} else {
logger.info("HTTP server closed successfully");
}
resolve();
});
}
}
/**
* Start the server
* @returns HTTP Server instance
*/
async start() {
try {
const server = this.app.listen(this.port, () => {
logger.info(`Plane Live server has started at port ${this.port}`);
});
// Register servers with ShutdownManager
const shutdownManager = ShutdownManager.getInstance();
shutdownManager.register(server, this.hocusPocusServer);
// Setup graceful termination via ProcessManager (for signal handling)
const processManager = new ProcessManager(this.hocusPocusServer, server);
processManager.registerTerminationHandlers();
return server;
} catch (error) {
handleError(error, {
errorType: "service-unavailable",
component: "server",
operation: "start",
extraContext: { port: this.port },
throw: true,
});
}
});
}
}
@@ -1,7 +1,7 @@
// types
import { TPage } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/core/services/api.service";
import { API_BASE_URL, APIService } from "@/services/api.service";
export class PageService extends APIService {
constructor() {
@@ -1,7 +1,7 @@
// types
import type { IUser } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/core/services/api.service";
import { API_BASE_URL, APIService } from "@/services/api.service";
export class UserService extends APIService {
constructor() {
+6 -33
View File
@@ -1,37 +1,10 @@
import { Server } from "./server";
import Server from "./server";
import { env } from "./env";
import { logger } from "@plane/logger";
import { handleError } from "@/core/helpers/error-handling/error-factory";
/**
* The main entry point for the application
* Starts the server and handles any startup errors
*/
const startServer = async () => {
try {
// Log server startup details
logger.info(`Starting Plane Live server in ${env.NODE_ENV} environment`);
// 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}`);
} 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");
}
};
// Start the server
startServer();
// Initialize and start the server
const server = new Server();
server.start();
+5 -18
View File
@@ -3,19 +3,13 @@
"compilerOptions": {
"module": "ES2015",
"moduleResolution": "Bundler",
"lib": [
"ES2015"
],
"lib": ["ES2015"],
"outDir": "./dist",
"rootDir": ".",
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
],
"@/plane-live/*": [
"./src/ce/*"
]
"@/*": ["./src/*"],
"@/plane-live/*": ["./src/ce/*"]
},
"removeComments": true,
"esModuleInterop": true,
@@ -26,13 +20,6 @@
"emitDecoratorMetadata": true,
"sourceRoot": "/"
},
"include": [
"src/**/*.ts",
"tsup.config.ts"
],
"exclude": [
"./dist",
"./build",
"./node_modules"
]
"include": ["src/**/*.ts", "tsup.config.ts"],
"exclude": ["./dist", "./build", "./node_modules"]
}
+5 -4
View File
@@ -20,11 +20,12 @@ interface ControllerConstructor {
prototype: ControllerInstance;
}
export function registerControllers(
export function registerController(
router: Router,
Controller: ControllerConstructor,
dependencies: any[] = []
): void {
const instance = new Controller();
const instance = new Controller(...dependencies);
const baseRoute = Reflect.getMetadata("baseRoute", Controller) as string;
Object.getOwnPropertyNames(Controller.prototype).forEach((methodName) => {
@@ -33,14 +34,14 @@ export function registerControllers(
const method = Reflect.getMetadata(
"method",
instance,
methodName,
methodName
) as HttpMethod;
const route = Reflect.getMetadata("route", instance, methodName) as string;
const middlewares =
(Reflect.getMetadata(
"middlewares",
instance,
methodName,
methodName
) as RequestHandler[]) || [];
if (method && route) {
+2 -3
View File
@@ -2,8 +2,8 @@
export { Controller, Middleware } from "./rest";
export { Get, Post, Put, Patch, Delete } from "./rest";
export { WebSocket } from "./websocket";
export { registerControllers } from "./controller";
export { registerWebSocketControllers } from "./websocket-controller";
export { registerController } from "./controller";
export { registerWebSocketController } from "./websocket-controller";
// Also provide namespaced exports for better organization
import * as RestDecorators from "./rest";
@@ -12,4 +12,3 @@ import * as WebSocketDecorators from "./websocket";
// Named namespace exports
export const Rest = RestDecorators;
export const WebSocketNS = WebSocketDecorators;
+21 -56
View File
@@ -1,5 +1,3 @@
import { Router, Request } from "express";
import type { WebSocket } from "ws";
import "reflect-metadata";
interface ControllerInstance {
@@ -11,23 +9,19 @@ interface ControllerConstructor {
prototype: ControllerInstance;
}
export function registerWebSocketControllers(
router: Router,
export function registerWebSocketController(
router: any,
Controller: ControllerConstructor,
existingInstance?: ControllerInstance,
dependencies: any[] = []
): void {
const instance = existingInstance || new Controller();
const baseRoute = Reflect.getMetadata("baseRoute", Controller) as string;
const instance = new Controller(...dependencies);
const baseRoute = Reflect.getMetadata("baseRoute", Controller);
Object.getOwnPropertyNames(Controller.prototype).forEach((methodName) => {
if (methodName === "constructor") return; // Skip the constructor
const method = Reflect.getMetadata(
"method",
instance,
methodName,
) as string;
const route = Reflect.getMetadata("route", instance, methodName) as string;
const method = Reflect.getMetadata("method", instance, methodName);
const route = Reflect.getMetadata("route", instance, methodName);
if (method === "ws" && route) {
const handler = instance[methodName] as unknown;
@@ -36,50 +30,21 @@ export function registerWebSocketControllers(
typeof handler === "function" &&
typeof (router as any).ws === "function"
) {
(router as any).ws(
`${baseRoute}${route}`,
(ws: WebSocket, req: Request) => {
try {
handler.call(instance, ws, req);
} catch (error) {
console.error(
`WebSocket error in ${Controller.name}.${methodName}`,
error,
);
ws.close(
1011,
error instanceof Error
? error.message
: "Internal server error",
);
}
},
);
router.ws(`${baseRoute}${route}`, (ws: any, req: any) => {
try {
handler.call(instance, ws, req);
} catch (error) {
console.error(
`WebSocket error in ${Controller.name}.${methodName}`,
error
);
ws.close(
1011,
error instanceof Error ? error.message : "Internal server error"
);
}
});
}
}
});
}
/**
* Base controller class for WebSocket endpoints
*/
export abstract class BaseWebSocketController {
protected router: Router;
constructor() {
this.router = Router();
}
/**
* Get the base route for this controller
*/
protected getBaseRoute(): string {
return Reflect.getMetadata("baseRoute", this.constructor) || "";
}
/**
* Abstract method to handle WebSocket connections
* Implement this in your derived class
*/
abstract handleConnection(ws: WebSocket, req: Request): void;
}
+1 -1
View File
@@ -9,7 +9,7 @@ export function WebSocket(route: string): MethodDecorator {
return function (
target: object,
propertyKey: string | symbol,
descriptor: PropertyDescriptor,
descriptor: PropertyDescriptor
) {
Reflect.defineMetadata("method", "ws", target, propertyKey);
Reflect.defineMetadata("route", route, target, propertyKey);