[WIKI-679] refactor: live server (#7720)

This commit is contained in:
sriram veeraghanta 2025-09-30 19:28:15 +05:30 committed by GitHub
parent 7ce21a6488
commit 5951372555
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 1690 additions and 1144 deletions

View File

@ -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"]

View File

@ -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",

View File

@ -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.`);
};

View File

@ -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.`);
};

View File

@ -1 +0,0 @@
export type TAdditionalDocumentTypes = never;

View 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");
}
}
}

View 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.`,
});
}
}
}

View 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,
});
}
}

View 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];

View File

@ -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;
};

View File

@ -1,44 +0,0 @@
// plane editor
import {
getAllDocumentFormatsFromDocumentEditorBinaryData,
getAllDocumentFormatsFromRichTextEditorBinaryData,
getBinaryDataFromDocumentEditorHTMLString,
getBinaryDataFromRichTextEditorHTMLString,
} from "@plane/editor";
// plane types
import { TDocumentPayload } from "@plane/types";
type TArgs = {
document_html: string;
variant: "rich" | "document";
};
export const convertHTMLDocumentToAllFormats = (args: TArgs): TDocumentPayload => {
const { document_html, variant } = args;
let allFormats: TDocumentPayload;
if (variant === "rich") {
const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else if (variant === "document") {
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else {
throw new Error(`Invalid variant provided: ${variant}`);
}
return allFormats;
};

View File

@ -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 }),
},
});
};

View File

@ -1,39 +0,0 @@
import { pinoHttp } from "pino-http";
const transport = {
target: "pino-pretty",
options: {
colorize: true,
},
};
const hooks = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
logMethod(inputArgs: any, method: any): any {
if (inputArgs.length >= 2) {
const arg1 = inputArgs.shift();
const arg2 = inputArgs.shift();
return method.apply(this, [arg2, arg1, ...inputArgs]);
}
return method.apply(this, inputArgs);
},
};
export const logger = pinoHttp({
level: "info",
transport: transport,
hooks: hooks,
serializers: {
req(req) {
return `${req.method} ${req.url}`;
},
res(res) {
return `${res.statusCode} ${res?.statusMessage || ""}`;
},
responseTime(time) {
return `${time}ms`;
},
},
});
export const manualLogger: typeof logger.logger = logger.logger;

View File

@ -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,
});
};

View File

@ -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,
},
};
};

View File

@ -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;
}
};

View File

@ -1,15 +0,0 @@
export function getRedisUrl() {
const redisUrl = process.env.REDIS_URL?.trim();
const redisHost = process.env.REDIS_HOST?.trim();
const redisPort = process.env.REDIS_PORT?.trim();
if (redisUrl) {
return redisUrl;
}
if (redisHost && redisPort && !Number.isNaN(Number(redisPort))) {
return `redis://${redisHost}:${redisPort}`;
}
return "";
}

View File

@ -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;
});
}
}

View File

@ -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";
};

View File

@ -1 +0,0 @@
export * from "../../ce/lib/fetch-document.js";

View File

@ -1 +0,0 @@
export * from "../../ce/lib/update-document.js";

View File

@ -1 +0,0 @@
export * from "../../ce/types/common.js";

36
apps/live/src/env.ts Normal file
View 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();

View 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 });
}
}

View 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()];

View File

@ -0,0 +1,13 @@
import { Logger as HocuspocusLogger } from "@hocuspocus/extension-logger";
import { logger } from "@plane/logger";
export class Logger extends HocuspocusLogger {
constructor() {
super({
onChange: false,
log: (message) => {
logger.info(message);
},
});
}
}

View 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())])
);
}
}

View File

@ -0,0 +1,63 @@
import { Server, Hocuspocus } from "@hocuspocus/server";
import { v4 as uuidv4 } from "uuid";
// env
import { env } from "@/env";
// extensions
import { getExtensions } from "@/extensions";
// lib
import { onAuthenticate } from "@/lib/auth";
import { onStateless } from "@/lib/stateless";
export class HocusPocusServerManager {
private static instance: HocusPocusServerManager | null = null;
private server: Hocuspocus | null = null;
// server options
private serverName = env.HOSTNAME || uuidv4();
private constructor() {
// Private constructor to prevent direct instantiation
}
/**
* Get the singleton instance of HocusPocusServerManager
*/
public static getInstance(): HocusPocusServerManager {
if (!HocusPocusServerManager.instance) {
HocusPocusServerManager.instance = new HocusPocusServerManager();
}
return HocusPocusServerManager.instance;
}
/**
* Initialize and configure the HocusPocus server
*/
public async initialize(): Promise<Hocuspocus> {
if (this.server) {
return this.server;
}
this.server = Server.configure({
name: this.serverName,
onAuthenticate,
onStateless,
extensions: getExtensions(),
debounce: 10000,
});
return this.server;
}
/**
* Get the configured server instance
*/
public getServer(): Hocuspocus | null {
return this.server;
}
/**
* Reset the singleton instance (useful for testing)
*/
public static resetInstance(): void {
HocusPocusServerManager.instance = null;
}
}

