Initial commit: Plane
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled
Synced from upstream: 8853637e981ed7d8a6cff32bd98e7afe20f54362
This commit is contained in:
14
apps/live/.env.example
Normal file
14
apps/live/.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
PORT=3100
|
||||
API_BASE_URL="http://localhost:8000"
|
||||
|
||||
WEB_BASE_URL="http://localhost:3000"
|
||||
|
||||
LIVE_BASE_URL="http://localhost:3100"
|
||||
LIVE_BASE_PATH="/live"
|
||||
|
||||
LIVE_SERVER_SECRET_KEY="secret-key"
|
||||
|
||||
# If you prefer not to provide a Redis URL, you can set the REDIS_HOST and REDIS_PORT environment variables instead.
|
||||
REDIS_PORT=6379
|
||||
REDIS_HOST=localhost
|
||||
REDIS_URL="redis://localhost:6379/"
|
||||
4
apps/live/.eslintignore
Normal file
4
apps/live/.eslintignore
Normal file
@@ -0,0 +1,4 @@
|
||||
.turbo/*
|
||||
out/*
|
||||
dist/*
|
||||
public/*
|
||||
4
apps/live/.eslintrc.cjs
Normal file
4
apps/live/.eslintrc.cjs
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@plane/eslint-config/server.js"],
|
||||
};
|
||||
6
apps/live/.prettierignore
Normal file
6
apps/live/.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.next
|
||||
.turbo
|
||||
out/
|
||||
dist/
|
||||
build/
|
||||
node_modules/
|
||||
5
apps/live/.prettierrc
Normal file
5
apps/live/.prettierrc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
15
apps/live/Dockerfile.dev
Normal file
15
apps/live/Dockerfile.dev
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
RUN corepack enable pnpm && pnpm add -g turbo
|
||||
RUN pnpm install
|
||||
EXPOSE 3003
|
||||
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
VOLUME [ "/app/node_modules", "/app/live/node_modules"]
|
||||
|
||||
CMD ["pnpm","dev", "--filter=live"]
|
||||
64
apps/live/Dockerfile.live
Normal file
64
apps/live/Dockerfile.live
Normal file
@@ -0,0 +1,64 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
# Setup pnpm package manager with corepack and configure global bin directory for caching
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
# *****************************************************************************
|
||||
# STAGE 1: Prune the project
|
||||
# *****************************************************************************
|
||||
FROM base AS builder
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
ARG TURBO_VERSION=2.5.6
|
||||
RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION}
|
||||
COPY . .
|
||||
RUN turbo prune --scope=live --docker
|
||||
|
||||
# *****************************************************************************
|
||||
# STAGE 2: Install dependencies & build the project
|
||||
# *****************************************************************************
|
||||
# Add lockfile and package.json's of isolated subworkspace
|
||||
FROM base AS installer
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# First install dependencies (as they change less often)
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=builder /app/out/json/ .
|
||||
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN corepack enable pnpm
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
|
||||
|
||||
# Build the project and its dependencies
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store
|
||||
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN pnpm turbo run build --filter=live
|
||||
|
||||
# *****************************************************************************
|
||||
# STAGE 3: Run the project
|
||||
# *****************************************************************************
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=installer /app/packages ./packages
|
||||
COPY --from=installer /app/apps/live/dist ./apps/live/dist
|
||||
COPY --from=installer /app/apps/live/node_modules ./apps/live/node_modules
|
||||
COPY --from=installer /app/node_modules ./node_modules
|
||||
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "apps/live/dist/start.js"]
|
||||
63
apps/live/package.json
Normal file
63
apps/live/package.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "live",
|
||||
"version": "1.1.0",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "A realtime collaborative server powers Plane's rich text editor",
|
||||
"main": "./dist/start.js",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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}\"",
|
||||
"fix:lint": "eslint . --fix",
|
||||
"fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\"",
|
||||
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
|
||||
},
|
||||
"author": "Plane Software Inc.",
|
||||
"dependencies": {
|
||||
"@dotenvx/dotenvx": "^1.49.0",
|
||||
"@hocuspocus/extension-database": "2.15.2",
|
||||
"@hocuspocus/extension-logger": "2.15.2",
|
||||
"@hocuspocus/extension-redis": "2.15.2",
|
||||
"@hocuspocus/server": "2.15.2",
|
||||
"@hocuspocus/transformer": "2.15.2",
|
||||
"@plane/decorators": "workspace:*",
|
||||
"@plane/editor": "workspace:*",
|
||||
"@plane/logger": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
"@sentry/node": "catalog:",
|
||||
"@sentry/profiling-node": "catalog:",
|
||||
"@tiptap/core": "catalog:",
|
||||
"@tiptap/html": "catalog:",
|
||||
"axios": "catalog:",
|
||||
"compression": "1.8.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.2",
|
||||
"express-ws": "^5.0.2",
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "5.7.0",
|
||||
"uuid": "catalog:",
|
||||
"ws": "^8.18.3",
|
||||
"y-prosemirror": "^1.3.7",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.20",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/compression": "1.8.1",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "4.17.23",
|
||||
"@types/express-ws": "^3.0.5",
|
||||
"@types/node": "catalog:",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
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("COLLABORATION_CONTROLLER: WebSocket connection error:", error);
|
||||
ws.close(1011, "Internal server error");
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("COLLABORATION_CONTROLLER: WebSocket connection error:", error);
|
||||
ws.close(1011, "Internal server error");
|
||||
}
|
||||
}
|
||||
}
|
||||
63
apps/live/src/controllers/document.controller.ts
Normal file
63
apps/live/src/controllers/document.controller.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { z } from "zod";
|
||||
// helpers
|
||||
import { Controller, Post } from "@plane/decorators";
|
||||
import { convertHTMLDocumentToAllFormats } from "@plane/editor";
|
||||
// logger
|
||||
import { logger } from "@plane/logger";
|
||||
import { type TConvertDocumentRequestBody } from "@/types";
|
||||
|
||||
// Define the schema with more robust validation
|
||||
const convertDocumentSchema = z.object({
|
||||
description_html: z
|
||||
.string()
|
||||
.min(1, "HTML content cannot be empty")
|
||||
.refine((html) => html.trim().length > 0, "HTML content cannot be just whitespace")
|
||||
.refine((html) => html.includes("<") && html.includes(">"), "Content must be valid HTML"),
|
||||
variant: z.enum(["rich", "document"]),
|
||||
});
|
||||
|
||||
@Controller("/convert-document")
|
||||
export class DocumentController {
|
||||
@Post("/")
|
||||
async convertDocument(req: Request, res: Response) {
|
||||
try {
|
||||
// Validate request body
|
||||
const validatedData = convertDocumentSchema.parse(req.body as TConvertDocumentRequestBody);
|
||||
const { description_html, variant } = validatedData;
|
||||
|
||||
// Process document conversion
|
||||
const { description, description_binary } = convertHTMLDocumentToAllFormats({
|
||||
document_html: description_html,
|
||||
variant,
|
||||
});
|
||||
|
||||
// Return successful response
|
||||
res.status(200).json({
|
||||
description,
|
||||
description_binary,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const validationErrors = error.errors.map((err) => ({
|
||||
path: err.path.join("."),
|
||||
message: err.message,
|
||||
}));
|
||||
logger.error("DOCUMENT_CONTROLLER: Validation error", {
|
||||
validationErrors,
|
||||
});
|
||||
return res.status(400).json({
|
||||
message: `Validation error`,
|
||||
context: {
|
||||
validationErrors,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
logger.error("DOCUMENT_CONTROLLER: Internal server error", error);
|
||||
return 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 { DocumentController } from "./document.controller";
|
||||
import { HealthController } from "./health.controller";
|
||||
|
||||
export const CONTROLLERS = [CollaborationController, DocumentController, HealthController];
|
||||
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();
|
||||
112
apps/live/src/extensions/database.ts
Normal file
112
apps/live/src/extensions/database.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Database as HocuspocusDatabase } from "@hocuspocus/extension-database";
|
||||
// utils
|
||||
import {
|
||||
getAllDocumentFormatsFromDocumentEditorBinaryData,
|
||||
getBinaryDataFromDocumentEditorHTMLString,
|
||||
} from "@plane/editor";
|
||||
// logger
|
||||
import { logger } from "@plane/logger";
|
||||
import { AppError } from "@/lib/errors";
|
||||
// services
|
||||
import { getPageService } from "@/services/page/handler";
|
||||
// type
|
||||
import type { FetchPayloadWithContext, StorePayloadWithContext } from "@/types";
|
||||
import { ForceCloseReason, CloseCode } from "@/types/admin-commands";
|
||||
import { broadcastError } from "@/utils/broadcast-error";
|
||||
// force close utility
|
||||
import { forceCloseDocumentAcrossServers } from "./force-close-handler";
|
||||
|
||||
const fetchDocument = async ({ context, documentName: pageId, instance }: 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) {
|
||||
const appError = new AppError(error, { context: { pageId } });
|
||||
logger.error("Error in fetching document", appError);
|
||||
|
||||
// Broadcast error to frontend for user document types
|
||||
await broadcastError(instance, pageId, "Unable to load the page. Please try refreshing.", "fetch", context);
|
||||
|
||||
throw appError;
|
||||
}
|
||||
};
|
||||
|
||||
const storeDocument = async ({
|
||||
context,
|
||||
state: pageBinaryData,
|
||||
documentName: pageId,
|
||||
instance,
|
||||
}: 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,
|
||||
};
|
||||
await service.updateDescriptionBinary(pageId, payload);
|
||||
} catch (error) {
|
||||
const appError = new AppError(error, { context: { pageId } });
|
||||
logger.error("Error in updating document:", appError);
|
||||
|
||||
// Check error types
|
||||
const isContentTooLarge = appError.statusCode === 413;
|
||||
|
||||
// Determine if we should disconnect and unload
|
||||
const shouldDisconnect = isContentTooLarge;
|
||||
|
||||
// Determine error message and code
|
||||
let errorMessage: string;
|
||||
let errorCode: "content_too_large" | "page_locked" | "page_archived" | undefined;
|
||||
|
||||
if (isContentTooLarge) {
|
||||
errorMessage = "Document is too large to save. Please reduce the content size.";
|
||||
errorCode = "content_too_large";
|
||||
} else {
|
||||
errorMessage = "Unable to save the page. Please try again.";
|
||||
}
|
||||
|
||||
// Broadcast error to frontend for user document types
|
||||
await broadcastError(instance, pageId, errorMessage, "store", context, errorCode, shouldDisconnect);
|
||||
|
||||
// If we should disconnect, close connections and unload document
|
||||
if (shouldDisconnect) {
|
||||
// Map error code to ForceCloseReason with proper types
|
||||
const reason =
|
||||
errorCode === "content_too_large" ? ForceCloseReason.DOCUMENT_TOO_LARGE : ForceCloseReason.CRITICAL_ERROR;
|
||||
|
||||
const closeCode = errorCode === "content_too_large" ? CloseCode.DOCUMENT_TOO_LARGE : CloseCode.FORCE_CLOSE;
|
||||
|
||||
// force close connections and unload document
|
||||
await forceCloseDocumentAcrossServers(instance, pageId, reason, closeCode);
|
||||
|
||||
// Don't throw after force close - document is already unloaded
|
||||
// Throwing would cause hocuspocus's finally block to access the null document
|
||||
return;
|
||||
}
|
||||
|
||||
throw appError;
|
||||
}
|
||||
};
|
||||
|
||||
export class Database extends HocuspocusDatabase {
|
||||
constructor() {
|
||||
super({ fetch: fetchDocument, store: storeDocument });
|
||||
}
|
||||
}
|
||||
203
apps/live/src/extensions/force-close-handler.ts
Normal file
203
apps/live/src/extensions/force-close-handler.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import type { Connection, Extension, Hocuspocus, onConfigurePayload } from "@hocuspocus/server";
|
||||
import { logger } from "@plane/logger";
|
||||
import { Redis } from "@/extensions/redis";
|
||||
import {
|
||||
AdminCommand,
|
||||
CloseCode,
|
||||
ForceCloseReason,
|
||||
getForceCloseMessage,
|
||||
isForceCloseCommand,
|
||||
type ClientForceCloseMessage,
|
||||
type ForceCloseCommandData,
|
||||
} from "@/types/admin-commands";
|
||||
|
||||
/**
|
||||
* Extension to handle force close commands from other servers via Redis admin channel
|
||||
*/
|
||||
export class ForceCloseHandler implements Extension {
|
||||
name = "ForceCloseHandler";
|
||||
priority = 999;
|
||||
|
||||
async onConfigure({ instance }: onConfigurePayload) {
|
||||
const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis) as Redis | undefined;
|
||||
|
||||
if (!redisExt) {
|
||||
logger.warn("[FORCE_CLOSE_HANDLER] Redis extension not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Register handler for force_close admin command
|
||||
redisExt.onAdminCommand<ForceCloseCommandData>(AdminCommand.FORCE_CLOSE, async (data) => {
|
||||
// Type guard for safety
|
||||
if (!isForceCloseCommand(data)) {
|
||||
logger.error("[FORCE_CLOSE_HANDLER] Received invalid force close command");
|
||||
return;
|
||||
}
|
||||
|
||||
const { docId, reason, code } = data;
|
||||
|
||||
const document = instance.documents.get(docId);
|
||||
if (!document) {
|
||||
// Not our document, ignore
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionCount = document.getConnectionsCount();
|
||||
logger.info(`[FORCE_CLOSE_HANDLER] Sending force close message to ${connectionCount} clients...`);
|
||||
|
||||
// Step 1: Send force close message to ALL clients first
|
||||
const forceCloseMessage: ClientForceCloseMessage = {
|
||||
type: "force_close",
|
||||
reason,
|
||||
code,
|
||||
message: getForceCloseMessage(reason),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
let messageSent = 0;
|
||||
document.connections.forEach(({ connection }: { connection: Connection }) => {
|
||||
try {
|
||||
connection.sendStateless(JSON.stringify(forceCloseMessage));
|
||||
messageSent++;
|
||||
} catch (error) {
|
||||
logger.error("[FORCE_CLOSE_HANDLER] Failed to send message:", error);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[FORCE_CLOSE_HANDLER] Sent force close message to ${messageSent}/${connectionCount} clients`);
|
||||
|
||||
// Wait a moment for messages to be delivered
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Step 2: Close connections
|
||||
logger.info(`[FORCE_CLOSE_HANDLER] Closing ${connectionCount} connections...`);
|
||||
|
||||
let closed = 0;
|
||||
document.connections.forEach(({ connection }: { connection: Connection }) => {
|
||||
try {
|
||||
connection.close({ code, reason });
|
||||
closed++;
|
||||
} catch (error) {
|
||||
logger.error("[FORCE_CLOSE_HANDLER] Failed to close connection:", error);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[FORCE_CLOSE_HANDLER] Closed ${closed}/${connectionCount} connections for ${docId}`);
|
||||
});
|
||||
|
||||
logger.info("[FORCE_CLOSE_HANDLER] Registered with Redis extension");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force close all connections to a document across all servers and unload it from memory.
|
||||
* Used for critical errors or admin operations.
|
||||
*
|
||||
* @param instance - The Hocuspocus server instance
|
||||
* @param pageId - The document ID to force close
|
||||
* @param reason - The reason for force closing
|
||||
* @param code - Optional WebSocket close code (defaults to FORCE_CLOSE)
|
||||
* @returns Promise that resolves when document is closed and unloaded
|
||||
* @throws Error if document not found in memory
|
||||
*/
|
||||
export const forceCloseDocumentAcrossServers = async (
|
||||
instance: Hocuspocus,
|
||||
pageId: string,
|
||||
reason: ForceCloseReason,
|
||||
code: CloseCode = CloseCode.FORCE_CLOSE
|
||||
): Promise<void> => {
|
||||
// STEP 1: VERIFY DOCUMENT EXISTS
|
||||
const document = instance.documents.get(pageId);
|
||||
|
||||
if (!document) {
|
||||
logger.info(`[FORCE_CLOSE] Document ${pageId} already unloaded - no action needed`);
|
||||
return; // Document already cleaned up, nothing to do
|
||||
}
|
||||
|
||||
const connectionsBefore = document.getConnectionsCount();
|
||||
logger.info(`[FORCE_CLOSE] Sending force close message to ${connectionsBefore} local clients...`);
|
||||
|
||||
const forceCloseMessage: ClientForceCloseMessage = {
|
||||
type: "force_close",
|
||||
reason,
|
||||
code,
|
||||
message: getForceCloseMessage(reason),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
let messageSentCount = 0;
|
||||
document.connections.forEach(({ connection }: { connection: Connection }) => {
|
||||
try {
|
||||
connection.sendStateless(JSON.stringify(forceCloseMessage));
|
||||
messageSentCount++;
|
||||
} catch (error) {
|
||||
logger.error("[FORCE_CLOSE] Failed to send message to client:", error);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[FORCE_CLOSE] Sent force close message to ${messageSentCount}/${connectionsBefore} clients`);
|
||||
|
||||
// Wait a moment for messages to be delivered
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// STEP 3: CLOSE LOCAL CONNECTIONS
|
||||
logger.info(`[FORCE_CLOSE] Closing ${connectionsBefore} local connections...`);
|
||||
|
||||
let closedCount = 0;
|
||||
document.connections.forEach(({ connection }: { connection: Connection }) => {
|
||||
try {
|
||||
connection.close({ code, reason });
|
||||
closedCount++;
|
||||
} catch (error) {
|
||||
logger.error("[FORCE_CLOSE] Failed to close local connection:", error);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[FORCE_CLOSE] Closed ${closedCount}/${connectionsBefore} local connections`);
|
||||
|
||||
// STEP 4: BROADCAST TO OTHER SERVERS
|
||||
const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis) as Redis | undefined;
|
||||
|
||||
if (redisExt) {
|
||||
const commandData: ForceCloseCommandData = {
|
||||
command: AdminCommand.FORCE_CLOSE,
|
||||
docId: pageId,
|
||||
reason,
|
||||
code,
|
||||
originServer: instance.configuration.name || "unknown",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const receivers = await redisExt.publishAdminCommand(commandData);
|
||||
logger.info(`[FORCE_CLOSE] Notified ${receivers} other server(s)`);
|
||||
} else {
|
||||
logger.warn("[FORCE_CLOSE] Redis extension not found, cannot notify other servers");
|
||||
}
|
||||
|
||||
// STEP 5: WAIT FOR OTHER SERVERS
|
||||
const waitTime = 800;
|
||||
logger.info(`[FORCE_CLOSE] Waiting ${waitTime}ms for other servers to close connections...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
||||
|
||||
// STEP 6: UNLOAD DOCUMENT after closing all the connections
|
||||
logger.info(`[FORCE_CLOSE] Unloading document from memory...`);
|
||||
|
||||
try {
|
||||
await instance.unloadDocument(document);
|
||||
logger.info(`[FORCE_CLOSE] Document unloaded successfully ✅`);
|
||||
} catch (unloadError: unknown) {
|
||||
logger.error("[FORCE_CLOSE] UNLOAD FAILED:", unloadError);
|
||||
logger.error(` Error: ${unloadError instanceof Error ? unloadError.message : "unknown"}`);
|
||||
}
|
||||
|
||||
// STEP 7: VERIFY UNLOAD
|
||||
const documentAfterUnload = instance.documents.get(pageId);
|
||||
|
||||
if (documentAfterUnload) {
|
||||
logger.error(
|
||||
`❌ [FORCE_CLOSE] Document still in memory!, Document ID: ${pageId}, Connections: ${documentAfterUnload.getConnectionsCount()}`
|
||||
);
|
||||
} else {
|
||||
logger.info(`✅ [FORCE_CLOSE] COMPLETE, Document: ${pageId}, Status: Successfully closed and unloaded`);
|
||||
}
|
||||
};
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
134
apps/live/src/extensions/redis.ts
Normal file
134
apps/live/src/extensions/redis.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Redis as HocuspocusRedis } from "@hocuspocus/extension-redis";
|
||||
import { OutgoingMessage, type onConfigurePayload } from "@hocuspocus/server";
|
||||
import { logger } from "@plane/logger";
|
||||
import { AppError } from "@/lib/errors";
|
||||
import { redisManager } from "@/redis";
|
||||
import { AdminCommand } from "@/types/admin-commands";
|
||||
import type { AdminCommandData, AdminCommandHandler } from "@/types/admin-commands";
|
||||
|
||||
const getRedisClient = () => {
|
||||
const redisClient = redisManager.getClient();
|
||||
if (!redisClient) {
|
||||
throw new AppError("Redis client not initialized");
|
||||
}
|
||||
return redisClient;
|
||||
};
|
||||
|
||||
export class Redis extends HocuspocusRedis {
|
||||
private adminHandlers = new Map<AdminCommand, AdminCommandHandler>();
|
||||
private readonly ADMIN_CHANNEL = "hocuspocus:admin";
|
||||
|
||||
constructor() {
|
||||
super({ redis: getRedisClient() });
|
||||
}
|
||||
|
||||
async onConfigure(payload: onConfigurePayload) {
|
||||
await super.onConfigure(payload);
|
||||
|
||||
// Subscribe to admin channel
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.sub.subscribe(this.ADMIN_CHANNEL, (error: Error) => {
|
||||
if (error) {
|
||||
logger.error(`[Redis] Failed to subscribe to admin channel:`, error);
|
||||
reject(error);
|
||||
} else {
|
||||
logger.info(`[Redis] Subscribed to admin channel: ${this.ADMIN_CHANNEL}`);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for admin messages
|
||||
this.sub.on("message", this.handleAdminMessage);
|
||||
logger.info(`[Redis] Attached admin message listener`);
|
||||
}
|
||||
|
||||
private handleAdminMessage = async (channel: string, message: string) => {
|
||||
if (channel !== this.ADMIN_CHANNEL) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(message) as AdminCommandData;
|
||||
|
||||
// Validate command
|
||||
if (!data.command || !Object.values(AdminCommand).includes(data.command as AdminCommand)) {
|
||||
logger.warn(`[Redis] Invalid admin command received: ${data.command}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = this.adminHandlers.get(data.command);
|
||||
|
||||
if (handler) {
|
||||
await handler(data);
|
||||
} else {
|
||||
logger.warn(`[Redis] No handler registered for admin command: ${data.command}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[Redis] Error handling admin message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Register handler for an admin command
|
||||
*/
|
||||
public onAdminCommand<T extends AdminCommandData = AdminCommandData>(
|
||||
command: AdminCommand,
|
||||
handler: AdminCommandHandler<T>
|
||||
) {
|
||||
this.adminHandlers.set(command, handler as AdminCommandHandler);
|
||||
logger.info(`[Redis] Registered admin command: ${command}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish admin command to global channel
|
||||
*/
|
||||
public async publishAdminCommand<T extends AdminCommandData>(data: T): Promise<number> {
|
||||
// Validate command data
|
||||
if (!data.command || !Object.values(AdminCommand).includes(data.command)) {
|
||||
throw new AppError(`Invalid admin command: ${data.command}`);
|
||||
}
|
||||
|
||||
const message = JSON.stringify(data);
|
||||
const receivers = await this.pub.publish(this.ADMIN_CHANNEL, message);
|
||||
|
||||
logger.info(`[Redis] Published "${data.command}" command, received by ${receivers} server(s)`);
|
||||
return receivers;
|
||||
}
|
||||
|
||||
async onDestroy() {
|
||||
// Unsubscribe from admin channel
|
||||
await new Promise<void>((resolve) => {
|
||||
this.sub.unsubscribe(this.ADMIN_CHANNEL, (error: Error) => {
|
||||
if (error) {
|
||||
logger.error(`[Redis] Error unsubscribing from admin channel:`, error);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Remove the message listener to prevent memory leaks
|
||||
this.sub.removeListener("message", this.handleAdminMessage);
|
||||
logger.info(`[Redis] Removed admin message listener`);
|
||||
|
||||
await super.onDestroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a message to a document across all servers via Redis.
|
||||
* Uses empty identifier so ALL servers process the message.
|
||||
*/
|
||||
public async broadcastToDocument(documentName: string, payload: unknown): 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])]);
|
||||
const channel = this["pubKey"](documentName);
|
||||
const encodedMessage = Buffer.concat([emptyPrefix, Buffer.from(message.toUint8Array())]);
|
||||
|
||||
const result = await this.pub.publishBuffer(channel, encodedMessage);
|
||||
|
||||
logger.info(`REDIS_EXTENSION: Published to ${documentName}, ${result} subscribers`);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
63
apps/live/src/hocuspocus.ts
Normal file
63
apps/live/src/hocuspocus.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { 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 = new Hocuspocus({
|
||||
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;
|
||||
}
|
||||
}
|
||||
15
apps/live/src/instrument.ts
Normal file
15
apps/live/src/instrument.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as Sentry from "@sentry/node";
|
||||
import { nodeProfilingIntegration } from "@sentry/profiling-node";
|
||||
|
||||
export const setupSentry = () => {
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
integrations: [Sentry.httpIntegration(), Sentry.expressIntegration(), nodeProfilingIntegration()],
|
||||
tracesSampleRate: process.env.SENTRY_TRACES_SAMPLE_RATE ? parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE) : 0.5,
|
||||
environment: process.env.SENTRY_ENVIRONMENT || "development",
|
||||
release: process.env.APP_VERSION || "v1.0.0",
|
||||
sendDefaultPii: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
50
apps/live/src/lib/auth-middleware.ts
Normal file
50
apps/live/src/lib/auth-middleware.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { logger } from "@plane/logger";
|
||||
import { env } from "@/env";
|
||||
|
||||
/**
|
||||
* Express middleware to verify secret key authentication for protected endpoints
|
||||
*
|
||||
* Checks for secret key in headers:
|
||||
* - x-admin-secret-key (preferred for admin endpoints)
|
||||
* - live-server-secret-key (for backward compatibility)
|
||||
*
|
||||
* @param req - Express request object
|
||||
* @param res - Express response object
|
||||
* @param next - Express next function
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Middleware } from "@plane/decorators";
|
||||
* import { requireSecretKey } from "@/lib/auth-middleware";
|
||||
*
|
||||
* @Get("/protected")
|
||||
* @Middleware(requireSecretKey)
|
||||
* async protectedEndpoint(req: Request, res: Response) {
|
||||
* // This will only execute if secret key is valid
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
// TODO - Move to hmac
|
||||
export const requireSecretKey = (req: Request, res: Response, next: NextFunction): void => {
|
||||
const secretKey = req.headers["live-server-secret-key"];
|
||||
|
||||
if (!secretKey || secretKey !== env.LIVE_SERVER_SECRET_KEY) {
|
||||
logger.warn(`
|
||||
⚠️ [AUTH] Unauthorized access attempt
|
||||
Endpoint: ${req.path}
|
||||
Method: ${req.method}
|
||||
IP: ${req.ip}
|
||||
User-Agent: ${req.headers["user-agent"]}
|
||||
`);
|
||||
|
||||
res.status(401).json({
|
||||
error: "Unauthorized",
|
||||
status: 401,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Secret key is valid, proceed to the route handler
|
||||
next();
|
||||
};
|
||||
91
apps/live/src/lib/auth.ts
Normal file
91
apps/live/src/lib/auth.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
// plane imports
|
||||
import type { IncomingHttpHeaders } from "http";
|
||||
import type { TUserDetails } from "@plane/editor";
|
||||
import { logger } from "@plane/logger";
|
||||
import { AppError } from "@/lib/errors";
|
||||
// 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) {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "onAuthenticate" },
|
||||
});
|
||||
logger.error("Token parsing failed, using request headers", appError);
|
||||
} finally {
|
||||
// If cookie is still not found, fallback to request headers
|
||||
if (!cookie) {
|
||||
cookie = requestHeaders.cookie?.toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (!cookie || !userId) {
|
||||
const appError = new AppError("Credentials not provided", { code: "AUTH_MISSING_CREDENTIALS" });
|
||||
logger.error("Credentials not provided", appError);
|
||||
throw appError;
|
||||
}
|
||||
|
||||
// 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 AppError("Authentication unsuccessful: User ID mismatch", { code: "AUTH_USER_MISMATCH" });
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.display_name,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "handleAuthentication" },
|
||||
});
|
||||
logger.error("Authentication failed", appError);
|
||||
throw new AppError("Authentication unsuccessful", { code: appError.code });
|
||||
}
|
||||
};
|
||||
73
apps/live/src/lib/errors.ts
Normal file
73
apps/live/src/lib/errors.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
/**
|
||||
* Application error class that sanitizes and standardizes errors across the app.
|
||||
* Extracts only essential information from AxiosError to prevent massive log bloat
|
||||
* and sensitive data leaks (cookies, tokens, etc).
|
||||
*
|
||||
* Usage:
|
||||
* new AppError("Simple error message")
|
||||
* new AppError("Custom error", { code: "MY_CODE", statusCode: 400 })
|
||||
* new AppError(axiosError) // Auto-extracts essential info
|
||||
* new AppError(anyError) // Works with any error type
|
||||
*/
|
||||
export class AppError extends Error {
|
||||
statusCode?: number;
|
||||
method?: string;
|
||||
url?: string;
|
||||
code?: string;
|
||||
context?: Record<string, any>;
|
||||
|
||||
constructor(messageOrError: string | unknown, data?: Partial<Omit<AppError, "name" | "message">>) {
|
||||
// Handle error objects - extract essential info
|
||||
const error = messageOrError;
|
||||
|
||||
// Already AppError - return immediately for performance (no need to re-process)
|
||||
if (error instanceof AppError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
// Handle string message (simple case like regular Error)
|
||||
if (typeof messageOrError === "string") {
|
||||
super(messageOrError);
|
||||
this.name = "AppError";
|
||||
if (data) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// AxiosError - extract ONLY essential info (no config, no headers, no cookies)
|
||||
if (error && typeof error === "object" && "isAxiosError" in error) {
|
||||
const axiosError = error as AxiosError;
|
||||
const responseData = axiosError.response?.data as any;
|
||||
super(responseData?.message || axiosError.message);
|
||||
this.name = "AppError";
|
||||
this.statusCode = axiosError.response?.status;
|
||||
this.method = axiosError.config?.method?.toUpperCase();
|
||||
this.url = axiosError.config?.url;
|
||||
this.code = axiosError.code;
|
||||
return;
|
||||
}
|
||||
|
||||
// DOMException (AbortError from cancelled requests)
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
super(error.message);
|
||||
this.name = "AppError";
|
||||
this.code = "ABORT_ERROR";
|
||||
return;
|
||||
}
|
||||
|
||||
// Standard Error objects
|
||||
if (error instanceof Error) {
|
||||
super(error.message);
|
||||
this.name = "AppError";
|
||||
this.code = error.name;
|
||||
return;
|
||||
}
|
||||
|
||||
// Unknown error types - safe fallback
|
||||
super("Unknown error occurred");
|
||||
this.name = "AppError";
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
};
|
||||
214
apps/live/src/redis.ts
Normal file
214
apps/live/src/redis.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
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_MANAGER: client already initialized and connected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.connectionPromise) {
|
||||
logger.info("REDIS_MANAGER: 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("REDIS_MANAGER: No Redis URL provided, Redis functionality will be disabled");
|
||||
this.isConnected = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Configuration optimized for BOTH regular operations AND pub/sub
|
||||
// HocuspocusRedis uses .duplicate() which inherits these settings
|
||||
this.redisClient = new Redis(redisUrl, {
|
||||
lazyConnect: false, // Connect immediately for reliability (duplicates inherit this)
|
||||
keepAlive: 30000,
|
||||
connectTimeout: 10000,
|
||||
maxRetriesPerRequest: 3,
|
||||
enableOfflineQueue: true, // Keep commands queued during reconnection
|
||||
retryStrategy: (times: number) => {
|
||||
// Exponential backoff with max 2 seconds
|
||||
const delay = Math.min(times * 50, 2000);
|
||||
logger.info(`REDIS_MANAGER: Reconnection attempt ${times}, delay: ${delay}ms`);
|
||||
return delay;
|
||||
},
|
||||
});
|
||||
|
||||
// Set up event listeners
|
||||
this.redisClient.on("connect", () => {
|
||||
logger.info("REDIS_MANAGER: Redis client connected");
|
||||
this.isConnected = true;
|
||||
});
|
||||
|
||||
this.redisClient.on("ready", () => {
|
||||
logger.info("REDIS_MANAGER: Redis client ready");
|
||||
this.isConnected = true;
|
||||
});
|
||||
|
||||
this.redisClient.on("error", (error) => {
|
||||
logger.error("REDIS_MANAGER: Redis client error:", error);
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
this.redisClient.on("close", () => {
|
||||
logger.warn("REDIS_MANAGER: Redis client connection closed");
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
this.redisClient.on("reconnecting", () => {
|
||||
logger.info("REDIS_MANAGER: Redis client reconnecting...");
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
await this.redisClient.ping();
|
||||
logger.info("REDIS_MANAGER: Redis connection test successful");
|
||||
} catch (error) {
|
||||
logger.error("REDIS_MANAGER: 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_MANAGER: 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_MANAGER: Redis client disconnected gracefully");
|
||||
} catch (error) {
|
||||
logger.error("REDIS_MANAGER: 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(`REDIS_MANAGER: 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(`REDIS_MANAGER: 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(`REDIS_MANAGER: 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(`REDIS_MANAGER: 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(`REDIS_MANAGER: Error setting expiry for Redis key ${key}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export a default instance for convenience
|
||||
export const redisManager = RedisManager.getInstance();
|
||||
121
apps/live/src/server.ts
Normal file
121
apps/live/src/server.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Server as HttpServer } from "http";
|
||||
import { type Hocuspocus } from "@hocuspocus/server";
|
||||
import compression from "compression";
|
||||
import cors from "cors";
|
||||
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
|
||||
import { HocusPocusServerManager } from "@/hocuspocus";
|
||||
// redis
|
||||
import { redisManager } from "@/redis";
|
||||
|
||||
export class Server {
|
||||
private app: Express;
|
||||
private router: Router;
|
||||
private hocuspocusServer: Hocuspocus | undefined;
|
||||
private httpServer: HttpServer | undefined;
|
||||
|
||||
constructor() {
|
||||
this.app = express();
|
||||
expressWs(this.app);
|
||||
this.setupMiddleware();
|
||||
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("SERVER: Redis setup completed");
|
||||
const manager = HocusPocusServerManager.getInstance();
|
||||
this.hocuspocusServer = await manager.initialize();
|
||||
logger.info("SERVER: HocusPocus setup completed");
|
||||
this.setupRoutes(this.hocuspocusServer);
|
||||
this.setupNotFoundHandler();
|
||||
} catch (error) {
|
||||
logger.error("SERVER: 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: env.COMPRESSION_LEVEL, threshold: env.COMPRESSION_THRESHOLD }));
|
||||
// Logging middleware
|
||||
this.app.use(loggerMiddleware);
|
||||
// Body parsing middleware
|
||||
this.app.use(express.json());
|
||||
this.app.use(express.urlencoded({ extended: true }));
|
||||
// cors middleware
|
||||
this.setupCors();
|
||||
}
|
||||
|
||||
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 setupNotFoundHandler() {
|
||||
this.app.use((_req: Request, res: Response) => {
|
||||
res.status(404).json({
|
||||
message: "Not Found",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private setupRoutes(hocuspocusServer: Hocuspocus) {
|
||||
CONTROLLERS.forEach((controller) => registerController(this.router, controller, [hocuspocusServer]));
|
||||
}
|
||||
|
||||
public listen() {
|
||||
this.httpServer = this.app
|
||||
.listen(this.app.get("port"), () => {
|
||||
logger.info(`SERVER: Express server has started at port ${this.app.get("port")}`);
|
||||
})
|
||||
.on("error", (err) => {
|
||||
logger.error("SERVER: Failed to start server:", err);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
public async destroy() {
|
||||
if (this.hocuspocusServer) {
|
||||
this.hocuspocusServer.closeConnections();
|
||||
logger.info("SERVER: HocusPocus connections closed gracefully.");
|
||||
}
|
||||
|
||||
await redisManager.disconnect();
|
||||
logger.info("SERVER: Redis connection closed gracefully.");
|
||||
|
||||
if (this.httpServer) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.httpServer!.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
logger.info("SERVER: Express server closed gracefully.");
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
63
apps/live/src/services/api.service.ts
Normal file
63
apps/live/src/services/api.service.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import { env } from "@/env";
|
||||
import { AppError } from "@/lib/errors";
|
||||
|
||||
export abstract class APIService {
|
||||
protected baseURL: string;
|
||||
private axiosInstance: AxiosInstance;
|
||||
private header: Record<string, string> = {};
|
||||
|
||||
constructor(baseURL?: string) {
|
||||
this.baseURL = baseURL || env.API_BASE_URL;
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL: this.baseURL,
|
||||
withCredentials: true,
|
||||
timeout: 20000,
|
||||
});
|
||||
this.setupInterceptors();
|
||||
}
|
||||
|
||||
private setupInterceptors() {
|
||||
this.axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
return Promise.reject(new AppError(error));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
setHeader(key: string, value: string) {
|
||||
this.header[key] = value;
|
||||
}
|
||||
|
||||
getHeader() {
|
||||
return this.header;
|
||||
}
|
||||
|
||||
get(url: string, params = {}, config = {}) {
|
||||
return this.axiosInstance.get(url, {
|
||||
...params,
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
post(url: string, data = {}, config = {}) {
|
||||
return this.axiosInstance.post(url, data, config);
|
||||
}
|
||||
|
||||
put(url: string, data = {}, config = {}) {
|
||||
return this.axiosInstance.put(url, data, config);
|
||||
}
|
||||
|
||||
patch(url: string, data = {}, config = {}) {
|
||||
return this.axiosInstance.patch(url, data, config);
|
||||
}
|
||||
|
||||
delete(url: string, data?: Record<string, unknown> | null | string, config = {}) {
|
||||
return this.axiosInstance.delete(url, { data, ...config });
|
||||
}
|
||||
|
||||
request(config = {}) {
|
||||
return this.axiosInstance(config);
|
||||
}
|
||||
}
|
||||
119
apps/live/src/services/page/core.service.ts
Normal file
119
apps/live/src/services/page/core.service.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { logger } from "@plane/logger";
|
||||
import { TPage } from "@plane/types";
|
||||
// services
|
||||
import { AppError } from "@/lib/errors";
|
||||
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) => {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "fetchDetails", pageId },
|
||||
});
|
||||
logger.error("Failed to fetch page details", appError);
|
||||
throw appError;
|
||||
});
|
||||
}
|
||||
|
||||
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) => {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "fetchDescriptionBinary", pageId },
|
||||
});
|
||||
logger.error("Failed to fetch page description binary", appError);
|
||||
throw appError;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 AppError(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 AppError(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) => {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "updatePageProperties", pageId },
|
||||
});
|
||||
|
||||
if (appError.code === "ABORT_ERROR") {
|
||||
throw appError;
|
||||
}
|
||||
|
||||
logger.error("Failed to update page properties", appError);
|
||||
throw appError;
|
||||
}),
|
||||
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) => {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "updateDescriptionBinary", pageId },
|
||||
});
|
||||
logger.error("Failed to update page description binary", appError);
|
||||
throw appError;
|
||||
});
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
16
apps/live/src/services/page/handler.ts
Normal file
16
apps/live/src/services/page/handler.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { AppError } from "@/lib/errors";
|
||||
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 AppError(`Invalid document type ${documentType} provided.`);
|
||||
};
|
||||
25
apps/live/src/services/page/project-page.service.ts
Normal file
25
apps/live/src/services/page/project-page.service.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { AppError } from "@/lib/errors";
|
||||
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 AppError("Missing required fields.");
|
||||
// validate cookie
|
||||
if (!params.cookie) throw new AppError("Cookie is required.");
|
||||
// set cookie
|
||||
this.setHeader("Cookie", params.cookie);
|
||||
// set base path
|
||||
this.basePath = `/api/workspaces/${workspaceSlug}/projects/${projectId}`;
|
||||
}
|
||||
}
|
||||
34
apps/live/src/services/user.service.ts
Normal file
34
apps/live/src/services/user.service.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// types
|
||||
import { logger } from "@plane/logger";
|
||||
import type { IUser } from "@plane/types";
|
||||
// services
|
||||
import { AppError } from "@/lib/errors";
|
||||
import { APIService } from "@/services/api.service";
|
||||
|
||||
export class UserService extends APIService {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
currentUserConfig() {
|
||||
return {
|
||||
url: `${this.baseURL}/api/users/me/`,
|
||||
};
|
||||
}
|
||||
|
||||
async currentUser(cookie: string): Promise<IUser> {
|
||||
return this.get("/api/users/me/", {
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
},
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "currentUser" },
|
||||
});
|
||||
logger.error("Failed to fetch current user", appError);
|
||||
throw appError;
|
||||
});
|
||||
}
|
||||
}
|
||||
61
apps/live/src/start.ts
Normal file
61
apps/live/src/start.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// eslint-disable-next-line import/order
|
||||
import { setupSentry } from "./instrument";
|
||||
setupSentry();
|
||||
|
||||
import { logger } from "@plane/logger";
|
||||
import { AppError } from "@/lib/errors";
|
||||
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();
|
||||
|
||||
// Handle process signals
|
||||
process.on("SIGTERM", async () => {
|
||||
logger.info("Received SIGTERM signal. Initiating graceful shutdown...");
|
||||
try {
|
||||
if (server) {
|
||||
await server.destroy();
|
||||
}
|
||||
logger.info("Server shut down gracefully");
|
||||
} catch (error) {
|
||||
logger.error("Error during graceful shutdown:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
logger.info("Received SIGINT signal. Killing node process...");
|
||||
try {
|
||||
if (server) {
|
||||
await server.destroy();
|
||||
}
|
||||
logger.info("Server shut down gracefully");
|
||||
} catch (error) {
|
||||
logger.error("Error during graceful shutdown:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (err: Error) => {
|
||||
const error = new AppError(err);
|
||||
logger.error(`[UNHANDLED_REJECTION]`, error);
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (err: Error) => {
|
||||
const error = new AppError(err);
|
||||
logger.error(`[UNCAUGHT_EXCEPTION]`, error);
|
||||
});
|
||||
143
apps/live/src/types/admin-commands.ts
Normal file
143
apps/live/src/types/admin-commands.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Type-safe admin commands for server-to-server communication
|
||||
*/
|
||||
|
||||
/**
|
||||
* Force close error codes - reasons why a document is being force closed
|
||||
*/
|
||||
export enum ForceCloseReason {
|
||||
CRITICAL_ERROR = "critical_error",
|
||||
MEMORY_LEAK = "memory_leak",
|
||||
DOCUMENT_TOO_LARGE = "document_too_large",
|
||||
ADMIN_REQUEST = "admin_request",
|
||||
SERVER_SHUTDOWN = "server_shutdown",
|
||||
SECURITY_VIOLATION = "security_violation",
|
||||
CORRUPTION_DETECTED = "corruption_detected",
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket close codes
|
||||
* https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code
|
||||
*/
|
||||
export enum CloseCode {
|
||||
/** Normal closure; the connection successfully completed */
|
||||
NORMAL = 1000,
|
||||
/** The endpoint is going away (server shutdown or browser navigating away) */
|
||||
GOING_AWAY = 1001,
|
||||
/** Protocol error */
|
||||
PROTOCOL_ERROR = 1002,
|
||||
/** Unsupported data */
|
||||
UNSUPPORTED_DATA = 1003,
|
||||
/** Reserved (no status code was present) */
|
||||
NO_STATUS = 1005,
|
||||
/** Abnormal closure */
|
||||
ABNORMAL = 1006,
|
||||
/** Invalid frame payload data */
|
||||
INVALID_DATA = 1007,
|
||||
/** Policy violation */
|
||||
POLICY_VIOLATION = 1008,
|
||||
/** Message too big */
|
||||
MESSAGE_TOO_BIG = 1009,
|
||||
/** Client expected extension not negotiated */
|
||||
MANDATORY_EXTENSION = 1010,
|
||||
/** Server encountered unexpected condition */
|
||||
INTERNAL_ERROR = 1011,
|
||||
/** Custom: Force close requested */
|
||||
FORCE_CLOSE = 4000,
|
||||
/** Custom: Document too large */
|
||||
DOCUMENT_TOO_LARGE = 4001,
|
||||
/** Custom: Memory pressure */
|
||||
MEMORY_PRESSURE = 4002,
|
||||
/** Custom: Security violation */
|
||||
SECURITY_VIOLATION = 4003,
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin command types
|
||||
*/
|
||||
export enum AdminCommand {
|
||||
FORCE_CLOSE = "force_close",
|
||||
HEALTH_CHECK = "health_check",
|
||||
RESTART_DOCUMENT = "restart_document",
|
||||
}
|
||||
|
||||
/**
|
||||
* Force close command data structure
|
||||
*/
|
||||
export interface ForceCloseCommandData {
|
||||
command: AdminCommand.FORCE_CLOSE;
|
||||
docId: string;
|
||||
reason: ForceCloseReason;
|
||||
code: CloseCode;
|
||||
originServer: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check command data structure
|
||||
*/
|
||||
export interface HealthCheckCommandData {
|
||||
command: AdminCommand.HEALTH_CHECK;
|
||||
originServer: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all admin commands
|
||||
*/
|
||||
export type AdminCommandData = ForceCloseCommandData | HealthCheckCommandData;
|
||||
|
||||
/**
|
||||
* Client force close message structure (sent to clients via sendStateless)
|
||||
*/
|
||||
export interface ClientForceCloseMessage {
|
||||
type: "force_close";
|
||||
reason: ForceCloseReason;
|
||||
code: CloseCode;
|
||||
message?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin command handler function type
|
||||
*/
|
||||
export type AdminCommandHandler<T extends AdminCommandData = AdminCommandData> = (data: T) => Promise<void> | void;
|
||||
|
||||
/**
|
||||
* Type guard to check if data is a ForceCloseCommandData
|
||||
*/
|
||||
export function isForceCloseCommand(data: AdminCommandData): data is ForceCloseCommandData {
|
||||
return data.command === AdminCommand.FORCE_CLOSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if data is a HealthCheckCommandData
|
||||
*/
|
||||
export function isHealthCheckCommand(data: AdminCommandData): data is HealthCheckCommandData {
|
||||
return data.command === AdminCommand.HEALTH_CHECK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate force close reason
|
||||
*/
|
||||
export function isValidForceCloseReason(reason: string): reason is ForceCloseReason {
|
||||
return Object.values(ForceCloseReason).includes(reason as ForceCloseReason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable message for force close reason
|
||||
*/
|
||||
export function getForceCloseMessage(reason: ForceCloseReason): string {
|
||||
const messages: Record<ForceCloseReason, string> = {
|
||||
[ForceCloseReason.CRITICAL_ERROR]: "A critical error occurred. Please refresh the page.",
|
||||
[ForceCloseReason.MEMORY_LEAK]: "Memory limit exceeded. Please refresh the page.",
|
||||
[ForceCloseReason.DOCUMENT_TOO_LARGE]:
|
||||
"Content limit reached and live sync is off. Create a new page or use nested pages to continue syncing.",
|
||||
[ForceCloseReason.ADMIN_REQUEST]: "Connection closed by administrator. Please try again later.",
|
||||
[ForceCloseReason.SERVER_SHUTDOWN]: "Server is shutting down. Please reconnect in a moment.",
|
||||
[ForceCloseReason.SECURITY_VIOLATION]: "Security violation detected. Connection terminated.",
|
||||
[ForceCloseReason.CORRUPTION_DETECTED]: "Data corruption detected. Please refresh the page.",
|
||||
};
|
||||
|
||||
return messages[reason] || "Connection closed. Please refresh the page.";
|
||||
}
|
||||
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;
|
||||
};
|
||||
38
apps/live/src/utils/broadcast-error.ts
Normal file
38
apps/live/src/utils/broadcast-error.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { type Hocuspocus } from "@hocuspocus/server";
|
||||
import { createRealtimeEvent } from "@plane/editor";
|
||||
import { logger } from "@plane/logger";
|
||||
import type { FetchPayloadWithContext, StorePayloadWithContext } from "@/types";
|
||||
import { broadcastMessageToPage } from "./broadcast-message";
|
||||
|
||||
// Helper to broadcast error to frontend
|
||||
export const broadcastError = async (
|
||||
hocuspocusServerInstance: Hocuspocus,
|
||||
pageId: string,
|
||||
errorMessage: string,
|
||||
errorType: "fetch" | "store",
|
||||
context: FetchPayloadWithContext["context"] | StorePayloadWithContext["context"],
|
||||
errorCode?: "content_too_large" | "page_locked" | "page_archived",
|
||||
shouldDisconnect?: boolean
|
||||
) => {
|
||||
try {
|
||||
const errorEvent = createRealtimeEvent({
|
||||
action: "error",
|
||||
page_id: pageId,
|
||||
parent_id: undefined,
|
||||
descendants_ids: [],
|
||||
data: {
|
||||
error_message: errorMessage,
|
||||
error_type: errorType,
|
||||
error_code: errorCode,
|
||||
should_disconnect: shouldDisconnect,
|
||||
user_id: context.userId || "",
|
||||
},
|
||||
workspace_slug: context.workspaceSlug || "",
|
||||
user_id: context.userId || "",
|
||||
});
|
||||
|
||||
await broadcastMessageToPage(hocuspocusServerInstance, pageId, errorEvent);
|
||||
} catch (broadcastError) {
|
||||
logger.error("Error broadcasting error message to frontend:", broadcastError);
|
||||
}
|
||||
};
|
||||
34
apps/live/src/utils/broadcast-message.ts
Normal file
34
apps/live/src/utils/broadcast-message.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Hocuspocus } from "@hocuspocus/server";
|
||||
import { BroadcastedEvent } from "@plane/editor";
|
||||
import { logger } from "@plane/logger";
|
||||
import { Redis } from "@/extensions/redis";
|
||||
import { AppError } from "@/lib/errors";
|
||||
|
||||
export const broadcastMessageToPage = async (
|
||||
hocuspocusServerInstance: Hocuspocus,
|
||||
documentName: string,
|
||||
eventData: BroadcastedEvent
|
||||
): Promise<boolean> => {
|
||||
if (!hocuspocusServerInstance || !hocuspocusServerInstance.documents) {
|
||||
const appError = new AppError("HocusPocus server not available or initialized", {
|
||||
context: { operation: "broadcastMessageToPage", documentName },
|
||||
});
|
||||
logger.error("Error while broadcasting message:", appError);
|
||||
return false;
|
||||
}
|
||||
|
||||
const redisExtension = hocuspocusServerInstance.configuration.extensions.find((ext) => ext instanceof Redis);
|
||||
|
||||
if (!redisExtension) {
|
||||
logger.error("BROADCAST_MESSAGE_TO_PAGE: Redis extension not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await redisExtension.broadcastToDocument(documentName, eventData);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`BROADCAST_MESSAGE_TO_PAGE: Error broadcasting to ${documentName}:`, error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
21
apps/live/src/utils/document.ts
Normal file
21
apps/live/src/utils/document.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export const generateTitleProsemirrorJson = (text: string) => {
|
||||
return {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 1 },
|
||||
...(text
|
||||
? {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
1
apps/live/src/utils/index.ts
Normal file
1
apps/live/src/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./document";
|
||||
27
apps/live/tsconfig.json
Normal file
27
apps/live/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "@plane/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ES2015",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2015"],
|
||||
"target": "ES2015",
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@/plane-live/*": ["./src/ce/*"]
|
||||
},
|
||||
"removeComments": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"inlineSources": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"sourceRoot": "/",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tsdown.config.ts"],
|
||||
"exclude": ["./dist", "./build", "./node_modules", "**/*.d.ts"]
|
||||
}
|
||||
10
apps/live/tsdown.config.ts
Normal file
10
apps/live/tsdown.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "tsdown";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/start.ts"],
|
||||
outDir: "dist",
|
||||
format: ["esm"],
|
||||
dts: false,
|
||||
clean: true,
|
||||
sourcemap: false,
|
||||
});
|
||||
Reference in New Issue
Block a user