mirror of
https://github.com/gosticks/plane.git
synced 2025-10-16 12:45:33 +00:00
[WIKI-679] refactor: live server (#7720)
This commit is contained in:
parent
7ce21a6488
commit
5951372555
@ -61,4 +61,4 @@ ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "apps/live/dist/server.js"]
|
||||
CMD ["node", "apps/live/dist/start.js"]
|
||||
|
||||
@ -3,13 +3,13 @@
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "A realtime collaborative server powers Plane's rich text editor",
|
||||
"main": "./src/server.ts",
|
||||
"main": "./dist/start.js",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"dev": "tsdown --watch",
|
||||
"start": "node --env-file=.env dist/server.js",
|
||||
"build": "tsc --noEmit && tsdown",
|
||||
"dev": "tsdown --watch --onSuccess \"node --env-file=.env dist/start.js\"",
|
||||
"start": "node --env-file=.env dist/start.js",
|
||||
"check:lint": "eslint . --max-warnings 10",
|
||||
"check:types": "tsc --noEmit",
|
||||
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
|
||||
@ -20,11 +20,14 @@
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"dependencies": {
|
||||
"@dotenvx/dotenvx": "^1.49.0",
|
||||
"@hocuspocus/extension-database": "^2.15.0",
|
||||
"@hocuspocus/extension-logger": "^2.15.0",
|
||||
"@hocuspocus/extension-redis": "^2.15.0",
|
||||
"@hocuspocus/server": "^2.15.0",
|
||||
"@plane/decorators": "workspace:*",
|
||||
"@plane/editor": "workspace:*",
|
||||
"@plane/logger": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
"@tiptap/core": "^2.22.3",
|
||||
"@tiptap/html": "^2.22.3",
|
||||
@ -42,7 +45,8 @@
|
||||
"uuid": "catalog:",
|
||||
"y-prosemirror": "^1.2.15",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.20"
|
||||
"yjs": "^13.6.20",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
@ -54,6 +58,7 @@
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/pino-http": "^5.8.4",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"concurrently": "^9.0.1",
|
||||
"nodemon": "^3.1.7",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
// types
|
||||
import { TDocumentTypes } from "@/core/types/common.js";
|
||||
|
||||
type TArgs = {
|
||||
cookie: string | undefined;
|
||||
documentType: TDocumentTypes | undefined;
|
||||
pageId: string;
|
||||
params: URLSearchParams;
|
||||
};
|
||||
|
||||
export const fetchDocument = async (args: TArgs): Promise<Uint8Array | null> => {
|
||||
const { documentType } = args;
|
||||
throw Error(`Fetch failed: Invalid document type ${documentType} provided.`);
|
||||
};
|
||||
@ -1,15 +0,0 @@
|
||||
// types
|
||||
import { TDocumentTypes } from "@/core/types/common.js";
|
||||
|
||||
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
apps/live/src/ce/types/common.d.ts
vendored
1
apps/live/src/ce/types/common.d.ts
vendored
@ -1 +0,0 @@
|
||||
export type TAdditionalDocumentTypes = never;
|
||||
33
apps/live/src/controllers/collaboration.controller.ts
Normal file
33
apps/live/src/controllers/collaboration.controller.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import type { Hocuspocus } from "@hocuspocus/server";
|
||||
import type { Request } from "express";
|
||||
import type WebSocket from "ws";
|
||||
// plane imports
|
||||
import { Controller, WebSocket as WSDecorator } from "@plane/decorators";
|
||||
import { logger } from "@plane/logger";
|
||||
|
||||
@Controller("/collaboration")
|
||||
export class CollaborationController {
|
||||
[key: string]: unknown;
|
||||
private readonly hocusPocusServer: Hocuspocus;
|
||||
|
||||
constructor(hocusPocusServer: Hocuspocus) {
|
||||
this.hocusPocusServer = hocusPocusServer;
|
||||
}
|
||||
|
||||
@WSDecorator("/")
|
||||
handleConnection(ws: WebSocket, req: Request) {
|
||||
try {
|
||||
// Initialize the connection with Hocuspocus
|
||||
this.hocusPocusServer.handleConnection(ws, req);
|
||||
|
||||
// Set up error handling for the connection
|
||||
ws.on("error", (error: Error) => {
|
||||
logger.error("WebSocket connection error:", error);
|
||||
ws.close(1011, "Internal server error");
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("WebSocket connection error:", error);
|
||||
ws.close(1011, "Internal server error");
|
||||
}
|
||||
}
|
||||
}
|
||||
37
apps/live/src/controllers/convert-document.controller.ts
Normal file
37
apps/live/src/controllers/convert-document.controller.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import type { Request, Response } from "express";
|
||||
// plane imports
|
||||
import { Controller, Post } from "@plane/decorators";
|
||||
import { logger } from "@plane/logger";
|
||||
// types
|
||||
import type { TConvertDocumentRequestBody } from "@/types";
|
||||
// utils
|
||||
import { convertHTMLDocumentToAllFormats } from "@/utils";
|
||||
|
||||
@Controller("/convert-document")
|
||||
export class ConvertDocumentController {
|
||||
@Post("/")
|
||||
handleConvertDocument(req: Request, res: Response) {
|
||||
const { description_html, variant } = req.body as TConvertDocumentRequestBody;
|
||||
try {
|
||||
if (typeof description_html !== "string" || variant === undefined) {
|
||||
res.status(400).json({
|
||||
message: "Missing required fields",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { description, description_binary } = convertHTMLDocumentToAllFormats({
|
||||
document_html: description_html,
|
||||
variant,
|
||||
});
|
||||
res.status(200).json({
|
||||
description,
|
||||
description_binary,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error in /convert-document endpoint:", error);
|
||||
res.status(500).json({
|
||||
message: `Internal server error.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
15
apps/live/src/controllers/health.controller.ts
Normal file
15
apps/live/src/controllers/health.controller.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { Controller, Get } from "@plane/decorators";
|
||||
import { env } from "@/env";
|
||||
|
||||
@Controller("/health")
|
||||
export class HealthController {
|
||||
@Get("/")
|
||||
async healthCheck(_req: Request, res: Response) {
|
||||
res.status(200).json({
|
||||
status: "OK",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: env.APP_VERSION,
|
||||
});
|
||||
}
|
||||
}
|
||||
5
apps/live/src/controllers/index.ts
Normal file
5
apps/live/src/controllers/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { CollaborationController } from "./collaboration.controller";
|
||||
import { ConvertDocumentController } from "./convert-document.controller";
|
||||
import { HealthController } from "./health.controller";
|
||||
|
||||
export const CONTROLLERS = [CollaborationController, ConvertDocumentController, HealthController];
|
||||
@ -1,117 +0,0 @@
|
||||
import { Database } from "@hocuspocus/extension-database";
|
||||
import { Logger } from "@hocuspocus/extension-logger";
|
||||
import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis";
|
||||
import { Extension } from "@hocuspocus/server";
|
||||
import { Redis } from "ioredis";
|
||||
// core helpers and utilities
|
||||
import { manualLogger } from "@/core/helpers/logger.js";
|
||||
// core libraries
|
||||
import { fetchPageDescriptionBinary, updatePageDescription } from "@/core/lib/page.js";
|
||||
import { getRedisUrl } from "@/core/lib/utils/redis-url.js";
|
||||
import { type HocusPocusServerContext, type TDocumentTypes } from "@/core/types/common.js";
|
||||
// plane live libraries
|
||||
import { fetchDocument } from "@/plane-live/lib/fetch-document.js";
|
||||
import { updateDocument } from "@/plane-live/lib/update-document.js";
|
||||
|
||||
export const getExtensions: () => Promise<Extension[]> = async () => {
|
||||
const extensions: Extension[] = [
|
||||
new Logger({
|
||||
onChange: false,
|
||||
log: (message) => {
|
||||
manualLogger.info(message);
|
||||
},
|
||||
}),
|
||||
new Database({
|
||||
fetch: async ({ context, documentName: pageId, requestParameters }) => {
|
||||
const cookie = (context as HocusPocusServerContext).cookie;
|
||||
// query params
|
||||
const params = requestParameters;
|
||||
const documentType = params.get("documentType")?.toString() as TDocumentTypes | undefined;
|
||||
// TODO: Fix this lint error.
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
return new Promise(async (resolve) => {
|
||||
try {
|
||||
let fetchedData = null;
|
||||
if (documentType === "project_page") {
|
||||
fetchedData = await fetchPageDescriptionBinary(params, pageId, cookie);
|
||||
} else {
|
||||
fetchedData = await fetchDocument({
|
||||
cookie,
|
||||
documentType,
|
||||
pageId,
|
||||
params,
|
||||
});
|
||||
}
|
||||
resolve(fetchedData);
|
||||
} catch (error) {
|
||||
manualLogger.error("Error in fetching document", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
store: async ({ context, state, documentName: pageId, requestParameters }) => {
|
||||
const cookie = (context as HocusPocusServerContext).cookie;
|
||||
// query params
|
||||
const params = requestParameters;
|
||||
const documentType = params.get("documentType")?.toString() as TDocumentTypes | undefined;
|
||||
|
||||
// TODO: Fix this lint error.
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
return new Promise(async () => {
|
||||
try {
|
||||
if (documentType === "project_page") {
|
||||
await updatePageDescription(params, pageId, state, cookie);
|
||||
} else {
|
||||
await updateDocument({
|
||||
cookie,
|
||||
documentType,
|
||||
pageId,
|
||||
params,
|
||||
updatedDescription: state,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
manualLogger.error("Error in updating document:", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const redisUrl = getRedisUrl();
|
||||
|
||||
if (redisUrl) {
|
||||
try {
|
||||
const redisClient = new Redis(redisUrl);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
redisClient.on("error", (error: any) => {
|
||||
if (error?.code === "ENOTFOUND" || error.message.includes("WRONGPASS") || error.message.includes("NOAUTH")) {
|
||||
redisClient.disconnect();
|
||||
}
|
||||
manualLogger.warn(
|
||||
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
|
||||
error
|
||||
);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
redisClient.on("ready", () => {
|
||||
extensions.push(new HocusPocusRedis({ redis: redisClient }));
|
||||
manualLogger.info("Redis Client connected ✅");
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
manualLogger.warn(
|
||||
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
|
||||
error
|
||||
);
|
||||
}
|
||||
} else {
|
||||
manualLogger.warn(
|
||||
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)"
|
||||
);
|
||||
}
|
||||
|
||||
return extensions;
|
||||
};
|
||||
@ -1,44 +0,0 @@
|
||||
// plane editor
|
||||
import {
|
||||
getAllDocumentFormatsFromDocumentEditorBinaryData,
|
||||
getAllDocumentFormatsFromRichTextEditorBinaryData,
|
||||
getBinaryDataFromDocumentEditorHTMLString,
|
||||
getBinaryDataFromRichTextEditorHTMLString,
|
||||
} from "@plane/editor";
|
||||
// plane types
|
||||
import { TDocumentPayload } from "@plane/types";
|
||||
|
||||
type TArgs = {
|
||||
document_html: string;
|
||||
variant: "rich" | "document";
|
||||
};
|
||||
|
||||
export const convertHTMLDocumentToAllFormats = (args: TArgs): TDocumentPayload => {
|
||||
const { document_html, variant } = args;
|
||||
|
||||
let allFormats: TDocumentPayload;
|
||||
|
||||
if (variant === "rich") {
|
||||
const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html);
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
||||
getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary);
|
||||
allFormats = {
|
||||
description: contentJSON,
|
||||
description_html: contentHTML,
|
||||
description_binary: contentBinaryEncoded,
|
||||
};
|
||||
} else if (variant === "document") {
|
||||
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
||||
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
|
||||
allFormats = {
|
||||
description: contentJSON,
|
||||
description_html: contentHTML,
|
||||
description_binary: contentBinaryEncoded,
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Invalid variant provided: ${variant}`);
|
||||
}
|
||||
|
||||
return allFormats;
|
||||
};
|
||||
@ -1,18 +0,0 @@
|
||||
import { ErrorRequestHandler } from "express";
|
||||
import { manualLogger } from "@/core/helpers/logger.js";
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (err, _req, res) => {
|
||||
// Log the error
|
||||
manualLogger.error(err);
|
||||
|
||||
// Set the response status
|
||||
res.status(err.status || 500);
|
||||
|
||||
// Send the response
|
||||
res.json({
|
||||
error: {
|
||||
message: process.env.NODE_ENV === "production" ? "An unexpected error occurred" : err.message,
|
||||
...(process.env.NODE_ENV !== "production" && { stack: err.stack }),
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,39 +0,0 @@
|
||||
import { pinoHttp } from "pino-http";
|
||||
|
||||
const transport = {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
},
|
||||
};
|
||||
|
||||
const hooks = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
logMethod(inputArgs: any, method: any): any {
|
||||
if (inputArgs.length >= 2) {
|
||||
const arg1 = inputArgs.shift();
|
||||
const arg2 = inputArgs.shift();
|
||||
return method.apply(this, [arg2, arg1, ...inputArgs]);
|
||||
}
|
||||
return method.apply(this, inputArgs);
|
||||
},
|
||||
};
|
||||
|
||||
export const logger = pinoHttp({
|
||||
level: "info",
|
||||
transport: transport,
|
||||
hooks: hooks,
|
||||
serializers: {
|
||||
req(req) {
|
||||
return `${req.method} ${req.url}`;
|
||||
},
|
||||
res(res) {
|
||||
return `${res.statusCode} ${res?.statusMessage || ""}`;
|
||||
},
|
||||
responseTime(time) {
|
||||
return `${time}ms`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const manualLogger: typeof logger.logger = logger.logger;
|
||||
@ -1,69 +0,0 @@
|
||||
import { Server } from "@hocuspocus/server";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// editor types
|
||||
import { TUserDetails } from "@plane/editor";
|
||||
import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib";
|
||||
// extensions
|
||||
import { getExtensions } from "@/core/extensions/index.js";
|
||||
// lib
|
||||
import { handleAuthentication } from "@/core/lib/authentication.js";
|
||||
// types
|
||||
import { type HocusPocusServerContext } from "@/core/types/common.js";
|
||||
|
||||
export const getHocusPocusServer = async () => {
|
||||
const extensions = await getExtensions();
|
||||
const serverName = process.env.HOSTNAME || uuidv4();
|
||||
return Server.configure({
|
||||
name: serverName,
|
||||
onAuthenticate: async ({
|
||||
requestHeaders,
|
||||
context,
|
||||
// user id used as token for authentication
|
||||
token,
|
||||
}) => {
|
||||
let cookie: string | undefined = undefined;
|
||||
let userId: string | undefined = undefined;
|
||||
|
||||
// Extract cookie (fallback to request headers) and userId from token (for scenarios where
|
||||
// the cookies are not passed in the request headers)
|
||||
try {
|
||||
const parsedToken = JSON.parse(token) as TUserDetails;
|
||||
userId = parsedToken.id;
|
||||
cookie = parsedToken.cookie;
|
||||
} catch (error) {
|
||||
// If token parsing fails, fallback to request headers
|
||||
console.error("Token parsing failed, using request headers:", error);
|
||||
} finally {
|
||||
// If cookie is still not found, fallback to request headers
|
||||
if (!cookie) {
|
||||
cookie = requestHeaders.cookie?.toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (!cookie || !userId) {
|
||||
throw new Error("Credentials not provided");
|
||||
}
|
||||
|
||||
// set cookie in context, so it can be used throughout the ws connection
|
||||
(context as HocusPocusServerContext).cookie = cookie;
|
||||
|
||||
try {
|
||||
await handleAuthentication({
|
||||
cookie,
|
||||
userId,
|
||||
});
|
||||
} catch (_error) {
|
||||
throw Error("Authentication unsuccessful!");
|
||||
}
|
||||
},
|
||||
async onStateless({ payload, document }) {
|
||||
// broadcast the client event (derived from the server event) to all the clients so that they can update their state
|
||||
const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
|
||||
if (response) {
|
||||
document.broadcastStateless(response);
|
||||
}
|
||||
},
|
||||
extensions,
|
||||
debounce: 10000,
|
||||
});
|
||||
};
|
||||
@ -1,33 +0,0 @@
|
||||
// core helpers
|
||||
import { manualLogger } from "@/core/helpers/logger.js";
|
||||
// services
|
||||
import { UserService } from "@/core/services/user.service.js";
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
type Props = {
|
||||
cookie: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export const handleAuthentication = async (props: Props) => {
|
||||
const { cookie, userId } = props;
|
||||
// fetch current user info
|
||||
let response;
|
||||
try {
|
||||
response = await userService.currentUser(cookie);
|
||||
} catch (error) {
|
||||
manualLogger.error("Failed to fetch current user:", error);
|
||||
throw error;
|
||||
}
|
||||
if (response.id !== userId) {
|
||||
throw Error("Authentication failed: Token doesn't match the current user.");
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: response.id,
|
||||
name: response.display_name,
|
||||
},
|
||||
};
|
||||
};
|
||||
@ -1,80 +0,0 @@
|
||||
// helpers
|
||||
import { getAllDocumentFormatsFromBinaryData, getBinaryDataFromHTMLString } from "@/core/helpers/page.js";
|
||||
// services
|
||||
import { PageService } from "@/core/services/page.service.js";
|
||||
import { manualLogger } from "../helpers/logger.js";
|
||||
const pageService = new PageService();
|
||||
|
||||
export const updatePageDescription = async (
|
||||
params: URLSearchParams,
|
||||
pageId: string,
|
||||
updatedDescription: Uint8Array,
|
||||
cookie: string | undefined
|
||||
) => {
|
||||
if (!(updatedDescription instanceof Uint8Array)) {
|
||||
throw new Error("Invalid updatedDescription: must be an instance of Uint8Array");
|
||||
}
|
||||
|
||||
const workspaceSlug = params.get("workspaceSlug")?.toString();
|
||||
const projectId = params.get("projectId")?.toString();
|
||||
if (!workspaceSlug || !projectId || !cookie) return;
|
||||
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromBinaryData(updatedDescription);
|
||||
try {
|
||||
const payload = {
|
||||
description_binary: contentBinaryEncoded,
|
||||
description_html: contentHTML,
|
||||
description: contentJSON,
|
||||
};
|
||||
|
||||
await pageService.updateDescription(workspaceSlug, projectId, pageId, payload, cookie);
|
||||
} catch (error) {
|
||||
manualLogger.error("Update error:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDescriptionHTMLAndTransform = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
pageId: string,
|
||||
cookie: string
|
||||
) => {
|
||||
if (!workspaceSlug || !projectId || !cookie) return;
|
||||
|
||||
try {
|
||||
const pageDetails = await pageService.fetchDetails(workspaceSlug, projectId, pageId, cookie);
|
||||
const { contentBinary } = getBinaryDataFromHTMLString(pageDetails.description_html ?? "<p></p>");
|
||||
return contentBinary;
|
||||
} catch (error) {
|
||||
manualLogger.error("Error while transforming from HTML to Uint8Array", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
const response = await pageService.fetchDescriptionBinary(workspaceSlug, projectId, pageId, cookie);
|
||||
const binaryData = new Uint8Array(response);
|
||||
|
||||
if (binaryData.byteLength === 0) {
|
||||
const binary = await fetchDescriptionHTMLAndTransform(workspaceSlug, projectId, pageId, cookie);
|
||||
if (binary) {
|
||||
return binary;
|
||||
}
|
||||
}
|
||||
|
||||
return binaryData;
|
||||
} catch (error) {
|
||||
manualLogger.error("Fetch error:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@ -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,58 +0,0 @@
|
||||
// types
|
||||
import { TPage } from "@plane/types";
|
||||
// services
|
||||
import { API_BASE_URL, APIService } from "@/core/services/api.service.js";
|
||||
|
||||
export class PageService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async fetchDetails(workspaceSlug: string, projectId: string, pageId: string, cookie: string): Promise<TPage> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`, {
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
},
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async fetchDescriptionBinary(workspaceSlug: string, projectId: string, pageId: string, cookie: string): Promise<any> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, {
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
Cookie: cookie,
|
||||
},
|
||||
responseType: "arraybuffer",
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async updateDescription(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
pageId: string,
|
||||
data: {
|
||||
description_binary: string;
|
||||
description_html: string;
|
||||
description: object;
|
||||
},
|
||||
cookie: string
|
||||
): Promise<any> {
|
||||
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, data, {
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
},
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
13
apps/live/src/core/types/common.d.ts
vendored
13
apps/live/src/core/types/common.d.ts
vendored
@ -1,13 +0,0 @@
|
||||
// types
|
||||
import { TAdditionalDocumentTypes } from "@/plane-live/types/common.js";
|
||||
|
||||
export type TDocumentTypes = "project_page" | TAdditionalDocumentTypes;
|
||||
|
||||
export type HocusPocusServerContext = {
|
||||
cookie: string;
|
||||
};
|
||||
|
||||
export type TConvertDocumentRequestBody = {
|
||||
description_html: string;
|
||||
variant: "rich" | "document";
|
||||
};
|
||||
@ -1 +0,0 @@
|
||||
export * from "../../ce/lib/fetch-document.js";
|
||||
@ -1 +0,0 @@
|
||||
export * from "../../ce/lib/update-document.js";
|
||||
1
apps/live/src/ee/types/common.d.ts
vendored
1
apps/live/src/ee/types/common.d.ts
vendored
@ -1 +0,0 @@
|
||||
export * from "../../ce/types/common.js";
|
||||
36
apps/live/src/env.ts
Normal file
36
apps/live/src/env.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import * as dotenv from "@dotenvx/dotenvx";
|
||||
import { z } from "zod";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
// Environment variable validation
|
||||
const envSchema = z.object({
|
||||
APP_VERSION: z.string().default("1.0.0"),
|
||||
HOSTNAME: z.string().optional(),
|
||||
PORT: z.string().default("3000"),
|
||||
API_BASE_URL: z.string().url("API_BASE_URL must be a valid URL"),
|
||||
// CORS configuration
|
||||
CORS_ALLOWED_ORIGINS: z.string().default(""),
|
||||
// Live running location
|
||||
LIVE_BASE_PATH: z.string().default("/live"),
|
||||
// Compression options
|
||||
COMPRESSION_LEVEL: z.string().default("6").transform(Number),
|
||||
COMPRESSION_THRESHOLD: z.string().default("5000").transform(Number),
|
||||
// secret
|
||||
LIVE_SERVER_SECRET_KEY: z.string(),
|
||||
// Redis configuration
|
||||
REDIS_HOST: z.string().optional(),
|
||||
REDIS_PORT: z.string().default("6379").transform(Number),
|
||||
REDIS_URL: z.string().optional(),
|
||||
});
|
||||
|
||||
const validateEnv = () => {
|
||||
const result = envSchema.safeParse(process.env);
|
||||
if (!result.success) {
|
||||
console.error("❌ Invalid environment variables:", JSON.stringify(result.error.format(), null, 4));
|
||||
process.exit(1);
|
||||
}
|
||||
return result.data;
|
||||
};
|
||||
|
||||
export const env = validateEnv();
|
||||
59
apps/live/src/extensions/database.ts
Normal file
59
apps/live/src/extensions/database.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { Database as HocuspocusDatabase } from "@hocuspocus/extension-database";
|
||||
// utils
|
||||
import {
|
||||
getAllDocumentFormatsFromDocumentEditorBinaryData,
|
||||
getBinaryDataFromDocumentEditorHTMLString,
|
||||
} from "@plane/editor";
|
||||
// logger
|
||||
import { logger } from "@plane/logger";
|
||||
// lib
|
||||
import { getPageService } from "@/services/page/handler";
|
||||
// type
|
||||
import type { FetchPayloadWithContext, StorePayloadWithContext } from "@/types";
|
||||
|
||||
const fetchDocument = async ({ context, documentName: pageId }: FetchPayloadWithContext) => {
|
||||
try {
|
||||
const service = getPageService(context.documentType, context);
|
||||
// fetch details
|
||||
const response = await service.fetchDescriptionBinary(pageId);
|
||||
const binaryData = new Uint8Array(response);
|
||||
// if binary data is empty, convert HTML to binary data
|
||||
if (binaryData.byteLength === 0) {
|
||||
const pageDetails = await service.fetchDetails(pageId);
|
||||
const convertedBinaryData = getBinaryDataFromDocumentEditorHTMLString(pageDetails.description_html ?? "<p></p>");
|
||||
if (convertedBinaryData) {
|
||||
return convertedBinaryData;
|
||||
}
|
||||
}
|
||||
// return binary data
|
||||
return binaryData;
|
||||
} catch (error) {
|
||||
logger.error("Error in fetching document", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const storeDocument = async ({ context, state: pageBinaryData, documentName: pageId }: StorePayloadWithContext) => {
|
||||
try {
|
||||
const service = getPageService(context.documentType, context);
|
||||
// convert binary data to all formats
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
||||
getAllDocumentFormatsFromDocumentEditorBinaryData(pageBinaryData);
|
||||
// create payload
|
||||
const payload = {
|
||||
description_binary: contentBinaryEncoded,
|
||||
description_html: contentHTML,
|
||||
description: contentJSON,
|
||||
};
|
||||
return service.updateDescriptionBinary(pageId, payload);
|
||||
} catch (error) {
|
||||
logger.error("Error in updating document:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export class Database extends HocuspocusDatabase {
|
||||
constructor() {
|
||||
super({ fetch: fetchDocument, store: storeDocument });
|
||||
}
|
||||
}
|
||||
5
apps/live/src/extensions/index.ts
Normal file
5
apps/live/src/extensions/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Database } from "./database";
|
||||
import { Logger } from "./logger";
|
||||
import { Redis } from "./redis";
|
||||
|
||||
export const getExtensions = () => [new Logger(), new Database(), new Redis()];
|
||||
13
apps/live/src/extensions/logger.ts
Normal file
13
apps/live/src/extensions/logger.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Logger as HocuspocusLogger } from "@hocuspocus/extension-logger";
|
||||
import { logger } from "@plane/logger";
|
||||
|
||||
export class Logger extends HocuspocusLogger {
|
||||
constructor() {
|
||||
super({
|
||||
onChange: false,
|
||||
log: (message) => {
|
||||
logger.info(message);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
31
apps/live/src/extensions/redis.ts
Normal file
31
apps/live/src/extensions/redis.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { Redis as HocuspocusRedis } from "@hocuspocus/extension-redis";
|
||||
import { OutgoingMessage } from "@hocuspocus/server";
|
||||
// redis
|
||||
import { redisManager } from "@/redis";
|
||||
|
||||
const getRedisClient = () => {
|
||||
const redisClient = redisManager.getClient();
|
||||
if (!redisClient) {
|
||||
throw new Error("Redis client not initialized");
|
||||
}
|
||||
return redisClient;
|
||||
};
|
||||
|
||||
export class Redis extends HocuspocusRedis {
|
||||
constructor() {
|
||||
super({ redis: getRedisClient() });
|
||||
}
|
||||
|
||||
public broadcastToDocument(documentName: string, payload: any): Promise<number> {
|
||||
const stringPayload = typeof payload === "string" ? payload : JSON.stringify(payload);
|
||||
const message = new OutgoingMessage(documentName).writeBroadcastStateless(stringPayload);
|
||||
|
||||
const emptyPrefix = Buffer.concat([Buffer.from([0])]);
|
||||
|
||||
return this.pub.publishBuffer(
|
||||
// we're accessing the private method of the hocuspocus redis extension
|
||||
this["pubKey"](documentName),
|
||||
Buffer.concat([emptyPrefix, Buffer.from(message.toUint8Array())])
|
||||
);
|
||||
}
|
||||
}
|
||||
63
apps/live/src/hocuspocus.ts
Normal file
63
apps/live/src/hocuspocus.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { Server, Hocuspocus } from "@hocuspocus/server";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// env
|
||||
import { env } from "@/env";
|
||||
// extensions
|
||||
import { getExtensions } from "@/extensions";
|
||||
// lib
|
||||
import { onAuthenticate } from "@/lib/auth";
|
||||
import { onStateless } from "@/lib/stateless";
|
||||
|
||||
export class HocusPocusServerManager {
|
||||
private static instance: HocusPocusServerManager | null = null;
|
||||
private server: Hocuspocus | null = null;
|
||||
// server options
|
||||
private serverName = env.HOSTNAME || uuidv4();
|
||||
|
||||
private constructor() {
|
||||
// Private constructor to prevent direct instantiation
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of HocusPocusServerManager
|
||||
*/
|
||||
public static getInstance(): HocusPocusServerManager {
|
||||
if (!HocusPocusServerManager.instance) {
|
||||
HocusPocusServerManager.instance = new HocusPocusServerManager();
|
||||
}
|
||||
return HocusPocusServerManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and configure the HocusPocus server
|
||||
*/
|
||||
public async initialize(): Promise<Hocuspocus> {
|
||||
if (this.server) {
|
||||
return this.server;
|
||||
}
|
||||
|
||||
this.server = Server.configure({
|
||||
name: this.serverName,
|
||||
onAuthenticate,
|
||||
onStateless,
|
||||
extensions: getExtensions(),
|
||||
debounce: 10000,
|
||||
});
|
||||
|
||||
return this.server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured server instance
|
||||
*/
|
||||
public getServer(): Hocuspocus | null {
|
||||
return this.server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (useful for testing)
|
||||
*/
|
||||
public static resetInstance(): void {
|
||||
HocusPocusServerManager.instance = null;
|
||||
}
|
||||
}
|
||||
82
apps/live/src/lib/auth.ts
Normal file
82
apps/live/src/lib/auth.ts
Normal file
@ -0,0 +1,82 @@
|
||||
// plane imports
|
||||
import type { IncomingHttpHeaders } from "http";
|
||||
import type { TUserDetails } from "@plane/editor";
|
||||
import { logger } from "@plane/logger";
|
||||
// services
|
||||
import { UserService } from "@/services/user.service";
|
||||
// types
|
||||
import type { HocusPocusServerContext, TDocumentTypes } from "@/types";
|
||||
|
||||
/**
|
||||
* Authenticate the user
|
||||
* @param requestHeaders - The request headers
|
||||
* @param context - The context
|
||||
* @param token - The token
|
||||
* @returns The authenticated user
|
||||
*/
|
||||
export const onAuthenticate = async ({
|
||||
requestHeaders,
|
||||
requestParameters,
|
||||
context,
|
||||
token,
|
||||
}: {
|
||||
requestHeaders: IncomingHttpHeaders;
|
||||
context: HocusPocusServerContext;
|
||||
requestParameters: URLSearchParams;
|
||||
token: string;
|
||||
}) => {
|
||||
let cookie: string | undefined = undefined;
|
||||
let userId: string | undefined = undefined;
|
||||
|
||||
// Extract cookie (fallback to request headers) and userId from token (for scenarios where
|
||||
// the cookies are not passed in the request headers)
|
||||
try {
|
||||
const parsedToken = JSON.parse(token) as TUserDetails;
|
||||
userId = parsedToken.id;
|
||||
cookie = parsedToken.cookie;
|
||||
} catch (error) {
|
||||
// If token parsing fails, fallback to request headers
|
||||
logger.error("Token parsing failed, using request headers:", error);
|
||||
} finally {
|
||||
// If cookie is still not found, fallback to request headers
|
||||
if (!cookie) {
|
||||
cookie = requestHeaders.cookie?.toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (!cookie || !userId) {
|
||||
throw new Error("Credentials not provided");
|
||||
}
|
||||
|
||||
// set cookie in context, so it can be used throughout the ws connection
|
||||
context.cookie = cookie ?? requestParameters.get("cookie") ?? "";
|
||||
context.documentType = requestParameters.get("documentType")?.toString() as TDocumentTypes;
|
||||
context.projectId = requestParameters.get("projectId");
|
||||
context.userId = userId;
|
||||
context.workspaceSlug = requestParameters.get("workspaceSlug");
|
||||
|
||||
return await handleAuthentication({
|
||||
cookie: context.cookie,
|
||||
userId: context.userId,
|
||||
});
|
||||
};
|
||||
|
||||
export const handleAuthentication = async ({ cookie, userId }: { cookie: string; userId: string }) => {
|
||||
// fetch current user info
|
||||
try {
|
||||
const userService = new UserService();
|
||||
const user = await userService.currentUser(cookie);
|
||||
if (user.id !== userId) {
|
||||
throw new Error("Authentication unsuccessful!");
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.display_name,
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
throw Error("Authentication unsuccessful!");
|
||||
}
|
||||
};
|
||||
13
apps/live/src/lib/stateless.ts
Normal file
13
apps/live/src/lib/stateless.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { onStatelessPayload } from "@hocuspocus/server";
|
||||
import { DocumentCollaborativeEvents, type TDocumentEventsServer } from "@plane/editor/lib";
|
||||
|
||||
/**
|
||||
* Broadcast the client event to all the clients so that they can update their state
|
||||
* @param param0
|
||||
*/
|
||||
export const onStateless = async ({ payload, document }: onStatelessPayload) => {
|
||||
const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
|
||||
if (response) {
|
||||
document.broadcastStateless(response);
|
||||
}
|
||||
};
|
||||
211
apps/live/src/redis.ts
Normal file
211
apps/live/src/redis.ts
Normal file
@ -0,0 +1,211 @@
|
||||
import Redis from "ioredis";
|
||||
import { logger } from "@plane/logger";
|
||||
import { env } from "./env";
|
||||
|
||||
export class RedisManager {
|
||||
private static instance: RedisManager;
|
||||
private redisClient: Redis | null = null;
|
||||
private isConnected: boolean = false;
|
||||
private connectionPromise: Promise<void> | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): RedisManager {
|
||||
if (!RedisManager.instance) {
|
||||
RedisManager.instance = new RedisManager();
|
||||
}
|
||||
return RedisManager.instance;
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
if (this.redisClient && this.isConnected) {
|
||||
logger.info("Redis client already initialized and connected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.connectionPromise) {
|
||||
logger.info("Redis connection already in progress, waiting...");
|
||||
await this.connectionPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
this.connectionPromise = this.connect();
|
||||
await this.connectionPromise;
|
||||
}
|
||||
|
||||
private getRedisUrl(): string {
|
||||
const redisUrl = env.REDIS_URL;
|
||||
const redisHost = env.REDIS_HOST;
|
||||
const redisPort = env.REDIS_PORT;
|
||||
|
||||
if (redisUrl) {
|
||||
return redisUrl;
|
||||
}
|
||||
|
||||
if (redisHost && redisPort && !Number.isNaN(Number(redisPort))) {
|
||||
return `redis://${redisHost}:${redisPort}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
try {
|
||||
const redisUrl = this.getRedisUrl();
|
||||
|
||||
if (!redisUrl) {
|
||||
logger.warn("No Redis URL provided, Redis functionality will be disabled");
|
||||
this.isConnected = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.redisClient = new Redis(redisUrl, {
|
||||
lazyConnect: true,
|
||||
keepAlive: 30000,
|
||||
connectTimeout: 10000,
|
||||
commandTimeout: 5000,
|
||||
// enableOfflineQueue: false,
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
|
||||
// Set up event listeners
|
||||
this.redisClient.on("connect", () => {
|
||||
logger.info("Redis client connected");
|
||||
this.isConnected = true;
|
||||
});
|
||||
|
||||
this.redisClient.on("ready", () => {
|
||||
logger.info("Redis client ready");
|
||||
this.isConnected = true;
|
||||
});
|
||||
|
||||
this.redisClient.on("error", (error) => {
|
||||
logger.error("Redis client error:", error);
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
this.redisClient.on("close", () => {
|
||||
logger.warn("Redis client connection closed");
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
this.redisClient.on("reconnecting", () => {
|
||||
logger.info("Redis client reconnecting...");
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
// Connect to Redis
|
||||
await this.redisClient.connect();
|
||||
|
||||
// Test the connection
|
||||
await this.redisClient.ping();
|
||||
logger.info("Redis connection test successful");
|
||||
} catch (error) {
|
||||
logger.error("Failed to initialize Redis client:", error);
|
||||
this.isConnected = false;
|
||||
throw error;
|
||||
} finally {
|
||||
this.connectionPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
public getClient(): Redis | null {
|
||||
if (!this.redisClient || !this.isConnected) {
|
||||
logger.warn("Redis client not available or not connected");
|
||||
return null;
|
||||
}
|
||||
return this.redisClient;
|
||||
}
|
||||
|
||||
public isClientConnected(): boolean {
|
||||
return this.isConnected && this.redisClient !== null;
|
||||
}
|
||||
|
||||
public async disconnect(): Promise<void> {
|
||||
if (this.redisClient) {
|
||||
try {
|
||||
await this.redisClient.quit();
|
||||
logger.info("Redis client disconnected gracefully");
|
||||
} catch (error) {
|
||||
logger.error("Error disconnecting Redis client:", error);
|
||||
// Force disconnect if quit fails
|
||||
this.redisClient.disconnect();
|
||||
} finally {
|
||||
this.redisClient = null;
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience methods for common Redis operations
|
||||
public async set(key: string, value: string, ttl?: number): Promise<boolean> {
|
||||
const client = this.getClient();
|
||||
if (!client) return false;
|
||||
|
||||
try {
|
||||
if (ttl) {
|
||||
await client.setex(key, ttl, value);
|
||||
} else {
|
||||
await client.set(key, value);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Error setting Redis key ${key}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async get(key: string): Promise<string | null> {
|
||||
const client = this.getClient();
|
||||
if (!client) return null;
|
||||
|
||||
try {
|
||||
return await client.get(key);
|
||||
} catch (error) {
|
||||
logger.error(`Error getting Redis key ${key}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async del(key: string): Promise<boolean> {
|
||||
const client = this.getClient();
|
||||
if (!client) return false;
|
||||
|
||||
try {
|
||||
await client.del(key);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Error deleting Redis key ${key}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async exists(key: string): Promise<boolean> {
|
||||
const client = this.getClient();
|
||||
if (!client) return false;
|
||||
|
||||
try {
|
||||
const result = await client.exists(key);
|
||||
return result === 1;
|
||||
} catch (error) {
|
||||
logger.error(`Error checking Redis key ${key}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async expire(key: string, ttl: number): Promise<boolean> {
|
||||
const client = this.getClient();
|
||||
if (!client) return false;
|
||||
|
||||
try {
|
||||
const result = await client.expire(key, ttl);
|
||||
return result === 1;
|
||||
} catch (error) {
|
||||
logger.error(`Error setting expiry for Redis key ${key}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export a default instance for convenience
|
||||
export const redisManager = RedisManager.getInstance();
|
||||
@ -1,93 +1,80 @@
|
||||
import { Server as HttpServer } from "http";
|
||||
import { type Hocuspocus } from "@hocuspocus/server";
|
||||
import compression from "compression";
|
||||
import cors from "cors";
|
||||
import express, { Request, Response } from "express";
|
||||
import express, { Express, Request, Response, Router } from "express";
|
||||
import expressWs from "express-ws";
|
||||
import helmet from "helmet";
|
||||
// plane imports
|
||||
import { registerController } from "@plane/decorators";
|
||||
import { logger, loggerMiddleware } from "@plane/logger";
|
||||
// controllers
|
||||
import { CONTROLLERS } from "@/controllers";
|
||||
// env
|
||||
import { env } from "@/env";
|
||||
// hocuspocus server
|
||||
// helpers
|
||||
import { convertHTMLDocumentToAllFormats } from "@/core/helpers/convert-document.js";
|
||||
import { logger, manualLogger } from "@/core/helpers/logger.js";
|
||||
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
|
||||
// types
|
||||
import { TConvertDocumentRequestBody } from "@/core/types/common.js";
|
||||
import { HocusPocusServerManager } from "@/hocuspocus";
|
||||
// redis
|
||||
import { redisManager } from "@/redis";
|
||||
|
||||
export class Server {
|
||||
private app: any;
|
||||
private router: any;
|
||||
private hocuspocusServer: any;
|
||||
private serverInstance: any;
|
||||
private app: Express;
|
||||
private router: Router;
|
||||
private hocuspocusServer: Hocuspocus | undefined;
|
||||
private httpServer: HttpServer | undefined;
|
||||
|
||||
constructor() {
|
||||
this.app = express();
|
||||
this.router = express.Router();
|
||||
expressWs(this.app);
|
||||
this.app.set("port", process.env.PORT || 3000);
|
||||
this.setupMiddleware();
|
||||
this.setupHocusPocus();
|
||||
this.setupRoutes();
|
||||
this.router = express.Router();
|
||||
this.app.set("port", env.PORT || 3000);
|
||||
this.app.use(env.LIVE_BASE_PATH, this.router);
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
try {
|
||||
await redisManager.initialize();
|
||||
logger.info("Redis setup completed");
|
||||
const manager = HocusPocusServerManager.getInstance();
|
||||
this.hocuspocusServer = await manager.initialize();
|
||||
logger.info("HocusPocus setup completed");
|
||||
|
||||
this.setupRoutes(this.hocuspocusServer);
|
||||
this.setupNotFoundHandler();
|
||||
} catch (error) {
|
||||
logger.error("Failed to initialize live server dependencies:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private setupMiddleware() {
|
||||
// Security middleware
|
||||
this.app.use(helmet());
|
||||
// Middleware for response compression
|
||||
this.app.use(compression({ level: 6, threshold: 5 * 1000 }));
|
||||
this.app.use(compression({ level: env.COMPRESSION_LEVEL, threshold: env.COMPRESSION_THRESHOLD }));
|
||||
// Logging middleware
|
||||
this.app.use(logger);
|
||||
this.app.use(loggerMiddleware);
|
||||
// Body parsing middleware
|
||||
this.app.use(express.json());
|
||||
this.app.use(express.urlencoded({ extended: true }));
|
||||
// cors middleware
|
||||
this.app.use(cors());
|
||||
this.app.use(process.env.LIVE_BASE_PATH || "/live", this.router);
|
||||
this.setupCors();
|
||||
}
|
||||
|
||||
private async setupHocusPocus() {
|
||||
this.hocuspocusServer = await getHocusPocusServer().catch((err) => {
|
||||
manualLogger.error("Failed to initialize HocusPocusServer:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
private setupCors() {
|
||||
const allowedOrigins = env.CORS_ALLOWED_ORIGINS.split(",").map((s) => s.trim());
|
||||
this.app.use(
|
||||
cors({
|
||||
origin: allowedOrigins.length > 0 ? allowedOrigins : false,
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "x-api-key"],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private setupRoutes() {
|
||||
this.router.get("/health", (_req: Request, res: Response) => {
|
||||
res.status(200).json({ status: "OK" });
|
||||
});
|
||||
|
||||
this.router.ws("/collaboration", (ws: any, req: Request) => {
|
||||
try {
|
||||
this.hocuspocusServer.handleConnection(ws, req);
|
||||
} catch (err) {
|
||||
manualLogger.error("WebSocket connection error:", err);
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
this.router.post("/convert-document", (req: Request, res: Response) => {
|
||||
const { description_html, variant } = req.body as TConvertDocumentRequestBody;
|
||||
try {
|
||||
if (description_html === undefined || variant === undefined) {
|
||||
res.status(400).send({
|
||||
message: "Missing required fields",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { description, description_binary } = convertHTMLDocumentToAllFormats({
|
||||
document_html: description_html,
|
||||
variant,
|
||||
});
|
||||
res.status(200).json({
|
||||
description,
|
||||
description_binary,
|
||||
});
|
||||
} catch (error) {
|
||||
manualLogger.error("Error in /convert-document endpoint:", error);
|
||||
res.status(500).json({
|
||||
message: `Internal server error.`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
private setupNotFoundHandler() {
|
||||
this.app.use((_req: Request, res: Response) => {
|
||||
res.status(404).json({
|
||||
message: "Not Found",
|
||||
@ -95,37 +82,41 @@ export class Server {
|
||||
});
|
||||
}
|
||||
|
||||
private setupRoutes(hocuspocusServer: Hocuspocus) {
|
||||
CONTROLLERS.forEach((controller) => registerController(this.router, controller, [hocuspocusServer]));
|
||||
}
|
||||
|
||||
public listen() {
|
||||
this.serverInstance = this.app.listen(this.app.get("port"), () => {
|
||||
manualLogger.info(`Plane Live server has started at port ${this.app.get("port")}`);
|
||||
});
|
||||
this.httpServer = this.app
|
||||
.listen(this.app.get("port"), () => {
|
||||
logger.info(`Plane Live server has started at port ${this.app.get("port")}`);
|
||||
})
|
||||
.on("error", (err) => {
|
||||
logger.error("Failed to start server:", err);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
public async destroy() {
|
||||
// Close the HocusPocus server WebSocket connections
|
||||
await this.hocuspocusServer.destroy();
|
||||
manualLogger.info("HocusPocus server WebSocket connections closed gracefully.");
|
||||
// Close the Express server
|
||||
this.serverInstance.close(() => {
|
||||
manualLogger.info("Express server closed gracefully.");
|
||||
process.exit(1);
|
||||
});
|
||||
if (this.hocuspocusServer) {
|
||||
await this.hocuspocusServer.destroy();
|
||||
logger.info("HocusPocus server closed gracefully.");
|
||||
}
|
||||
|
||||
await redisManager.disconnect();
|
||||
logger.info("Redis connection closed gracefully.");
|
||||
|
||||
if (this.httpServer) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.httpServer!.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
logger.info("Express server closed gracefully.");
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const server = new Server();
|
||||
server.listen();
|
||||
|
||||
// Graceful shutdown on unhandled rejection
|
||||
process.on("unhandledRejection", async (err: any) => {
|
||||
manualLogger.info("Unhandled Rejection: ", err);
|
||||
manualLogger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
|
||||
await server.destroy();
|
||||
});
|
||||
|
||||
// Graceful shutdown on uncaught exception
|
||||
process.on("uncaughtException", async (err: any) => {
|
||||
manualLogger.info("Uncaught Exception: ", err);
|
||||
manualLogger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
|
||||
await server.destroy();
|
||||
});
|
||||
|
||||
@ -1,23 +1,28 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import { config } from "dotenv";
|
||||
|
||||
config();
|
||||
|
||||
export const API_BASE_URL = process.env.API_BASE_URL ?? "";
|
||||
import { env } from "@/env";
|
||||
|
||||
export abstract class APIService {
|
||||
protected baseURL: string;
|
||||
private axiosInstance: AxiosInstance;
|
||||
private header: Record<string, string> = {};
|
||||
|
||||
constructor(baseURL: string) {
|
||||
this.baseURL = baseURL;
|
||||
constructor(baseURL?: string) {
|
||||
this.baseURL = baseURL || env.API_BASE_URL;
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL,
|
||||
baseURL: this.baseURL,
|
||||
withCredentials: true,
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
setHeader(key: string, value: string) {
|
||||
this.header[key] = value;
|
||||
}
|
||||
|
||||
getHeader() {
|
||||
return this.header;
|
||||
}
|
||||
|
||||
get(url: string, params = {}, config = {}) {
|
||||
return this.axiosInstance.get(url, {
|
||||
...params,
|
||||
99
apps/live/src/services/page/core.service.ts
Normal file
99
apps/live/src/services/page/core.service.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { TPage } from "@plane/types";
|
||||
// services
|
||||
import { APIService } from "../api.service";
|
||||
|
||||
export type TPageDescriptionPayload = {
|
||||
description_binary: string;
|
||||
description_html: string;
|
||||
description: object;
|
||||
};
|
||||
|
||||
export abstract class PageCoreService extends APIService {
|
||||
protected abstract basePath: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
async fetchDetails(pageId: string): Promise<TPage> {
|
||||
return this.get(`${this.basePath}/pages/${pageId}/`, {
|
||||
headers: this.getHeader(),
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async fetchDescriptionBinary(pageId: string): Promise<any> {
|
||||
return this.get(`${this.basePath}/pages/${pageId}/description/`, {
|
||||
headers: {
|
||||
...this.getHeader(),
|
||||
"Content-Type": "application/octet-stream",
|
||||
},
|
||||
responseType: "arraybuffer",
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the title of a page
|
||||
*/
|
||||
async updatePageProperties(
|
||||
pageId: string,
|
||||
params: { data: Partial<TPage>; abortSignal?: AbortSignal }
|
||||
): Promise<TPage> {
|
||||
const { data, abortSignal } = params;
|
||||
|
||||
// Early abort check
|
||||
if (abortSignal?.aborted) {
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
|
||||
// Create an abort listener that will reject the pending promise
|
||||
let abortListener: (() => void) | undefined;
|
||||
const abortPromise = new Promise((_, reject) => {
|
||||
if (abortSignal) {
|
||||
abortListener = () => {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
abortSignal.addEventListener("abort", abortListener);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
this.patch(`${this.basePath}/pages/${pageId}`, data, {
|
||||
headers: this.getHeader(),
|
||||
signal: abortSignal,
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
if (error.name === "AbortError") {
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
throw error;
|
||||
}),
|
||||
abortPromise,
|
||||
]);
|
||||
} finally {
|
||||
// Clean up abort listener
|
||||
if (abortSignal && abortListener) {
|
||||
abortSignal.removeEventListener("abort", abortListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateDescriptionBinary(pageId: string, data: TPageDescriptionPayload): Promise<any> {
|
||||
return this.patch(`${this.basePath}/pages/${pageId}/description/`, data, {
|
||||
headers: this.getHeader(),
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
12
apps/live/src/services/page/extended.service.ts
Normal file
12
apps/live/src/services/page/extended.service.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { PageCoreService } from "./core.service";
|
||||
|
||||
/**
|
||||
* This is the extended service for the page service.
|
||||
* It extends the core service and adds additional functionality.
|
||||
* Implementation for this is found in the enterprise repository.
|
||||
*/
|
||||
export abstract class PageService extends PageCoreService {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
15
apps/live/src/services/page/handler.ts
Normal file
15
apps/live/src/services/page/handler.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import type { HocusPocusServerContext, TDocumentTypes } from "@/types";
|
||||
// services
|
||||
import { ProjectPageService } from "./project-page.service";
|
||||
|
||||
export const getPageService = (documentType: TDocumentTypes, context: HocusPocusServerContext) => {
|
||||
if (documentType === "project_page") {
|
||||
return new ProjectPageService({
|
||||
workspaceSlug: context.workspaceSlug,
|
||||
projectId: context.projectId,
|
||||
cookie: context.cookie,
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Invalid document type ${documentType} provided.`);
|
||||
};
|
||||
24
apps/live/src/services/page/project-page.service.ts
Normal file
24
apps/live/src/services/page/project-page.service.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { PageService } from "./extended.service";
|
||||
|
||||
interface ProjectPageServiceParams {
|
||||
workspaceSlug: string | null;
|
||||
projectId: string | null;
|
||||
cookie: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class ProjectPageService extends PageService {
|
||||
protected basePath: string;
|
||||
|
||||
constructor(params: ProjectPageServiceParams) {
|
||||
super();
|
||||
const { workspaceSlug, projectId } = params;
|
||||
if (!workspaceSlug || !projectId) throw new Error("Missing required fields.");
|
||||
// validate cookie
|
||||
if (!params.cookie) throw new Error("Cookie is required.");
|
||||
// set cookie
|
||||
this.setHeader("Cookie", params.cookie);
|
||||
// set base path
|
||||
this.basePath = `/api/workspaces/${workspaceSlug}/projects/${projectId}`;
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,11 @@
|
||||
// types
|
||||
import type { IUser } from "@plane/types";
|
||||
// services
|
||||
import { API_BASE_URL, APIService } from "@/core/services/api.service.js";
|
||||
import { APIService } from "@/services/api.service";
|
||||
|
||||
export class UserService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
super();
|
||||
}
|
||||
|
||||
currentUserConfig() {
|
||||
43
apps/live/src/start.ts
Normal file
43
apps/live/src/start.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { logger } from "@plane/logger";
|
||||
import { Server } from "./server";
|
||||
|
||||
let server: Server;
|
||||
|
||||
async function startServer() {
|
||||
server = new Server();
|
||||
try {
|
||||
await server.initialize();
|
||||
server.listen();
|
||||
} catch (error) {
|
||||
logger.error("Failed to start server:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
startServer();
|
||||
|
||||
// Graceful shutdown on unhandled rejection
|
||||
process.on("unhandledRejection", async (err: Error) => {
|
||||
logger.error(`UNHANDLED REJECTION! 💥 Shutting down...`, err);
|
||||
try {
|
||||
if (server) {
|
||||
await server.destroy();
|
||||
}
|
||||
} finally {
|
||||
logger.info("Exiting process...");
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Graceful shutdown on uncaught exception
|
||||
process.on("uncaughtException", async (err: Error) => {
|
||||
logger.error(`UNCAUGHT EXCEPTION! 💥 Shutting down...`, err);
|
||||
try {
|
||||
if (server) {
|
||||
await server.destroy();
|
||||
}
|
||||
} finally {
|
||||
logger.info("Exiting process...");
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
29
apps/live/src/types/index.ts
Normal file
29
apps/live/src/types/index.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import type { fetchPayload, onLoadDocumentPayload, storePayload } from "@hocuspocus/server";
|
||||
|
||||
export type TConvertDocumentRequestBody = {
|
||||
description_html: string;
|
||||
variant: "rich" | "document";
|
||||
};
|
||||
|
||||
export interface OnLoadDocumentPayloadWithContext extends onLoadDocumentPayload {
|
||||
context: HocusPocusServerContext;
|
||||
}
|
||||
|
||||
export interface FetchPayloadWithContext extends fetchPayload {
|
||||
context: HocusPocusServerContext;
|
||||
}
|
||||
|
||||
export interface StorePayloadWithContext extends storePayload {
|
||||
context: HocusPocusServerContext;
|
||||
}
|
||||
|
||||
export type TDocumentTypes = "project_page";
|
||||
|
||||
// Additional Hocuspocus types that are not exported from the main package
|
||||
export type HocusPocusServerContext = {
|
||||
projectId: string | null;
|
||||
cookie: string;
|
||||
documentType: TDocumentTypes;
|
||||
workspaceSlug: string | null;
|
||||
userId: string;
|
||||
};
|
||||
@ -3,11 +3,52 @@ import { generateHTML, generateJSON } from "@tiptap/html";
|
||||
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
// plane editor
|
||||
import {
|
||||
getAllDocumentFormatsFromDocumentEditorBinaryData,
|
||||
getAllDocumentFormatsFromRichTextEditorBinaryData,
|
||||
getBinaryDataFromDocumentEditorHTMLString,
|
||||
getBinaryDataFromRichTextEditorHTMLString,
|
||||
} from "@plane/editor";
|
||||
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
|
||||
// plane types
|
||||
import { TDocumentPayload } from "@plane/types";
|
||||
|
||||
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
|
||||
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
||||
type TArgs = {
|
||||
document_html: string;
|
||||
variant: "rich" | "document";
|
||||
};
|
||||
|
||||
export const convertHTMLDocumentToAllFormats = (args: TArgs): TDocumentPayload => {
|
||||
const { document_html, variant } = args;
|
||||
|
||||
if (variant === "rich") {
|
||||
const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html);
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
||||
getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary);
|
||||
return {
|
||||
description: contentJSON,
|
||||
description_html: contentHTML,
|
||||
description_binary: contentBinaryEncoded,
|
||||
};
|
||||
}
|
||||
|
||||
if (variant === "document") {
|
||||
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
||||
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
|
||||
return {
|
||||
description: contentJSON,
|
||||
description_html: contentHTML,
|
||||
description_binary: contentBinaryEncoded,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Invalid variant provided: ${variant}`);
|
||||
};
|
||||
|
||||
export const getAllDocumentFormatsFromBinaryData = (
|
||||
description: Uint8Array
|
||||
): {
|
||||
@ -32,19 +73,11 @@ export const getAllDocumentFormatsFromBinaryData = (
|
||||
};
|
||||
};
|
||||
|
||||
export const getBinaryDataFromHTMLString = (
|
||||
descriptionHTML: string
|
||||
): {
|
||||
contentBinary: Uint8Array;
|
||||
} => {
|
||||
export const getBinaryDataFromHTMLString = (descriptionHTML: string): Uint8Array => {
|
||||
// convert HTML to JSON
|
||||
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", DOCUMENT_EDITOR_EXTENSIONS);
|
||||
// convert JSON to Y.Doc format
|
||||
const transformedData = prosemirrorJSONToYDoc(documentEditorSchema, contentJSON, "default");
|
||||
// convert Y.Doc to Uint8Array format
|
||||
const encodedData = Y.encodeStateAsUpdate(transformedData);
|
||||
|
||||
return {
|
||||
contentBinary: encodedData,
|
||||
};
|
||||
return Y.encodeStateAsUpdate(transformedData);
|
||||
};
|
||||
1
apps/live/src/utils/index.ts
Normal file
1
apps/live/src/utils/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./document";
|
||||
@ -19,8 +19,9 @@
|
||||
"inlineSources": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"sourceRoot": "/"
|
||||
"sourceRoot": "/",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tsdown.config.ts"],
|
||||
"exclude": ["./dist", "./build", "./node_modules"]
|
||||
"exclude": ["./dist", "./build", "./node_modules", "**/*.d.ts"]
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { defineConfig } from "tsdown";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/server.ts"],
|
||||
entry: ["src/start.ts"],
|
||||
outDir: "dist",
|
||||
format: ["esm", "cjs"],
|
||||
});
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"dev": "tsdown --watch",
|
||||
"check:lint": "eslint . --max-warnings 1",
|
||||
"check:lint": "eslint . --max-warnings 2",
|
||||
"check:types": "tsc --noEmit",
|
||||
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
|
||||
"fix:lint": "eslint . --fix",
|
||||
|
||||
@ -3,46 +3,48 @@ import type { WebSocket } from "ws";
|
||||
|
||||
import "reflect-metadata";
|
||||
|
||||
type HttpMethod = "get" | "post" | "put" | "delete" | "patch" | "options" | "head" | "ws";
|
||||
export type HttpMethod = "get" | "post" | "put" | "delete" | "patch" | "options" | "head" | "ws";
|
||||
|
||||
interface ControllerInstance {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
type ControllerInstance = {
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
interface ControllerConstructor {
|
||||
new (...args: unknown[]): ControllerInstance;
|
||||
export type ControllerConstructor = {
|
||||
new (...args: any[]): ControllerInstance;
|
||||
prototype: ControllerInstance;
|
||||
}
|
||||
};
|
||||
|
||||
export function registerControllers(
|
||||
export function registerController(
|
||||
router: Router,
|
||||
controllers: ControllerConstructor[],
|
||||
dependencies: any[] = []
|
||||
Controller: ControllerConstructor,
|
||||
dependencies: unknown[] = []
|
||||
): void {
|
||||
controllers.forEach((Controller) => {
|
||||
// Create the controller instance with dependencies
|
||||
const instance = new Controller(...dependencies);
|
||||
// Create the controller instance with dependencies
|
||||
const instance = new Controller(...dependencies);
|
||||
|
||||
// Determine if it's a WebSocket controller or REST controller by checking
|
||||
// if it has any methods with the "ws" method metadata
|
||||
const isWebsocket = Object.getOwnPropertyNames(Controller.prototype).some((methodName) => {
|
||||
if (methodName === "constructor") return false;
|
||||
return Reflect.getMetadata("method", instance, methodName) === "ws";
|
||||
});
|
||||
|
||||
if (isWebsocket) {
|
||||
// Register as WebSocket controller
|
||||
// Pass the existing instance with dependencies to avoid creating a new instance without them
|
||||
registerWebSocketController(router, Controller, instance);
|
||||
} else {
|
||||
// Register as REST controller - doesn't accept an instance parameter
|
||||
registerRestController(router, Controller);
|
||||
}
|
||||
// Determine if it's a WebSocket controller or REST controller by checking
|
||||
// if it has any methods with the "ws" method metadata
|
||||
const isWebsocket = Object.getOwnPropertyNames(Controller.prototype).some((methodName) => {
|
||||
if (methodName === "constructor") return false;
|
||||
return Reflect.getMetadata("method", instance, methodName) === "ws";
|
||||
});
|
||||
|
||||
if (isWebsocket) {
|
||||
// Register as WebSocket controller
|
||||
// Pass the existing instance with dependencies to avoid creating a new instance without them
|
||||
registerWebSocketController(router, Controller, instance);
|
||||
} else {
|
||||
// Register as REST controller with the existing instance
|
||||
registerRestController(router, Controller, instance);
|
||||
}
|
||||
}
|
||||
|
||||
function registerRestController(router: Router, Controller: ControllerConstructor): void {
|
||||
const instance = new Controller();
|
||||
function registerRestController(
|
||||
router: Router,
|
||||
Controller: ControllerConstructor,
|
||||
existingInstance?: ControllerInstance
|
||||
): void {
|
||||
const instance = existingInstance || new Controller();
|
||||
const baseRoute = Reflect.getMetadata("baseRoute", Controller) as string;
|
||||
|
||||
Object.getOwnPropertyNames(Controller.prototype).forEach((methodName) => {
|
||||
|
||||
@ -1,13 +1,3 @@
|
||||
// Export individual decorators
|
||||
export { Controller, Middleware } from "./rest";
|
||||
export { Get, Post, Put, Patch, Delete } from "./rest";
|
||||
export { WebSocket } from "./websocket";
|
||||
export { registerControllers } from "./controller";
|
||||
|
||||
// Also provide namespaced exports for better organization
|
||||
import * as RestDecorators from "./rest";
|
||||
import * as WebSocketDecorators from "./websocket";
|
||||
|
||||
// Named namespace exports
|
||||
export const Rest = RestDecorators;
|
||||
export const WebSocketNS = WebSocketDecorators;
|
||||
export * from "./controller";
|
||||
export * from "./rest";
|
||||
export * from "./websocket";
|
||||
|
||||
1134
pnpm-lock.yaml
1134
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user