82
apps/live/src/lib/auth.ts Normal file
View 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!");
}
};

View 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
View File

@ -0,0 +1,211 @@
import Redis from "ioredis";
import { logger } from "@plane/logger";
import { env } from "./env";
export class RedisManager {
private static instance: RedisManager;
private redisClient: Redis | null = null;
private isConnected: boolean = false;
private connectionPromise: Promise<void> | null = null;
private constructor() {}
public static getInstance(): RedisManager {
if (!RedisManager.instance) {
RedisManager.instance = new RedisManager();
}
return RedisManager.instance;
}
public async initialize(): Promise<void> {
if (this.redisClient && this.isConnected) {
logger.info("Redis client already initialized and connected");
return;
}
if (this.connectionPromise) {
logger.info("Redis connection already in progress, waiting...");
await this.connectionPromise;
return;
}
this.connectionPromise = this.connect();
await this.connectionPromise;
}
private getRedisUrl(): string {
const redisUrl = env.REDIS_URL;
const redisHost = env.REDIS_HOST;
const redisPort = env.REDIS_PORT;
if (redisUrl) {
return redisUrl;
}
if (redisHost && redisPort && !Number.isNaN(Number(redisPort))) {
return `redis://${redisHost}:${redisPort}`;
}
return "";
}
private async connect(): Promise<void> {
try {
const redisUrl = this.getRedisUrl();
if (!redisUrl) {
logger.warn("No Redis URL provided, Redis functionality will be disabled");
this.isConnected = false;
return;
}
this.redisClient = new Redis(redisUrl, {
lazyConnect: true,
keepAlive: 30000,
connectTimeout: 10000,
commandTimeout: 5000,
// enableOfflineQueue: false,
maxRetriesPerRequest: 3,
});
// Set up event listeners
this.redisClient.on("connect", () => {
logger.info("Redis client connected");
this.isConnected = true;
});
this.redisClient.on("ready", () => {
logger.info("Redis client ready");
this.isConnected = true;
});
this.redisClient.on("error", (error) => {
logger.error("Redis client error:", error);
this.isConnected = false;
});
this.redisClient.on("close", () => {
logger.warn("Redis client connection closed");
this.isConnected = false;
});
this.redisClient.on("reconnecting", () => {
logger.info("Redis client reconnecting...");
this.isConnected = false;
});
// Connect to Redis
await this.redisClient.connect();
// Test the connection
await this.redisClient.ping();
logger.info("Redis connection test successful");
} catch (error) {
logger.error("Failed to initialize Redis client:", error);
this.isConnected = false;
throw error;
} finally {
this.connectionPromise = null;
}
}
public getClient(): Redis | null {
if (!this.redisClient || !this.isConnected) {
logger.warn("Redis client not available or not connected");
return null;
}
return this.redisClient;
}
public isClientConnected(): boolean {
return this.isConnected && this.redisClient !== null;
}
public async disconnect(): Promise<void> {
if (this.redisClient) {
try {
await this.redisClient.quit();
logger.info("Redis client disconnected gracefully");
} catch (error) {
logger.error("Error disconnecting Redis client:", error);
// Force disconnect if quit fails
this.redisClient.disconnect();
} finally {
this.redisClient = null;
this.isConnected = false;
}
}
}
// Convenience methods for common Redis operations
public async set(key: string, value: string, ttl?: number): Promise<boolean> {
const client = this.getClient();
if (!client) return false;
try {
if (ttl) {
await client.setex(key, ttl, value);
} else {
await client.set(key, value);
}
return true;
} catch (error) {
logger.error(`Error setting Redis key ${key}:`, error);
return false;
}
}
public async get(key: string): Promise<string | null> {
const client = this.getClient();
if (!client) return null;
try {
return await client.get(key);
} catch (error) {
logger.error(`Error getting Redis key ${key}:`, error);
return null;
}
}
public async del(key: string): Promise<boolean> {
const client = this.getClient();
if (!client) return false;
try {
await client.del(key);
return true;
} catch (error) {
logger.error(`Error deleting Redis key ${key}:`, error);
return false;
}
}
public async exists(key: string): Promise<boolean> {
const client = this.getClient();
if (!client) return false;
try {
const result = await client.exists(key);
return result === 1;
} catch (error) {
logger.error(`Error checking Redis key ${key}:`, error);
return false;
}
}
public async expire(key: string, ttl: number): Promise<boolean> {
const client = this.getClient();
if (!client) return false;
try {
const result = await client.expire(key, ttl);
return result === 1;
} catch (error) {
logger.error(`Error setting expiry for Redis key ${key}:`, error);
return false;
}
}
}
// Export a default instance for convenience
export const redisManager = RedisManager.getInstance();

