Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e895da0e8 | ||
|
|
483961ea5a | ||
|
|
ecefd47823 | ||
|
|
3359172acf |
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
+2
-2
@@ -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")
|
||||
+3
-3
@@ -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
-1
@@ -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";
|
||||
|
||||
@@ -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];
|
||||
@@ -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];
|
||||
@@ -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";
|
||||
@@ -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" },
|
||||
}
|
||||
)();
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,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 (
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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,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);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
// });
|
||||
// }
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
+2
@@ -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;
|
||||
+1
-1
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user