Compare commits

...
14 changed files with 1395 additions and 185 deletions
+4 -4
View File
@@ -6,9 +6,9 @@
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
"dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/start.js\"",
"build": "babel src --out-dir dist --extensions \".ts,.js\"",
"start": "node dist/server.js",
"start": "node dist/start.js",
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
},
@@ -52,13 +52,13 @@
"@types/cors": "^2.8.17",
"@types/dotenv": "^8.2.0",
"@types/express": "^4.17.21",
"@types/express-ws": "^3.0.4",
"@types/express-ws": "^3.0.5",
"@types/node": "^20.14.9",
"babel-plugin-module-resolver": "^5.0.2",
"concurrently": "^9.0.1",
"nodemon": "^3.1.7",
"reflect-metadata": "^0.2.2",
"ts-node": "^10.9.2",
"tsup": "^7.2.0",
"typescript": "5.3.3"
}
}
@@ -0,0 +1,21 @@
import { Router } from "express";
import { WebSocket } from "ws";
import { manualLogger } from "../helpers/logger.js";
import type { Hocuspocus as HocusPocusServer } from "@hocuspocus/server";
export class CollaborationController {
constructor(private hocusPocusServer: HocusPocusServer) {
this.hocusPocusServer = hocusPocusServer;
}
registerRoutes(router: Router) {
router.ws("/collaboration", (ws: WebSocket, req) => {
try {
this.hocusPocusServer.handleConnection(ws, req);
} catch (err) {
manualLogger.error("WebSocket connection error:", err);
ws.close();
}
});
}
}
@@ -0,0 +1,16 @@
import { Router, Request, Response } from "express";
import type { Hocuspocus as HocusPocusServer } from "@hocuspocus/server";
export class HealthController {
constructor(private hocusPocusServer: HocusPocusServer) {
this.hocusPocusServer = hocusPocusServer;
}
registerRoutes(router: Router) {
router.get("/health", this.healthCheck);
}
private healthCheck(_req: Request, res: Response) {
res.status(200).json({ status: "OK" });
}
}
+28
View File
@@ -0,0 +1,28 @@
import { RequestHandler, Router } from "express";
type HttpMethod = "get" | "post" | "put" | "delete" | "patch" | "options" | "head";
export function registerControllers(router: Router, Controller: any) {
const instance = new Controller();
const baseRoute = Reflect.getMetadata("baseRoute", Controller) as string;
Object.getOwnPropertyNames(Controller.prototype).forEach((methodName) => {
if (methodName === "constructor") return; // Skip the constructor
const method = Reflect.getMetadata("method", instance, methodName) as HttpMethod;
const route = Reflect.getMetadata("route", instance, methodName) as string;
const middlewares = (Reflect.getMetadata("middlewares", instance, methodName) as RequestHandler[]) || [];
if (method && route) {
const handler = instance[methodName as keyof typeof instance] as unknown;
if (typeof handler === "function") {
(router[method] as (path: string, ...handlers: RequestHandler[]) => void)(
`${baseRoute}${route}`,
...middlewares,
handler.bind(instance)
);
}
}
});
}
+89
View File
@@ -0,0 +1,89 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-wrapper-object-types */
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
import "reflect-metadata";
import { RequestHandler } from "express";
/**
* Controller decorator
* @param baseRoute
* @returns
*/
export function Controller(baseRoute: string = ""): ClassDecorator {
return function (target: Function) {
Reflect.defineMetadata("baseRoute", baseRoute, target);
};
}
/**
* Controller GET method decorator
* @param baseRoute
* @returns
*/
export function Get(route: string): any {
return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
Reflect.defineMetadata("method", "get", target, propertyKey);
Reflect.defineMetadata("route", route, target, propertyKey);
};
}
/**
* Controller POST method decorator
* @param baseRoute
* @returns
*/
export function Post(route: string): any {
return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
Reflect.defineMetadata("method", "post", target, propertyKey);
Reflect.defineMetadata("route", route, target, propertyKey);
};
}
/**
* Controller PATCH method decorator
* @param baseRoute
* @returns
*/
export function Patch(route: string): any {
return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
Reflect.defineMetadata("method", "patch", target, propertyKey);
Reflect.defineMetadata("route", route, target, propertyKey);
};
}
/**
* Controller PUT method decorator
* @param baseRoute
* @returns
*/
export function Put(route: string): any {
return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
Reflect.defineMetadata("method", "put", target, propertyKey);
Reflect.defineMetadata("route", route, target, propertyKey);
};
}
/**
* Controller DELETE method decorator
* @param baseRoute
* @returns
*/
export function Delete(route: string): any {
return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
Reflect.defineMetadata("method", "delete", target, propertyKey);
Reflect.defineMetadata("route", route, target, propertyKey);
};
}
/**
* Middle method decorator
* @param baseRoute
* @returns
*/
export function Middleware(middleware: RequestHandler): any {
return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
const middlewares = Reflect.getMetadata("middlewares", target, propertyKey) || [];
middlewares.push(middleware);
Reflect.defineMetadata("middlewares", middlewares, target, propertyKey);
};
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./controller";
export * from "./decorators";
+143 -95
View File
@@ -1,118 +1,166 @@
import "@/core/config/sentry-config.js";
import express from "express";
import expressWs from "express-ws";
import express, { Express } from "express";
import expressWs, { Application as WsApplication } from "express-ws";
import * as Sentry from "@sentry/node";
import compression from "compression";
import helmet from "helmet";
// cors
import cors from "cors";
import { Server as HTTPServer } from "http";
// core hocuspocus server
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
// helpers
import { logger, manualLogger } from "@/core/helpers/logger.js";
import { errorHandler } from "@/core/helpers/error-handler.js";
import { HealthController } from "@/core/controllers/health.controller.js";
import { CollaborationController } from "@/core/controllers/collaboration.controller.js";
import type { Hocuspocus as HocusPocusServer } from "@hocuspocus/server";
const app = express();
expressWs(app);
// Types
type WebSocketServerType = Express & WsApplication;
app.set("port", process.env.PORT || 3000);
const Controllers = [HealthController, CollaborationController];
export class Server {
private app: WebSocketServerType;
private port: number;
private hocusPocusServer: HocusPocusServer | null;
private httpServer: HTTPServer | null;
// Security middleware
app.use(helmet());
constructor() {
const expressApp = express();
const wsInstance = expressWs(expressApp);
this.app = wsInstance.app as WebSocketServerType;
this.port = Number(process.env.PORT || 3000);
this.hocusPocusServer = null;
this.httpServer = null;
// Middleware for response compression
app.use(
compression({
level: 6,
threshold: 5 * 1000,
}),
);
// Logging middleware
app.use(logger);
// Body parsing middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// cors middleware
app.use(cors());
const router = express.Router();
const HocusPocusServer = await getHocusPocusServer().catch((err) => {
manualLogger.error("Failed to initialize HocusPocusServer:", err);
process.exit(1);
});
router.get("/health", (_req, res) => {
res.status(200).json({ status: "OK" });
});
router.ws("/collaboration", (ws, req) => {
try {
HocusPocusServer.handleConnection(ws, req);
} catch (err) {
manualLogger.error("WebSocket connection error:", err);
ws.close();
this.setupMiddleware();
}
});
app.use(process.env.LIVE_BASE_PATH || "/live", router);
private setupMiddleware(): void {
// Security middleware
this.app.use(helmet());
app.use((_req, res) => {
res.status(404).send("Not Found");
});
Sentry.setupExpressErrorHandler(app);
app.use(errorHandler);
const liveServer = app.listen(app.get("port"), () => {
manualLogger.info(`Plane Live server has started at port ${app.get("port")}`);
});
const gracefulShutdown = async () => {
manualLogger.info("Starting graceful shutdown...");
try {
// Close the HocusPocus server WebSocket connections
await HocusPocusServer.destroy();
manualLogger.info(
"HocusPocus server WebSocket connections closed gracefully.",
// Compression middleware
this.app.use(
compression({
level: 6,
threshold: 5 * 1000,
}),
);
// Close the Express server
liveServer.close(() => {
manualLogger.info("Express server closed gracefully.");
process.exit(1);
});
} catch (err) {
manualLogger.error("Error during shutdown:", err);
process.exit(1);
// Logging middleware
this.app.use(logger);
// Body parsing middleware
this.app.use(express.json());
this.app.use(express.urlencoded({ extended: true }));
// CORS middleware
this.app.use(cors());
}
// Forcefully shut down after 10 seconds if not closed
setTimeout(() => {
manualLogger.error("Forcing shutdown...");
process.exit(1);
}, 10000);
};
private async setupWebSocketServer(): Promise<void> {
try {
this.hocusPocusServer = await getHocusPocusServer();
} catch (err) {
manualLogger.error("Failed to initialize HocusPocusServer:", err);
process.exit(1);
}
}
// Graceful shutdown on unhandled rejection
process.on("unhandledRejection", (err: any) => {
manualLogger.info("Unhandled Rejection: ", err);
manualLogger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
gracefulShutdown();
});
private setupControllers(): void {
if (!this.hocusPocusServer) {
throw new Error("HocusPocus server not initialized");
}
// Graceful shutdown on uncaught exception
process.on("uncaughtException", (err: any) => {
manualLogger.info("Uncaught Exception: ", err);
manualLogger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
gracefulShutdown();
});
const router = express.Router();
Controllers.forEach((Controller) => {
const instance = new Controller(this.hocusPocusServer);
instance.registerRoutes(router);
});
this.app.use(process.env.LIVE_BASE_PATH || "/live", router);
// 404 handler
this.app.use((_req: express.Request, res: express.Response) => {
res.status(404).send("Not Found");
});
}
private setupErrorHandling(): void {
Sentry.setupExpressErrorHandler(this.app);
this.app.use(errorHandler);
}
private setupShutdownHandlers(): void {
const handleShutdown = async (signal: string) => {
manualLogger.info(`${signal} received. Starting graceful shutdown...`);
await this.gracefulShutdown();
};
process.on("SIGTERM", () => handleShutdown("SIGTERM"));
process.on("SIGINT", () => handleShutdown("SIGINT"));
process.on("unhandledRejection", (err: Error | null) => {
manualLogger.info("Unhandled Rejection: ", err);
manualLogger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
this.gracefulShutdown();
});
process.on("uncaughtException", (err: Error) => {
manualLogger.info("Uncaught Exception: ", err);
manualLogger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
this.gracefulShutdown();
});
}
public async gracefulShutdown(): Promise<void> {
manualLogger.info("Starting graceful shutdown...");
try {
if (this.hocusPocusServer) {
await this.hocusPocusServer.destroy();
manualLogger.info(
"HocusPocus server WebSocket connections closed gracefully.",
);
}
if (this.httpServer) {
await new Promise<void>((resolve) => {
this.httpServer?.close(() => {
manualLogger.info("Express server closed gracefully.");
resolve();
});
});
}
} catch (err) {
manualLogger.error("Error during shutdown:", err);
} finally {
process.exit(1);
}
}
public async start(): Promise<void> {
try {
// Initialize WebSocket server first
await this.setupWebSocketServer();
// Then setup controllers with the initialized hocusPocusServer
this.setupControllers();
// Setup error handling
this.setupErrorHandling();
// Start the server
this.httpServer = this.app.listen(this.port, () => {
manualLogger.info(`Plane Live server has started at port ${this.port}`);
});
// Setup graceful shutdown handlers
this.setupShutdownHandlers();
} catch (error) {
manualLogger.error("Failed to start server:", error);
process.exit(1);
}
}
}
+6
View File
@@ -0,0 +1,6 @@
import { Server } from "./server.js";
// Start the worker for taking over the migration jobs
const server = new Server();
server.start();
+19 -5
View File
@@ -3,16 +3,23 @@
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2015"],
"lib": [
"ES2015"
],
"outDir": "./dist",
"rootDir": ".",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/plane-live/*": ["./src/ce/*"]
"@/*": [
"./src/*"
],
"@/plane-live/*": [
"./src/ce/*"
]
},
"removeComments": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"sourceMap": true,
"inlineSources": true,
@@ -21,6 +28,13 @@
// This improves issue grouping in Sentry.
"sourceRoot": "/"
},
"include": ["src/**/*.ts", "tsup.config.ts"],
"exclude": ["./dist", "./build", "./node_modules"]
"include": [
"src/**/*.ts",
"tsup.config.ts"
],
"exclude": [
"./dist",
"./build",
"./node_modules"
]
}
-11
View File
@@ -1,11 +0,0 @@
import { defineConfig, Options } from "tsup";
export default defineConfig((options: Options) => ({
entry: ["src/server.ts"],
format: ["cjs", "esm"],
dts: true,
clean: false,
external: ["react"],
injectStyle: true,
...options,
}));
@@ -4,7 +4,7 @@ import React, { FC, useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { IUserEmailNotificationSettings } from "@plane/types";
// ui
import { Button, Checkbox, TOAST_TYPE, setToast } from "@plane/ui";
import { ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui";
// services
import { UserService } from "@/services/user.service";
// types
@@ -20,35 +20,32 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
const { data } = props;
// form data
const {
handleSubmit,
control,
reset,
formState: { isSubmitting, dirtyFields },
} = useForm<IUserEmailNotificationSettings>({
defaultValues: {
...data,
},
});
const onSubmit = async (formData: IUserEmailNotificationSettings) => {
// Get the dirty fields from the form data and create a payload
let payload = {};
Object.keys(dirtyFields).forEach((key) => {
payload = {
...payload,
[key]: formData[key as keyof IUserEmailNotificationSettings],
};
});
await userService
.updateCurrentUserEmailNotificationSettings(payload)
.then(() =>
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Email Notification Settings updated successfully",
})
)
.catch((err) => console.error(err));
const handleSettingChange = async (key: keyof IUserEmailNotificationSettings, value: boolean) => {
try {
await userService.updateCurrentUserEmailNotificationSettings({
[key]: value,
});
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Email notification setting updated successfully",
});
} catch (err) {
console.error(err);
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
message: "Failed to update email notification setting",
});
}
};
useEffect(() => {
@@ -64,7 +61,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">Property changes</div>
<div className="text-sm font-normal text-custom-text-300">
Notify me when issues properties like assignees, priority, estimates or anything else changes.
Notify me when issue's properties like assignees, priority, estimates or anything else changes.
</div>
</div>
<div className="shrink-0">
@@ -72,7 +69,14 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
control={control}
name="property_change"
render={({ field: { value, onChange } }) => (
<Checkbox checked={value} onChange={() => onChange(!value)} containerClassName="mx-2" />
<ToggleSwitch
value={value}
onChange={(newValue) => {
onChange(newValue);
handleSettingChange("property_change", newValue);
}}
size="sm"
/>
)}
/>
</div>
@@ -89,12 +93,13 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
control={control}
name="state_change"
render={({ field: { value, onChange } }) => (
<Checkbox
checked={value}
onChange={() => {
onChange(!value);
<ToggleSwitch
value={value}
onChange={(newValue) => {
onChange(newValue);
handleSettingChange("state_change", newValue);
}}
containerClassName="mx-2"
size="sm"
/>
)}
/>
@@ -110,7 +115,14 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
control={control}
name="issue_completed"
render={({ field: { value, onChange } }) => (
<Checkbox checked={value} onChange={() => onChange(!value)} containerClassName="mx-2" />
<ToggleSwitch
value={value}
onChange={(newValue) => {
onChange(newValue);
handleSettingChange("issue_completed", newValue);
}}
size="sm"
/>
)}
/>
</div>
@@ -127,7 +139,14 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
control={control}
name="comment"
render={({ field: { value, onChange } }) => (
<Checkbox checked={value} onChange={() => onChange(!value)} containerClassName="mx-2" />
<ToggleSwitch
value={value}
onChange={(newValue) => {
onChange(newValue);
handleSettingChange("comment", newValue);
}}
size="sm"
/>
)}
/>
</div>
@@ -144,17 +163,19 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
control={control}
name="mention"
render={({ field: { value, onChange } }) => (
<Checkbox checked={value} onChange={() => onChange(!value)} containerClassName="mx-2" />
<ToggleSwitch
value={value}
onChange={(newValue) => {
onChange(newValue);
handleSettingChange("mention", newValue);
}}
size="sm"
/>
)}
/>
</div>
</div>
</div>
<div className="flex items-center py-12">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
</div>
</>
);
};
+1 -1
View File
@@ -98,7 +98,7 @@ export class InboxIssueStore implements IInboxIssueStore {
// If issue accepted sync issue to local db
if (status === EInboxIssueStatus.ACCEPTED) {
addIssueToPersistanceLayer(inboxIssue.issue);
addIssueToPersistanceLayer({ ...this.issue, ...inboxIssue.issue });
}
} catch {
runInAction(() => set(this, "status", previousData.status));
@@ -352,7 +352,7 @@ export class WorkspaceDraftIssues implements IWorkspaceDraftIssues {
}
// sync issue to local db
addIssueToPersistanceLayer(response);
addIssueToPersistanceLayer({ ...payload, ...response });
// Update draft issue count in workspaceUserInfo
this.updateWorkspaceUserDraftIssueCount(workspaceSlug, -1);
+1007 -31
View File
File diff suppressed because it is too large Load Diff