View File

@ -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();
});

View File

@ -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,

View 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;
});
}
}

View 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();
}
}

View 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.`);
};

View 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}`;
}
}

View File

@ -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
View 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);
}
});

View 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;
};

View File

@ -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);
};

View File

@ -0,0 +1 @@
export * from "./document";

View File

@ -19,8 +19,9 @@
"inlineSources": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"sourceRoot": "/"
"sourceRoot": "/",
"types": ["node"]
},
"include": ["src/**/*.ts", "tsdown.config.ts"],
"exclude": ["./dist", "./build", "./node_modules"]
"exclude": ["./dist", "./build", "./node_modules", "**/*.d.ts"]
}

View File

@ -1,7 +1,7 @@
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["src/server.ts"],
entry: ["src/start.ts"],
outDir: "dist",
format: ["esm", "cjs"],
});

View File

@ -13,7 +13,7 @@
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"check:lint": "eslint . --max-warnings 1",
"check:lint": "eslint . --max-warnings 2",
"check:types": "tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",

View File

@ -3,46 +3,48 @@ import type { WebSocket } from "ws";
import "reflect-metadata";
type HttpMethod = "get" | "post" | "put" | "delete" | "patch" | "options" | "head" | "ws";
export type HttpMethod = "get" | "post" | "put" | "delete" | "patch" | "options" | "head" | "ws";
interface ControllerInstance {
[key: string]: unknown;
}
type ControllerInstance = {
[key: string]: any;
};
interface ControllerConstructor {
new (...args: unknown[]): ControllerInstance;
export type ControllerConstructor = {
new (...args: any[]): ControllerInstance;
prototype: ControllerInstance;
}
};
export function registerControllers(
export function registerController(
router: Router,
controllers: ControllerConstructor[],
dependencies: any[] = []
Controller: ControllerConstructor,
dependencies: unknown[] = []
): void {
controllers.forEach((Controller) => {
// Create the controller instance with dependencies
const instance = new Controller(...dependencies);
// Create the controller instance with dependencies
const instance = new Controller(...dependencies);
// Determine if it's a WebSocket controller or REST controller by checking
// if it has any methods with the "ws" method metadata
const isWebsocket = Object.getOwnPropertyNames(Controller.prototype).some((methodName) => {
if (methodName === "constructor") return false;
return Reflect.getMetadata("method", instance, methodName) === "ws";
});
if (isWebsocket) {
// Register as WebSocket controller
// Pass the existing instance with dependencies to avoid creating a new instance without them
registerWebSocketController(router, Controller, instance);
} else {
// Register as REST controller - doesn't accept an instance parameter
registerRestController(router, Controller);
}
// Determine if it's a WebSocket controller or REST controller by checking
// if it has any methods with the "ws" method metadata
const isWebsocket = Object.getOwnPropertyNames(Controller.prototype).some((methodName) => {
if (methodName === "constructor") return false;
return Reflect.getMetadata("method", instance, methodName) === "ws";
});
if (isWebsocket) {
// Register as WebSocket controller
// Pass the existing instance with dependencies to avoid creating a new instance without them
registerWebSocketController(router, Controller, instance);
} else {
// Register as REST controller with the existing instance
registerRestController(router, Controller, instance);
}
}
function registerRestController(router: Router, Controller: ControllerConstructor): void {
const instance = new Controller();
function registerRestController(
router: Router,
Controller: ControllerConstructor,
existingInstance?: ControllerInstance
): void {
const instance = existingInstance || new Controller();
const baseRoute = Reflect.getMetadata("baseRoute", Controller) as string;
Object.getOwnPropertyNames(Controller.prototype).forEach((methodName) => {

View File

@ -1,13 +1,3 @@
// Export individual decorators
export { Controller, Middleware } from "./rest";
export { Get, Post, Put, Patch, Delete } from "./rest";
export { WebSocket } from "./websocket";
export { registerControllers } from "./controller";
// Also provide namespaced exports for better organization
import * as RestDecorators from "./rest";
import * as WebSocketDecorators from "./websocket";
// Named namespace exports
export const Rest = RestDecorators;
export const WebSocketNS = WebSocketDecorators;
export * from "./controller";
export * from "./rest";
export * from "./websocket";

File diff suppressed because it is too large Load Diff