base
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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_json, description_binary } = convertHTMLDocumentToAllFormats({
|
||||
document_html: description_html,
|
||||
variant,
|
||||
});
|
||||
|
||||
// Return successful response
|
||||
res.status(200).json({
|
||||
description_json,
|
||||
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.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { CollaborationController } from "./collaboration.controller";
|
||||
import { DocumentController } from "./document.controller";
|
||||
import { HealthController } from "./health.controller";
|
||||
import { PdfExportController } from "./pdf-export.controller";
|
||||
|
||||
export const CONTROLLERS = [CollaborationController, DocumentController, HealthController, PdfExportController];
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { Effect, Schema, Cause } from "effect";
|
||||
import { Controller, Post } from "@plane/decorators";
|
||||
import { logger } from "@plane/logger";
|
||||
import { AppError } from "@/lib/errors";
|
||||
import { PdfExportRequestBody, PdfValidationError, PdfAuthenticationError } from "@/schema/pdf-export";
|
||||
import { PdfExportService, exportToPdf } from "@/services/pdf-export";
|
||||
import type { PdfExportInput } from "@/services/pdf-export";
|
||||
|
||||
@Controller("/pdf-export")
|
||||
export class PdfExportController {
|
||||
/**
|
||||
* Parses and validates the request, returning a typed input object
|
||||
*/
|
||||
private parseRequest(
|
||||
req: Request,
|
||||
requestId: string
|
||||
): Effect.Effect<PdfExportInput, PdfValidationError | PdfAuthenticationError> {
|
||||
return Effect.gen(function* () {
|
||||
const cookie = req.headers.cookie || "";
|
||||
if (!cookie) {
|
||||
return yield* Effect.fail(
|
||||
new PdfAuthenticationError({
|
||||
message: "Authentication required",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const body = yield* Schema.decodeUnknown(PdfExportRequestBody)(req.body).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new PdfValidationError({
|
||||
message: "Invalid request body",
|
||||
cause,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
pageId: body.pageId,
|
||||
workspaceSlug: body.workspaceSlug,
|
||||
projectId: body.projectId,
|
||||
title: body.title,
|
||||
author: body.author,
|
||||
subject: body.subject,
|
||||
pageSize: body.pageSize,
|
||||
pageOrientation: body.pageOrientation,
|
||||
fileName: body.fileName,
|
||||
noAssets: body.noAssets,
|
||||
cookie,
|
||||
requestId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps domain errors to HTTP responses
|
||||
*/
|
||||
private mapErrorToHttpResponse(error: unknown): { status: number; error: string } {
|
||||
if (error && typeof error === "object" && "_tag" in error) {
|
||||
const tag = (error as { _tag: string })._tag;
|
||||
const message = (error as { message?: string }).message || "Unknown error";
|
||||
|
||||
switch (tag) {
|
||||
case "PdfValidationError":
|
||||
return { status: 400, error: message };
|
||||
case "PdfAuthenticationError":
|
||||
return { status: 401, error: message };
|
||||
case "PdfContentFetchError":
|
||||
return {
|
||||
status: message.includes("not found") ? 404 : 502,
|
||||
error: message,
|
||||
};
|
||||
case "PdfTimeoutError":
|
||||
return { status: 504, error: message };
|
||||
case "PdfGenerationError":
|
||||
return { status: 500, error: message };
|
||||
case "PdfMetadataFetchError":
|
||||
case "PdfImageProcessingError":
|
||||
return { status: 502, error: message };
|
||||
default:
|
||||
return { status: 500, error: message };
|
||||
}
|
||||
}
|
||||
return { status: 500, error: "Failed to generate PDF" };
|
||||
}
|
||||
|
||||
@Post("/")
|
||||
async exportToPdf(req: Request, res: Response) {
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
const effect = Effect.gen(this, function* () {
|
||||
// Parse request
|
||||
const input = yield* this.parseRequest(req, requestId);
|
||||
|
||||
// Delegate to service
|
||||
return yield* exportToPdf(input);
|
||||
}).pipe(
|
||||
// Log errors before catching them
|
||||
Effect.tapError((error) => Effect.logError("PDF_EXPORT: Export failed", { requestId, error })),
|
||||
// Map all tagged errors to HTTP responses
|
||||
Effect.catchAll((error) => Effect.succeed(this.mapErrorToHttpResponse(error))),
|
||||
// Handle unexpected defects
|
||||
Effect.catchAllDefect((defect) => {
|
||||
const appError = new AppError(Cause.pretty(Cause.die(defect)), {
|
||||
context: { requestId, operation: "exportToPdf" },
|
||||
});
|
||||
logger.error("PDF_EXPORT: Unexpected failure", appError);
|
||||
return Effect.succeed({ status: 500, error: "Failed to generate PDF" });
|
||||
})
|
||||
);
|
||||
|
||||
const result = await Effect.runPromise(Effect.provide(effect, PdfExportService.Default));
|
||||
|
||||
// Check if result is an error response
|
||||
if ("error" in result && "status" in result) {
|
||||
return res.status(result.status).json({ message: result.error });
|
||||
}
|
||||
|
||||
// Success - send PDF
|
||||
const { pdfBuffer, outputFileName } = result;
|
||||
|
||||
// Sanitize filename for Content-Disposition header to prevent header injection
|
||||
const sanitizedFileName = outputFileName
|
||||
.replace(/["\\\r\n]/g, "") // Remove quotes, backslashes, and CRLF
|
||||
.replace(/[^\x20-\x7E]/g, "_"); // Replace non-ASCII with underscore
|
||||
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${sanitizedFileName}"; filename*=UTF-8''${encodeURIComponent(outputFileName)}`
|
||||
);
|
||||
res.setHeader("Content-Length", pdfBuffer.length);
|
||||
return res.send(pdfBuffer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as dotenv from "dotenv";
|
||||
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();
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Database as HocuspocusDatabase } from "@hocuspocus/extension-database";
|
||||
// plane imports
|
||||
import {
|
||||
getAllDocumentFormatsFromDocumentEditorBinaryData,
|
||||
getBinaryDataFromDocumentEditorHTMLString,
|
||||
} from "@plane/editor";
|
||||
import type { TDocumentPayload } from "@plane/types";
|
||||
import { logger } from "@plane/logger";
|
||||
// lib
|
||||
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)) as Buffer;
|
||||
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>",
|
||||
pageDetails.name
|
||||
);
|
||||
if (convertedBinaryData) {
|
||||
// save the converted binary data back to the database
|
||||
try {
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
|
||||
convertedBinaryData,
|
||||
true
|
||||
);
|
||||
const payload: TDocumentPayload = {
|
||||
description_binary: contentBinaryEncoded,
|
||||
description_html: contentHTML,
|
||||
description_json: contentJSON,
|
||||
};
|
||||
await service.updateDescriptionBinary(pageId, payload);
|
||||
} catch (e) {
|
||||
const error = new AppError(e);
|
||||
logger.error("Failed to save binary after first conversion from html:", error);
|
||||
}
|
||||
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,
|
||||
true
|
||||
);
|
||||
// create payload
|
||||
const payload: TDocumentPayload = {
|
||||
description_binary: contentBinaryEncoded,
|
||||
description_html: contentHTML,
|
||||
description_json: 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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Connection, Extension, Hocuspocus, onConfigurePayload } from "@hocuspocus/server";
|
||||
import { logger } from "@plane/logger";
|
||||
import { Redis } from "@/extensions/redis";
|
||||
import { AdminCommand, CloseCode, getForceCloseMessage, isForceCloseCommand } from "@/types/admin-commands";
|
||||
import type { ForceCloseReason, ClientForceCloseMessage, 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);
|
||||
|
||||
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);
|
||||
|
||||
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`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Database } from "./database";
|
||||
import { ForceCloseHandler } from "./force-close-handler";
|
||||
import { Logger } from "./logger";
|
||||
import { Redis } from "./redis";
|
||||
import { TitleSyncExtension } from "./title-sync";
|
||||
|
||||
export const getExtensions = () => [
|
||||
new Logger(),
|
||||
new Database(),
|
||||
new Redis(),
|
||||
new TitleSyncExtension(),
|
||||
new ForceCloseHandler(), // Must be after Redis to receive broadcasts
|
||||
];
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Redis as HocuspocusRedis } from "@hocuspocus/extension-redis";
|
||||
import { OutgoingMessage } from "@hocuspocus/server";
|
||||
import 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// hocuspocus
|
||||
import type { Extension, Hocuspocus, Document } from "@hocuspocus/server";
|
||||
import { TiptapTransformer } from "@hocuspocus/transformer";
|
||||
import type { AnyExtension, JSONContent } from "@tiptap/core";
|
||||
import type * as Y from "yjs";
|
||||
// editor extensions
|
||||
import {
|
||||
TITLE_EDITOR_EXTENSIONS,
|
||||
createRealtimeEvent,
|
||||
extractTextFromHTML,
|
||||
generateTitleProsemirrorJson,
|
||||
} from "@plane/editor";
|
||||
import { logger } from "@plane/logger";
|
||||
import { AppError } from "@/lib/errors";
|
||||
// helpers
|
||||
import { getPageService } from "@/services/page/handler";
|
||||
import type { HocusPocusServerContext, OnLoadDocumentPayloadWithContext } from "@/types";
|
||||
import { broadcastMessageToPage } from "@/utils/broadcast-message";
|
||||
import { TitleUpdateManager } from "./title-update/title-update-manager";
|
||||
|
||||
/**
|
||||
* Hocuspocus extension for synchronizing document titles
|
||||
*/
|
||||
export class TitleSyncExtension implements Extension {
|
||||
// Maps document names to their observers and update managers
|
||||
private titleObservers: Map<string, (events: Y.YEvent<any>[]) => void> = new Map();
|
||||
private titleUpdateManagers: Map<string, TitleUpdateManager> = new Map();
|
||||
// Store minimal data needed for each document's title observer (prevents closure memory leaks)
|
||||
private titleObserverData: Map<
|
||||
string,
|
||||
{
|
||||
parentId?: string | null;
|
||||
userId: string;
|
||||
workspaceSlug: string | null;
|
||||
instance: Hocuspocus;
|
||||
}
|
||||
> = new Map();
|
||||
|
||||
/**
|
||||
* Handle document loading - migrate old titles if needed
|
||||
*/
|
||||
async onLoadDocument({ context, document, documentName }: OnLoadDocumentPayloadWithContext) {
|
||||
try {
|
||||
// initially for on demand migration of old titles to a new title field
|
||||
// in the yjs binary
|
||||
if (document.isEmpty("title")) {
|
||||
const service = getPageService(context.documentType, context);
|
||||
const pageDetails = await service.fetchDetails(documentName);
|
||||
const title = pageDetails.name;
|
||||
if (title == null) return;
|
||||
const titleJson = (generateTitleProsemirrorJson as (text: string) => JSONContent)(title);
|
||||
const titleField = TiptapTransformer.toYdoc(titleJson, "title", TITLE_EDITOR_EXTENSIONS as AnyExtension[]);
|
||||
document.merge(titleField);
|
||||
}
|
||||
} catch (error) {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "onLoadDocument", documentName },
|
||||
});
|
||||
logger.error("Error loading document title", appError);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Set up title synchronization for a document after it's loaded
|
||||
*/
|
||||
async afterLoadDocument({
|
||||
document,
|
||||
documentName,
|
||||
context,
|
||||
instance,
|
||||
}: {
|
||||
document: Document;
|
||||
documentName: string;
|
||||
context: HocusPocusServerContext;
|
||||
instance: Hocuspocus;
|
||||
}) {
|
||||
// Create a title update manager for this document
|
||||
const updateManager = new TitleUpdateManager(documentName, context);
|
||||
|
||||
// Store the manager
|
||||
this.titleUpdateManagers.set(documentName, updateManager);
|
||||
|
||||
// Store minimal data needed for the observer (prevents closure memory leak)
|
||||
this.titleObserverData.set(documentName, {
|
||||
userId: context.userId,
|
||||
workspaceSlug: context.workspaceSlug,
|
||||
instance: instance,
|
||||
});
|
||||
|
||||
// Create observer using bound method to avoid closure capturing heavy objects
|
||||
const titleObserver = this.handleTitleChange.bind(this, documentName);
|
||||
|
||||
// Observe the title field
|
||||
document.getXmlFragment("title").observeDeep(titleObserver);
|
||||
this.titleObservers.set(documentName, titleObserver);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle title changes for a document
|
||||
* This is a separate method to avoid closure memory leaks
|
||||
*/
|
||||
private handleTitleChange(documentName: string, events: Y.YEvent<any>[]) {
|
||||
let title = "";
|
||||
events.forEach((event) => {
|
||||
title = extractTextFromHTML(event.currentTarget.toJSON() as string);
|
||||
});
|
||||
|
||||
// Get the manager for this document
|
||||
const manager = this.titleUpdateManagers.get(documentName);
|
||||
|
||||
// Get the stored data for this document
|
||||
const data = this.titleObserverData.get(documentName);
|
||||
|
||||
// Broadcast to parent page if it exists
|
||||
if (data?.parentId && data.workspaceSlug && data.instance) {
|
||||
const event = createRealtimeEvent({
|
||||
user_id: data.userId,
|
||||
workspace_slug: data.workspaceSlug,
|
||||
action: "property_updated",
|
||||
page_id: documentName,
|
||||
data: { name: title },
|
||||
descendants_ids: [],
|
||||
});
|
||||
|
||||
// Use the instance from stored data (guaranteed to be set)
|
||||
broadcastMessageToPage(data.instance, data.parentId, event);
|
||||
}
|
||||
|
||||
// Schedule the title update
|
||||
if (manager) {
|
||||
manager.scheduleUpdate(title);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force save title before unloading the document
|
||||
*/
|
||||
async beforeUnloadDocument({ documentName }: { documentName: string }) {
|
||||
const updateManager = this.titleUpdateManagers.get(documentName);
|
||||
if (updateManager) {
|
||||
// Force immediate save and wait for it to complete
|
||||
await updateManager.forceSave();
|
||||
// Clean up the manager
|
||||
this.titleUpdateManagers.delete(documentName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove observers after document unload
|
||||
*/
|
||||
async afterUnloadDocument({ documentName, document }: { documentName: string; document?: Document }) {
|
||||
// Clean up observer when document is unloaded
|
||||
const observer = this.titleObservers.get(documentName);
|
||||
if (observer) {
|
||||
// unregister observer from Y.js document to prevent memory leak
|
||||
if (document) {
|
||||
try {
|
||||
document.getXmlFragment("title").unobserveDeep(observer);
|
||||
} catch (error) {
|
||||
logger.error("Failed to unobserve title field", new AppError(error, { context: { documentName } }));
|
||||
}
|
||||
}
|
||||
this.titleObservers.delete(documentName);
|
||||
}
|
||||
|
||||
// Clean up the observer data map to prevent memory leak
|
||||
this.titleObserverData.delete(documentName);
|
||||
|
||||
// Ensure manager is cleaned up if beforeUnloadDocument somehow didn't run
|
||||
if (this.titleUpdateManagers.has(documentName)) {
|
||||
const manager = this.titleUpdateManagers.get(documentName)!;
|
||||
manager.cancel();
|
||||
this.titleUpdateManagers.delete(documentName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { logger } from "@plane/logger";
|
||||
|
||||
/**
|
||||
* DebounceState - Tracks the state of a debounced function
|
||||
*/
|
||||
export interface DebounceState {
|
||||
lastArgs: any[] | null;
|
||||
timerId: ReturnType<typeof setTimeout> | null;
|
||||
lastCallTime: number | undefined;
|
||||
lastExecutionTime: number;
|
||||
inProgress: boolean;
|
||||
abortController: AbortController | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new DebounceState object
|
||||
*/
|
||||
export const createDebounceState = (): DebounceState => ({
|
||||
lastArgs: null,
|
||||
timerId: null,
|
||||
lastCallTime: undefined,
|
||||
lastExecutionTime: 0,
|
||||
inProgress: false,
|
||||
abortController: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* DebounceOptions - Configuration options for debounce
|
||||
*/
|
||||
export interface DebounceOptions {
|
||||
/** The wait time in milliseconds */
|
||||
wait: number;
|
||||
|
||||
/** Optional logging prefix for debug messages */
|
||||
logPrefix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced debounce manager with abort support
|
||||
* Manages the state and timing of debounced function calls
|
||||
*/
|
||||
export class DebounceManager {
|
||||
private state: DebounceState;
|
||||
private wait: number;
|
||||
private logPrefix: string;
|
||||
|
||||
/**
|
||||
* Creates a new DebounceManager
|
||||
* @param options Debounce configuration options
|
||||
*/
|
||||
constructor(options: DebounceOptions) {
|
||||
this.state = createDebounceState();
|
||||
this.wait = options.wait;
|
||||
this.logPrefix = options.logPrefix || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a debounced function call
|
||||
* @param func The function to call
|
||||
* @param args The arguments to pass to the function
|
||||
*/
|
||||
schedule(func: (...args: any[]) => Promise<void>, ...args: any[]): void {
|
||||
// Always update the last arguments
|
||||
this.state.lastArgs = args;
|
||||
|
||||
const time = Date.now();
|
||||
this.state.lastCallTime = time;
|
||||
|
||||
// If an operation is in progress, just store the new args and start the timer
|
||||
if (this.state.inProgress) {
|
||||
// Always restart the timer for the new call, even if an operation is in progress
|
||||
if (this.state.timerId) {
|
||||
clearTimeout(this.state.timerId);
|
||||
}
|
||||
|
||||
this.state.timerId = setTimeout(() => {
|
||||
this.timerExpired(func);
|
||||
}, this.wait);
|
||||
return;
|
||||
}
|
||||
|
||||
// If already scheduled, update the args and restart the timer
|
||||
if (this.state.timerId) {
|
||||
clearTimeout(this.state.timerId);
|
||||
this.state.timerId = setTimeout(() => {
|
||||
this.timerExpired(func);
|
||||
}, this.wait);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the timer for the trailing edge execution
|
||||
this.state.timerId = setTimeout(() => {
|
||||
this.timerExpired(func);
|
||||
}, this.wait);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the timer expires
|
||||
*/
|
||||
private timerExpired(func: (...args: any[]) => Promise<void>): void {
|
||||
const time = Date.now();
|
||||
|
||||
// Check if this timer expiration represents the end of the debounce period
|
||||
if (this.shouldInvoke(time)) {
|
||||
// Execute the function
|
||||
this.executeFunction(func, time);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise restart the timer
|
||||
this.state.timerId = setTimeout(() => {
|
||||
this.timerExpired(func);
|
||||
}, this.remainingWait(time));
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the debounced function
|
||||
*/
|
||||
private executeFunction(func: (...args: any[]) => Promise<void>, time: number): void {
|
||||
this.state.timerId = null;
|
||||
this.state.lastExecutionTime = time;
|
||||
|
||||
// Execute the function asynchronously
|
||||
this.performFunction(func).catch((error) => {
|
||||
logger.error(`${this.logPrefix}: Error in execution:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the actual function call, handling any in-progress operations
|
||||
*/
|
||||
private async performFunction(func: (...args: any[]) => Promise<void>): Promise<void> {
|
||||
const args = this.state.lastArgs;
|
||||
if (!args) return;
|
||||
|
||||
// Store the args we're about to use
|
||||
const currentArgs = [...args];
|
||||
|
||||
// If another operation is in progress, abort it
|
||||
await this.abortOngoingOperation();
|
||||
|
||||
// Mark that we're starting a new operation
|
||||
this.state.inProgress = true;
|
||||
this.state.abortController = new AbortController();
|
||||
|
||||
try {
|
||||
// Add the abort signal to the arguments if the function can use it
|
||||
const execArgs = [...currentArgs];
|
||||
execArgs.push(this.state.abortController.signal);
|
||||
|
||||
await func(...execArgs);
|
||||
|
||||
// Only clear lastArgs if they haven't been changed during this operation
|
||||
if (this.state.lastArgs && this.arraysEqual(this.state.lastArgs, currentArgs)) {
|
||||
this.state.lastArgs = null;
|
||||
|
||||
// Clear any timer as we've successfully processed the latest args
|
||||
if (this.state.timerId) {
|
||||
clearTimeout(this.state.timerId);
|
||||
this.state.timerId = null;
|
||||
}
|
||||
} else if (this.state.lastArgs) {
|
||||
// If lastArgs have changed during this operation, the timer should already be running
|
||||
// but let's make sure it is
|
||||
if (!this.state.timerId) {
|
||||
this.state.timerId = setTimeout(() => {
|
||||
this.timerExpired(func);
|
||||
}, this.wait);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
// Nothing to do here, the new operation will be triggered by the timer expiration
|
||||
} else {
|
||||
logger.error(`${this.logPrefix}: Error during operation:`, error);
|
||||
|
||||
// On error (not abort), make sure we have a timer running to retry
|
||||
if (!this.state.timerId && this.state.lastArgs) {
|
||||
this.state.timerId = setTimeout(() => {
|
||||
this.timerExpired(func);
|
||||
}, this.wait);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.state.inProgress = false;
|
||||
this.state.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort any ongoing operation
|
||||
*/
|
||||
private async abortOngoingOperation(): Promise<void> {
|
||||
if (this.state.inProgress && this.state.abortController) {
|
||||
this.state.abortController.abort();
|
||||
|
||||
// Small delay to ensure the abort has had time to propagate
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
// Double-check that state has been reset, force it if not
|
||||
if (this.state.inProgress || this.state.abortController) {
|
||||
this.state.inProgress = false;
|
||||
this.state.abortController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if we should invoke the function now
|
||||
*/
|
||||
private shouldInvoke(time: number): boolean {
|
||||
// Either this is the first call, or we've waited long enough since the last call
|
||||
return this.state.lastCallTime === undefined || time - this.state.lastCallTime >= this.wait;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate how much longer we should wait
|
||||
*/
|
||||
private remainingWait(time: number): number {
|
||||
const timeSinceLastCall = time - (this.state.lastCallTime || 0);
|
||||
return Math.max(0, this.wait - timeSinceLastCall);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force immediate execution
|
||||
*/
|
||||
async flush(func: (...args: any[]) => Promise<void>): Promise<void> {
|
||||
// Clear any pending timeout
|
||||
if (this.state.timerId) {
|
||||
clearTimeout(this.state.timerId);
|
||||
this.state.timerId = null;
|
||||
}
|
||||
|
||||
// Reset timing state
|
||||
this.state.lastCallTime = undefined;
|
||||
|
||||
// Perform the function immediately
|
||||
if (this.state.lastArgs) {
|
||||
await this.performFunction(func);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any pending operations without executing
|
||||
*/
|
||||
cancel(): void {
|
||||
// Clear any pending timeout
|
||||
if (this.state.timerId) {
|
||||
clearTimeout(this.state.timerId);
|
||||
this.state.timerId = null;
|
||||
}
|
||||
|
||||
// Reset timing state
|
||||
this.state.lastCallTime = undefined;
|
||||
|
||||
// Abort any in-progress operation
|
||||
if (this.state.inProgress && this.state.abortController) {
|
||||
this.state.abortController.abort();
|
||||
this.state.inProgress = false;
|
||||
this.state.abortController = null;
|
||||
}
|
||||
|
||||
// Clear args
|
||||
this.state.lastArgs = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two arrays for equality
|
||||
*/
|
||||
private arraysEqual(a: any[], b: any[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { logger } from "@plane/logger";
|
||||
import { AppError } from "@/lib/errors";
|
||||
import { getPageService } from "@/services/page/handler";
|
||||
import type { HocusPocusServerContext } from "@/types";
|
||||
import { DebounceManager } from "./debounce";
|
||||
|
||||
/**
|
||||
* Manages title update operations for a single document
|
||||
* Handles debouncing, aborting, and force saving title updates
|
||||
*/
|
||||
export class TitleUpdateManager {
|
||||
private documentName: string;
|
||||
private context: HocusPocusServerContext;
|
||||
private debounceManager: DebounceManager;
|
||||
private lastTitle: string | null = null;
|
||||
|
||||
/**
|
||||
* Create a new TitleUpdateManager instance
|
||||
*/
|
||||
constructor(documentName: string, context: HocusPocusServerContext, wait: number = 5000) {
|
||||
this.documentName = documentName;
|
||||
this.context = context;
|
||||
|
||||
// Set up debounce manager with logging
|
||||
this.debounceManager = new DebounceManager({
|
||||
wait,
|
||||
logPrefix: `TitleManager[${documentName.substring(0, 8)}]`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a debounced title update
|
||||
*/
|
||||
scheduleUpdate(title: string): void {
|
||||
// Store the latest title
|
||||
this.lastTitle = title;
|
||||
|
||||
// Schedule the update with the debounce manager
|
||||
this.debounceManager.schedule(this.updateTitle.bind(this), title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the title - will be called by the debounce manager
|
||||
*/
|
||||
private async updateTitle(title: string, signal?: AbortSignal): Promise<void> {
|
||||
const service = getPageService(this.context.documentType, this.context);
|
||||
if (!service.updatePageProperties) {
|
||||
logger.warn(`No updateTitle method found for document ${this.documentName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await service.updatePageProperties(this.documentName, {
|
||||
data: { name: title },
|
||||
abortSignal: signal,
|
||||
});
|
||||
|
||||
// Clear last title only if it matches what we just updated
|
||||
if (this.lastTitle === title) {
|
||||
this.lastTitle = null;
|
||||
}
|
||||
} catch (error) {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "updateTitle", documentName: this.documentName },
|
||||
});
|
||||
logger.error("Error updating title", appError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force save the current title immediately
|
||||
*/
|
||||
async forceSave(): Promise<void> {
|
||||
// Ensure we have the current title
|
||||
if (!this.lastTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the debounce manager to flush the operation
|
||||
await this.debounceManager.flush(this.updateTitle.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any pending updates
|
||||
*/
|
||||
cancel(): void {
|
||||
this.debounceManager.cancel();
|
||||
this.lastTitle = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { sanitizeHTML } from "@plane/utils";
|
||||
|
||||
/**
|
||||
* Utility function to extract text from HTML content
|
||||
*/
|
||||
export const extractTextFromHTML = (html: string): string => {
|
||||
// Use sanitizeHTML to safely extract text and remove all HTML tags
|
||||
// This is more secure than regex as it handles edge cases and prevents injection
|
||||
// Note: sanitizeHTML trims whitespace, which is acceptable for title extraction
|
||||
return sanitizeHTML(html) || "";
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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();
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// 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 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { 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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* PDF Export Color Constants
|
||||
*
|
||||
* These colors are mapped from the editor CSS variables and tailwind-config tokens
|
||||
* to ensure PDF exports match the editor's appearance.
|
||||
*
|
||||
* Source mappings:
|
||||
* - Editor colors: packages/editor/src/styles/variables.css
|
||||
* - Tailwind tokens: packages/tailwind-config/variables.css
|
||||
*/
|
||||
|
||||
// Editor text colors (from variables.css :root)
|
||||
export const EDITOR_TEXT_COLORS = {
|
||||
gray: "#5c5e63",
|
||||
peach: "#ff5b59",
|
||||
pink: "#f65385",
|
||||
orange: "#fd9038",
|
||||
green: "#0fc27b",
|
||||
"light-blue": "#17bee9",
|
||||
"dark-blue": "#266df0",
|
||||
purple: "#9162f9",
|
||||
} as const;
|
||||
|
||||
// Editor background colors - Light theme (from variables.css [data-theme*="light"])
|
||||
export const EDITOR_BACKGROUND_COLORS_LIGHT = {
|
||||
gray: "#d6d6d8",
|
||||
peach: "#ffd5d7",
|
||||
pink: "#fdd4e3",
|
||||
orange: "#ffe3cd",
|
||||
green: "#c3f0de",
|
||||
"light-blue": "#c5eff9",
|
||||
"dark-blue": "#c9dafb",
|
||||
purple: "#e3d8fd",
|
||||
} as const;
|
||||
|
||||
// Editor background colors - Dark theme (from variables.css [data-theme*="dark"])
|
||||
export const EDITOR_BACKGROUND_COLORS_DARK = {
|
||||
gray: "#404144",
|
||||
peach: "#593032",
|
||||
pink: "#562e3d",
|
||||
orange: "#583e2a",
|
||||
green: "#1d4a3b",
|
||||
"light-blue": "#1f495c",
|
||||
"dark-blue": "#223558",
|
||||
purple: "#3d325a",
|
||||
} as const;
|
||||
|
||||
// Use light theme colors by default for PDF exports
|
||||
export const EDITOR_BACKGROUND_COLORS = EDITOR_BACKGROUND_COLORS_LIGHT;
|
||||
|
||||
// Color key type
|
||||
export type EditorColorKey = keyof typeof EDITOR_TEXT_COLORS;
|
||||
|
||||
/**
|
||||
* Maps a color key to its text color hex value
|
||||
*/
|
||||
export const getTextColorHex = (colorKey: string): string | null => {
|
||||
if (colorKey in EDITOR_TEXT_COLORS) {
|
||||
return EDITOR_TEXT_COLORS[colorKey as EditorColorKey];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps a color key to its background color hex value
|
||||
*/
|
||||
export const getBackgroundColorHex = (colorKey: string): string | null => {
|
||||
if (colorKey in EDITOR_BACKGROUND_COLORS) {
|
||||
return EDITOR_BACKGROUND_COLORS[colorKey as EditorColorKey];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a value is a CSS variable reference (e.g., "var(--editor-colors-gray-text)")
|
||||
*/
|
||||
export const isCssVariable = (value: string): boolean => {
|
||||
return value.startsWith("var(");
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the color key from a CSS variable reference
|
||||
* e.g., "var(--editor-colors-gray-text)" -> "gray"
|
||||
* e.g., "var(--editor-colors-light-blue-background)" -> "light-blue"
|
||||
*/
|
||||
export const extractColorKeyFromCssVariable = (cssVar: string): string | null => {
|
||||
// Match patterns like: var(--editor-colors-{color}-text) or var(--editor-colors-{color}-background)
|
||||
const match = cssVar.match(/var\(--editor-colors-([\w-]+)-(text|background)\)/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a color value to a hex color for PDF rendering
|
||||
* Handles both direct hex values and CSS variable references
|
||||
*/
|
||||
export const resolveColorForPdf = (value: string | null | undefined, type: "text" | "background"): string | null => {
|
||||
if (!value) return null;
|
||||
|
||||
// If it's already a hex color, return it
|
||||
if (value.startsWith("#")) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// If it's a CSS variable, extract the key and get the hex value
|
||||
if (isCssVariable(value)) {
|
||||
const colorKey = extractColorKeyFromCssVariable(value);
|
||||
if (colorKey) {
|
||||
return type === "text" ? getTextColorHex(colorKey) : getBackgroundColorHex(colorKey);
|
||||
}
|
||||
}
|
||||
|
||||
// If it's just a color key (e.g., "gray", "peach"), get the hex value
|
||||
if (type === "text") {
|
||||
return getTextColorHex(value);
|
||||
}
|
||||
return getBackgroundColorHex(value);
|
||||
};
|
||||
|
||||
// Semantic colors from tailwind-config (light theme)
|
||||
// These are derived from the CSS variables in packages/tailwind-config/variables.css
|
||||
|
||||
// Neutral colors (light theme)
|
||||
export const NEUTRAL_COLORS = {
|
||||
white: "#ffffff",
|
||||
100: "#fafafa", // oklch(0.9848 0.0003 230.66) ≈ #fafafa
|
||||
200: "#f5f5f5", // oklch(0.9696 0.0007 230.67) ≈ #f5f5f5
|
||||
300: "#f0f0f0", // oklch(0.9543 0.001 230.67) ≈ #f0f0f0
|
||||
400: "#ebebeb", // oklch(0.9389 0.0014 230.68) ≈ #ebebeb
|
||||
500: "#e5e5e5", // oklch(0.9235 0.001733 230.6853) ≈ #e5e5e5
|
||||
600: "#d9d9d9", // oklch(0.8925 0.0024 230.7) ≈ #d9d9d9
|
||||
700: "#cccccc", // oklch(0.8612 0.0032 230.71) ≈ #cccccc
|
||||
800: "#8c8c8c", // oklch(0.6668 0.0079 230.82) ≈ #8c8c8c
|
||||
900: "#7a7a7a", // oklch(0.6161 0.009153 230.867) ≈ #7a7a7a
|
||||
1000: "#636363", // oklch(0.5288 0.0083 230.88) ≈ #636363
|
||||
1100: "#4d4d4d", // oklch(0.4377 0.0066 230.87) ≈ #4d4d4d
|
||||
1200: "#1f1f1f", // oklch(0.2378 0.0029 230.83) ≈ #1f1f1f
|
||||
black: "#0f0f0f", // oklch(0.1472 0.0034 230.83) ≈ #0f0f0f
|
||||
} as const;
|
||||
|
||||
// Brand colors (light theme accent)
|
||||
export const BRAND_COLORS = {
|
||||
default: "#3f76ff", // oklch(0.4799 0.1158 242.91) - primary accent blue
|
||||
100: "#f5f8ff",
|
||||
200: "#e8f0ff",
|
||||
300: "#d1e1ff",
|
||||
400: "#b3d0ff",
|
||||
500: "#8ab8ff",
|
||||
600: "#5c9aff",
|
||||
700: "#3f76ff",
|
||||
900: "#2952b3",
|
||||
1000: "#1e3d80",
|
||||
1100: "#142b5c",
|
||||
1200: "#0d1f40",
|
||||
} as const;
|
||||
|
||||
// Semantic text colors
|
||||
export const TEXT_COLORS = {
|
||||
primary: NEUTRAL_COLORS[1200], // --txt-primary
|
||||
secondary: NEUTRAL_COLORS[1100], // --txt-secondary
|
||||
tertiary: NEUTRAL_COLORS[1000], // --txt-tertiary
|
||||
placeholder: NEUTRAL_COLORS[900], // --txt-placeholder
|
||||
disabled: NEUTRAL_COLORS[800], // --txt-disabled
|
||||
accentPrimary: BRAND_COLORS.default, // --txt-accent-primary
|
||||
linkPrimary: BRAND_COLORS.default, // --txt-link-primary
|
||||
} as const;
|
||||
|
||||
// Semantic background colors
|
||||
export const BACKGROUND_COLORS = {
|
||||
canvas: NEUTRAL_COLORS[300], // --bg-canvas
|
||||
surface1: NEUTRAL_COLORS.white, // --bg-surface-1
|
||||
surface2: NEUTRAL_COLORS[100], // --bg-surface-2
|
||||
layer1: NEUTRAL_COLORS[200], // --bg-layer-1
|
||||
layer2: NEUTRAL_COLORS.white, // --bg-layer-2
|
||||
layer3: NEUTRAL_COLORS[300], // --bg-layer-3
|
||||
accentSubtle: "#f5f8ff", // --bg-accent-subtle (brand-100)
|
||||
} as const;
|
||||
|
||||
// Semantic border colors
|
||||
export const BORDER_COLORS = {
|
||||
subtle: NEUTRAL_COLORS[400], // --border-subtle
|
||||
subtle1: NEUTRAL_COLORS[500], // --border-subtle-1
|
||||
strong: NEUTRAL_COLORS[600], // --border-strong
|
||||
strong1: NEUTRAL_COLORS[700], // --border-strong-1
|
||||
accentStrong: BRAND_COLORS.default, // --border-accent-strong
|
||||
} as const;
|
||||
|
||||
// Code/inline code colors
|
||||
export const CODE_COLORS = {
|
||||
background: NEUTRAL_COLORS[200], // Similar to bg-layer-1
|
||||
text: "#dc2626", // Red for inline code text (matches editor)
|
||||
blockText: NEUTRAL_COLORS[1200], // Regular text for code blocks
|
||||
} as const;
|
||||
|
||||
// Link colors
|
||||
export const LINK_COLORS = {
|
||||
primary: BRAND_COLORS.default,
|
||||
hover: BRAND_COLORS[900],
|
||||
} as const;
|
||||
|
||||
// Mention colors (from pi-chat-editor mention styles: bg-accent-primary/20 text-accent-primary)
|
||||
export const MENTION_COLORS = {
|
||||
background: "#e0e9ff", // accent-primary with ~20% opacity on white
|
||||
text: BRAND_COLORS.default,
|
||||
} as const;
|
||||
|
||||
// Success/Green colors
|
||||
export const SUCCESS_COLORS = {
|
||||
primary: "#10b981",
|
||||
subtle: "#d1fae5",
|
||||
} as const;
|
||||
|
||||
// Warning/Amber colors
|
||||
export const WARNING_COLORS = {
|
||||
primary: "#f59e0b",
|
||||
subtle: "#fef3c7",
|
||||
} as const;
|
||||
|
||||
// Danger/Red colors
|
||||
export const DANGER_COLORS = {
|
||||
primary: "#ef4444",
|
||||
subtle: "#fee2e2",
|
||||
} as const;
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Circle, Path, Rect, Svg } from "@react-pdf/renderer";
|
||||
|
||||
type IconProps = {
|
||||
size?: number;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
// Lightbulb icon for callouts (default)
|
||||
export const LightbulbIcon = ({ size = 16, color = "#ffffff" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M9 21h6M12 3a6 6 0 0 0-6 6c0 2.22 1.21 4.16 3 5.19V17a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-2.81c1.79-1.03 3-2.97 3-5.19a6 6 0 0 0-6-6z"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Document/file icon for page embeds
|
||||
export const DocumentIcon = ({ size = 12, color = "#1e40af" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<Path d="M14 2v6h6" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<Path d="M16 13H8M16 17H8M10 9H8" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Link icon for page links and external links
|
||||
export const LinkIcon = ({ size = 12, color = "#2563eb" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<Path
|
||||
d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Paperclip icon for attachments (default)
|
||||
export const PaperclipIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Image icon for image attachments
|
||||
export const ImageIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Rect x={3} y={3} width={18} height={18} rx={2} ry={2} fill="none" stroke={color} strokeWidth={2} />
|
||||
<Circle cx={8.5} cy={8.5} r={1.5} fill={color} />
|
||||
<Path d="M21 15l-5-5L5 21" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Video icon for video attachments
|
||||
export const VideoIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Rect x={2} y={4} width={15} height={16} rx={2} ry={2} fill="none" stroke={color} strokeWidth={2} />
|
||||
<Path d="M17 10l5-3v10l-5-3z" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Music/audio icon
|
||||
export const MusicIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path d="M9 18V5l12-2v13" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
<Circle cx={6} cy={18} r={3} fill="none" stroke={color} strokeWidth={2} />
|
||||
<Circle cx={18} cy={16} r={3} fill="none" stroke={color} strokeWidth={2} />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// File-text icon for PDFs and documents
|
||||
export const FileTextIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<Path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Table/spreadsheet icon
|
||||
export const TableIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Rect x={3} y={3} width={18} height={18} rx={2} fill="none" stroke={color} strokeWidth={2} />
|
||||
<Path d="M3 9h18M3 15h18M9 3v18M15 3v18" fill="none" stroke={color} strokeWidth={2} />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Presentation icon
|
||||
export const PresentationIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Rect x={2} y={3} width={20} height={14} rx={2} fill="none" stroke={color} strokeWidth={2} />
|
||||
<Path d="M8 21l4-4 4 4M12 17v-4" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Archive/zip icon
|
||||
export const ArchiveIcon = ({ size = 16, color = "#374151" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M21 8v13a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V8"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<Path
|
||||
d="M23 3H1v5h22V3zM10 12h4"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Globe icon for external embeds (rich cards)
|
||||
export const GlobeIcon = ({ size = 12, color = "#374151" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} fill="none" stroke={color} strokeWidth={2} />
|
||||
<Path
|
||||
d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Clipboard icon for whiteboards
|
||||
export const ClipboardIcon = ({ size = 12, color = "#6b7280" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<Rect x={8} y={2} width={8} height={4} rx={1} fill="none" stroke={color} strokeWidth={2} />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Ruler/diagram icon for diagrams
|
||||
export const DiagramIcon = ({ size = 12, color = "#6b7280" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M14 3v4a1 1 0 0 0 1 1h4"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<Path
|
||||
d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Path d="M9 9h1M9 13h6M9 17h6" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Work item / task icon
|
||||
export const TaskIcon = ({ size = 14, color = "#374151" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Rect x={3} y={3} width={18} height={18} rx={2} fill="none" stroke={color} strokeWidth={2} />
|
||||
<Path d="M9 12l2 2 4-4" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Checkmark icon for checked task items
|
||||
export const CheckIcon = ({ size = 10, color = "#ffffff" }: IconProps) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24">
|
||||
<Path d="M20 6L9 17l-5-5" fill="none" stroke={color} strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// Helper to get file icon component based on file type
|
||||
export const getFileIcon = (fileType: string, size = 16, color = "#374151") => {
|
||||
if (fileType.startsWith("image/")) return <ImageIcon size={size} color={color} />;
|
||||
if (fileType.startsWith("video/")) return <VideoIcon size={size} color={color} />;
|
||||
if (fileType.startsWith("audio/")) return <MusicIcon size={size} color={color} />;
|
||||
if (fileType.includes("pdf")) return <FileTextIcon size={size} color="#dc2626" />;
|
||||
if (fileType.includes("spreadsheet") || fileType.includes("excel")) return <TableIcon size={size} color="#16a34a" />;
|
||||
if (fileType.includes("document") || fileType.includes("word")) return <FileTextIcon size={size} color="#2563eb" />;
|
||||
if (fileType.includes("presentation") || fileType.includes("powerpoint"))
|
||||
return <PresentationIcon size={size} color="#ea580c" />;
|
||||
if (fileType.includes("zip") || fileType.includes("archive")) return <ArchiveIcon size={size} color={color} />;
|
||||
return <PaperclipIcon size={size} color={color} />;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export { createPdfDocument, renderPlaneDocToPdfBlob, renderPlaneDocToPdfBuffer } from "./plane-pdf-exporter";
|
||||
export { createKeyGenerator, nodeRenderers, renderNode } from "./node-renderers";
|
||||
export { markRenderers, applyMarks } from "./mark-renderers";
|
||||
export { pdfStyles } from "./styles";
|
||||
export type {
|
||||
KeyGenerator,
|
||||
MarkRendererRegistry,
|
||||
NodeRendererRegistry,
|
||||
PDFExportMetadata,
|
||||
PDFExportOptions,
|
||||
PDFMarkRenderer,
|
||||
PDFNodeRenderer,
|
||||
PDFRenderContext,
|
||||
PDFUserMention,
|
||||
TipTapDocument,
|
||||
TipTapMark,
|
||||
TipTapNode,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import {
|
||||
BACKGROUND_COLORS,
|
||||
CODE_COLORS,
|
||||
EDITOR_BACKGROUND_COLORS,
|
||||
EDITOR_TEXT_COLORS,
|
||||
LINK_COLORS,
|
||||
resolveColorForPdf,
|
||||
} from "./colors";
|
||||
import type { MarkRendererRegistry, TipTapMark } from "./types";
|
||||
|
||||
export const markRenderers: MarkRendererRegistry = {
|
||||
bold: (_mark: TipTapMark, style: Style): Style => ({
|
||||
...style,
|
||||
fontWeight: "bold",
|
||||
}),
|
||||
|
||||
italic: (_mark: TipTapMark, style: Style): Style => ({
|
||||
...style,
|
||||
fontStyle: "italic",
|
||||
}),
|
||||
|
||||
underline: (_mark: TipTapMark, style: Style): Style => ({
|
||||
...style,
|
||||
textDecoration: "underline",
|
||||
}),
|
||||
|
||||
strike: (_mark: TipTapMark, style: Style): Style => ({
|
||||
...style,
|
||||
textDecoration: "line-through",
|
||||
}),
|
||||
|
||||
code: (_mark: TipTapMark, style: Style): Style => ({
|
||||
...style,
|
||||
fontFamily: "Courier",
|
||||
fontSize: 10,
|
||||
backgroundColor: BACKGROUND_COLORS.layer1,
|
||||
color: CODE_COLORS.text,
|
||||
}),
|
||||
|
||||
link: (_mark: TipTapMark, style: Style): Style => ({
|
||||
...style,
|
||||
color: LINK_COLORS.primary,
|
||||
textDecoration: "underline",
|
||||
}),
|
||||
|
||||
textStyle: (mark: TipTapMark, style: Style): Style => {
|
||||
const attrs = mark.attrs || {};
|
||||
const newStyle: Style = { ...style };
|
||||
|
||||
if (attrs.color && typeof attrs.color === "string") {
|
||||
newStyle.color = attrs.color;
|
||||
}
|
||||
|
||||
if (attrs.backgroundColor && typeof attrs.backgroundColor === "string") {
|
||||
newStyle.backgroundColor = attrs.backgroundColor;
|
||||
}
|
||||
|
||||
return newStyle;
|
||||
},
|
||||
|
||||
highlight: (mark: TipTapMark, style: Style): Style => {
|
||||
const attrs = mark.attrs || {};
|
||||
return {
|
||||
...style,
|
||||
backgroundColor: (attrs.color as string) || EDITOR_BACKGROUND_COLORS.purple,
|
||||
};
|
||||
},
|
||||
|
||||
subscript: (_mark: TipTapMark, style: Style): Style => ({
|
||||
...style,
|
||||
fontSize: 8,
|
||||
}),
|
||||
|
||||
superscript: (_mark: TipTapMark, style: Style): Style => ({
|
||||
...style,
|
||||
fontSize: 8,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Custom color mark handler
|
||||
* Handles the customColor extension which stores colors as data-text-color and data-background-color attributes
|
||||
* The colors can be either:
|
||||
* 1. Color keys like "gray", "peach", "pink", etc. (from COLORS_LIST)
|
||||
* 2. Direct hex values for custom colors
|
||||
* 3. CSS variable references like "var(--editor-colors-gray-text)"
|
||||
*/
|
||||
customColor: (mark: TipTapMark, style: Style): Style => {
|
||||
const attrs = mark.attrs || {};
|
||||
const newStyle: Style = { ...style };
|
||||
|
||||
// Handle text color (stored in 'color' attribute)
|
||||
const textColor = attrs.color as string | undefined;
|
||||
if (textColor) {
|
||||
const resolvedColor = resolveColorForPdf(textColor, "text");
|
||||
if (resolvedColor) {
|
||||
newStyle.color = resolvedColor;
|
||||
} else if (textColor.startsWith("#") || textColor.startsWith("rgb")) {
|
||||
// Direct color value
|
||||
newStyle.color = textColor;
|
||||
} else if (textColor in EDITOR_TEXT_COLORS) {
|
||||
// Color key lookup
|
||||
newStyle.color = EDITOR_TEXT_COLORS[textColor as keyof typeof EDITOR_TEXT_COLORS];
|
||||
}
|
||||
}
|
||||
|
||||
// Handle background color (stored in 'backgroundColor' attribute)
|
||||
const backgroundColor = attrs.backgroundColor as string | undefined;
|
||||
if (backgroundColor) {
|
||||
const resolvedColor = resolveColorForPdf(backgroundColor, "background");
|
||||
if (resolvedColor) {
|
||||
newStyle.backgroundColor = resolvedColor;
|
||||
} else if (backgroundColor.startsWith("#") || backgroundColor.startsWith("rgb")) {
|
||||
// Direct color value
|
||||
newStyle.backgroundColor = backgroundColor;
|
||||
} else if (backgroundColor in EDITOR_BACKGROUND_COLORS) {
|
||||
// Color key lookup
|
||||
newStyle.backgroundColor = EDITOR_BACKGROUND_COLORS[backgroundColor as keyof typeof EDITOR_BACKGROUND_COLORS];
|
||||
}
|
||||
}
|
||||
|
||||
return newStyle;
|
||||
},
|
||||
};
|
||||
|
||||
export const applyMarks = (marks: TipTapMark[] | undefined, baseStyle: Style = {}): Style => {
|
||||
if (!marks || marks.length === 0) {
|
||||
return baseStyle;
|
||||
}
|
||||
|
||||
return marks.reduce((style, mark) => {
|
||||
const renderer = markRenderers[mark.type];
|
||||
if (renderer) {
|
||||
return renderer(mark, style);
|
||||
}
|
||||
return style;
|
||||
}, baseStyle);
|
||||
};
|
||||
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Image, Link, Text, View } from "@react-pdf/renderer";
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { ReactElement } from "react";
|
||||
import { CORE_EXTENSIONS } from "@plane/editor";
|
||||
import { BACKGROUND_COLORS, EDITOR_BACKGROUND_COLORS, resolveColorForPdf, TEXT_COLORS } from "./colors";
|
||||
import { CheckIcon, ClipboardIcon, DocumentIcon, GlobeIcon, LightbulbIcon, LinkIcon } from "./icons";
|
||||
import { applyMarks } from "./mark-renderers";
|
||||
import { pdfStyles } from "./styles";
|
||||
import type { KeyGenerator, NodeRendererRegistry, PDFExportMetadata, PDFRenderContext, TipTapNode } from "./types";
|
||||
|
||||
const getCalloutIcon = (node: TipTapNode, color: string): ReactElement => {
|
||||
const logoInUse = node.attrs?.["data-logo-in-use"] as string | undefined;
|
||||
const iconName = node.attrs?.["data-icon-name"] as string | undefined;
|
||||
const iconColor = (node.attrs?.["data-icon-color"] as string) || color;
|
||||
|
||||
if (logoInUse === "emoji") {
|
||||
const emojiUnicode = node.attrs?.["data-emoji-unicode"] as string | undefined;
|
||||
if (emojiUnicode) {
|
||||
return <Text style={{ fontSize: 14 }}>{emojiUnicode}</Text>;
|
||||
}
|
||||
}
|
||||
|
||||
if (iconName) {
|
||||
switch (iconName) {
|
||||
case "FileText":
|
||||
case "File":
|
||||
return <DocumentIcon size={16} color={iconColor} />;
|
||||
case "Link":
|
||||
return <LinkIcon size={16} color={iconColor} />;
|
||||
case "Globe":
|
||||
return <GlobeIcon size={16} color={iconColor} />;
|
||||
case "Clipboard":
|
||||
return <ClipboardIcon size={16} color={iconColor} />;
|
||||
case "CheckSquare":
|
||||
case "Check":
|
||||
return <CheckIcon size={16} color={iconColor} />;
|
||||
case "Lightbulb":
|
||||
default:
|
||||
return <LightbulbIcon size={16} color={iconColor} />;
|
||||
}
|
||||
}
|
||||
|
||||
return <LightbulbIcon size={16} color={color} />;
|
||||
};
|
||||
|
||||
export const createKeyGenerator = (): KeyGenerator => {
|
||||
let counter = 0;
|
||||
return () => `node-${counter++}`;
|
||||
};
|
||||
|
||||
const renderTextWithMarks = (node: TipTapNode, getKey: KeyGenerator): ReactElement => {
|
||||
const style = applyMarks(node.marks, {});
|
||||
const hasLink = node.marks?.find((m) => m.type === "link");
|
||||
|
||||
if (hasLink) {
|
||||
const href = (hasLink.attrs?.href as string) || "#";
|
||||
return (
|
||||
<Link key={getKey()} src={href} style={{ ...pdfStyles.link, ...style }}>
|
||||
{node.text || ""}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text key={getKey()} style={style}>
|
||||
{node.text || ""}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const getTextAlignStyle = (textAlign: string | null | undefined): Style => {
|
||||
if (!textAlign) return {};
|
||||
return {
|
||||
textAlign: textAlign as "left" | "right" | "center" | "justify",
|
||||
};
|
||||
};
|
||||
|
||||
const getFlexAlignStyle = (textAlign: string | null | undefined): Style => {
|
||||
if (!textAlign) return {};
|
||||
if (textAlign === "right") return { alignItems: "flex-end" };
|
||||
if (textAlign === "center") return { alignItems: "center" };
|
||||
return {};
|
||||
};
|
||||
|
||||
export const nodeRenderers: NodeRendererRegistry = {
|
||||
doc: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
|
||||
<View key={ctx.getKey()}>{children}</View>
|
||||
),
|
||||
|
||||
text: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement =>
|
||||
renderTextWithMarks(node, ctx.getKey),
|
||||
|
||||
paragraph: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const textAlign = node.attrs?.textAlign as string | null;
|
||||
const background = node.attrs?.backgroundColor as string | undefined;
|
||||
const alignStyle = getTextAlignStyle(textAlign);
|
||||
const flexStyle = getFlexAlignStyle(textAlign);
|
||||
const resolvedBgColor =
|
||||
background && background !== "default" ? resolveColorForPdf(background, "background") : null;
|
||||
const bgStyle = resolvedBgColor ? { backgroundColor: resolvedBgColor } : {};
|
||||
|
||||
return (
|
||||
<View key={ctx.getKey()} style={[pdfStyles.paragraphWrapper, flexStyle, bgStyle]}>
|
||||
<Text style={[pdfStyles.paragraph, alignStyle, bgStyle]}>{children}</Text>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
heading: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const level = (node.attrs?.level as number) || 1;
|
||||
const styleKey = `heading${level}` as keyof typeof pdfStyles;
|
||||
const style = pdfStyles[styleKey] || pdfStyles.heading1;
|
||||
const textAlign = node.attrs?.textAlign as string | null;
|
||||
const alignStyle = getTextAlignStyle(textAlign);
|
||||
const flexStyle = getFlexAlignStyle(textAlign);
|
||||
|
||||
return (
|
||||
<View key={ctx.getKey()} style={flexStyle}>
|
||||
<Text style={[style, alignStyle]}>{children}</Text>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
blockquote: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
|
||||
<View key={ctx.getKey()} style={pdfStyles.blockquote} wrap={false}>
|
||||
{children}
|
||||
</View>
|
||||
),
|
||||
|
||||
codeBlock: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const codeContent = node.content?.map((c) => c.text || "").join("") || "";
|
||||
return (
|
||||
<View key={ctx.getKey()} style={pdfStyles.codeBlock} wrap={false}>
|
||||
<Text>{codeContent}</Text>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
bulletList: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const nestingLevel = (node.attrs?._nestingLevel as number) || 0;
|
||||
const indentStyle = nestingLevel > 0 ? { marginLeft: 18 } : {};
|
||||
return (
|
||||
<View key={ctx.getKey()} style={[pdfStyles.bulletList, indentStyle]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
orderedList: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const nestingLevel = (node.attrs?._nestingLevel as number) || 0;
|
||||
const indentStyle = nestingLevel > 0 ? { marginLeft: 18 } : {};
|
||||
return (
|
||||
<View key={ctx.getKey()} style={[pdfStyles.orderedList, indentStyle]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
listItem: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const isOrdered = node.attrs?._parentType === "orderedList";
|
||||
const index = (node.attrs?._listItemIndex as number) || 0;
|
||||
|
||||
const bullet = isOrdered ? `${index}.` : "•";
|
||||
|
||||
const textAlign = node.attrs?._textAlign as string | null;
|
||||
const flexStyle = getFlexAlignStyle(textAlign);
|
||||
|
||||
return (
|
||||
<View key={ctx.getKey()} style={[pdfStyles.listItem, flexStyle]} wrap={false}>
|
||||
<View style={pdfStyles.listItemBullet}>
|
||||
<Text>{bullet}</Text>
|
||||
</View>
|
||||
<View style={pdfStyles.listItemContent}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
taskList: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
|
||||
<View key={ctx.getKey()} style={pdfStyles.taskList}>
|
||||
{children}
|
||||
</View>
|
||||
),
|
||||
|
||||
taskItem: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const checked = node.attrs?.checked === true;
|
||||
return (
|
||||
<View key={ctx.getKey()} style={pdfStyles.taskItem} wrap={false}>
|
||||
<View style={checked ? [pdfStyles.taskCheckbox, pdfStyles.taskCheckboxChecked] : pdfStyles.taskCheckbox}>
|
||||
{checked && <CheckIcon size={8} color="#ffffff" />}
|
||||
</View>
|
||||
<View style={pdfStyles.listItemContent}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
table: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
|
||||
<View key={ctx.getKey()} style={pdfStyles.table}>
|
||||
{children}
|
||||
</View>
|
||||
),
|
||||
|
||||
tableRow: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const isHeader = node.attrs?._isHeader === true;
|
||||
return (
|
||||
<View key={ctx.getKey()} style={isHeader ? pdfStyles.tableHeaderRow : pdfStyles.tableRow} wrap={false}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
tableHeader: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const colwidth = node.attrs?.colwidth as number[] | undefined;
|
||||
const background = node.attrs?.background as string | undefined;
|
||||
const width = colwidth?.[0];
|
||||
const widthStyle = width ? { width, flex: undefined } : {};
|
||||
const resolvedBgColor = background ? resolveColorForPdf(background, "background") : null;
|
||||
const bgStyle = resolvedBgColor ? { backgroundColor: resolvedBgColor } : {};
|
||||
|
||||
return (
|
||||
<View key={ctx.getKey()} style={[pdfStyles.tableHeaderCell, widthStyle, bgStyle]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
tableCell: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const colwidth = node.attrs?.colwidth as number[] | undefined;
|
||||
const background = node.attrs?.background as string | undefined;
|
||||
const width = colwidth?.[0];
|
||||
const widthStyle = width ? { width, flex: undefined } : {};
|
||||
const resolvedBgColor = background ? resolveColorForPdf(background, "background") : null;
|
||||
const bgStyle = resolvedBgColor ? { backgroundColor: resolvedBgColor } : {};
|
||||
|
||||
return (
|
||||
<View key={ctx.getKey()} style={[pdfStyles.tableCell, widthStyle, bgStyle]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
horizontalRule: (_node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
|
||||
<View key={ctx.getKey()} style={pdfStyles.horizontalRule} />
|
||||
),
|
||||
|
||||
hardBreak: (_node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
|
||||
<Text key={ctx.getKey()}>{"\n"}</Text>
|
||||
),
|
||||
|
||||
image: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
if (ctx.metadata?.noAssets) {
|
||||
return <View key={ctx.getKey()} />;
|
||||
}
|
||||
|
||||
const src = (node.attrs?.src as string) || "";
|
||||
const width = node.attrs?.width as number | undefined;
|
||||
const alignment = (node.attrs?.alignment as string) || "left";
|
||||
|
||||
if (!src) {
|
||||
return <View key={ctx.getKey()} />;
|
||||
}
|
||||
|
||||
const alignmentStyle =
|
||||
alignment === "center"
|
||||
? { alignItems: "center" as const }
|
||||
: alignment === "right"
|
||||
? { alignItems: "flex-end" as const }
|
||||
: { alignItems: "flex-start" as const };
|
||||
|
||||
return (
|
||||
<View key={ctx.getKey()} style={[{ width: "100%" }, alignmentStyle]}>
|
||||
<Image
|
||||
src={src}
|
||||
style={[pdfStyles.image, width ? { width, maxHeight: 500 } : { maxWidth: 400, maxHeight: 500 }]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
imageComponent: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
if (ctx.metadata?.noAssets) {
|
||||
return <View key={ctx.getKey()} />;
|
||||
}
|
||||
|
||||
const assetId = (node.attrs?.src as string) || "";
|
||||
const rawWidth = node.attrs?.width;
|
||||
const width = typeof rawWidth === "string" ? parseInt(rawWidth, 10) : (rawWidth as number | undefined);
|
||||
const alignment = (node.attrs?.alignment as string) || "left";
|
||||
|
||||
if (!assetId) {
|
||||
return <View key={ctx.getKey()} />;
|
||||
}
|
||||
|
||||
let resolvedSrc = assetId;
|
||||
if (ctx.metadata?.resolvedImageUrls && ctx.metadata.resolvedImageUrls[assetId]) {
|
||||
resolvedSrc = ctx.metadata.resolvedImageUrls[assetId];
|
||||
}
|
||||
|
||||
const alignmentStyle =
|
||||
alignment === "center"
|
||||
? { alignItems: "center" as const }
|
||||
: alignment === "right"
|
||||
? { alignItems: "flex-end" as const }
|
||||
: { alignItems: "flex-start" as const };
|
||||
|
||||
if (!resolvedSrc.startsWith("http") && !resolvedSrc.startsWith("data:")) {
|
||||
return (
|
||||
<View key={ctx.getKey()} style={[pdfStyles.imagePlaceholder, alignmentStyle]}>
|
||||
<Text style={pdfStyles.imagePlaceholderText}>[Image: {assetId.slice(0, 8)}...]</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const imageStyle = width && !isNaN(width) ? { width, maxHeight: 500 } : { maxWidth: 400, maxHeight: 500 };
|
||||
|
||||
return (
|
||||
<View key={ctx.getKey()} style={[{ width: "100%" }, alignmentStyle]}>
|
||||
<Image src={resolvedSrc} style={[pdfStyles.image, imageStyle]} />
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
calloutComponent: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const backgroundKey = (node.attrs?.["data-background"] as string) || "gray";
|
||||
const backgroundColor =
|
||||
EDITOR_BACKGROUND_COLORS[backgroundKey as keyof typeof EDITOR_BACKGROUND_COLORS] || BACKGROUND_COLORS.layer3;
|
||||
|
||||
return (
|
||||
<View key={ctx.getKey()} style={[pdfStyles.callout, { backgroundColor }]}>
|
||||
<View style={pdfStyles.calloutIconContainer}>{getCalloutIcon(node, TEXT_COLORS.primary)}</View>
|
||||
<View style={[pdfStyles.calloutContent, { color: TEXT_COLORS.primary }]}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
mention: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
|
||||
const id = (node.attrs?.id as string) || "";
|
||||
const entityIdentifier = (node.attrs?.entity_identifier as string) || "";
|
||||
const entityName = (node.attrs?.entity_name as string) || "";
|
||||
|
||||
let displayText = entityName || id || entityIdentifier;
|
||||
|
||||
if (ctx.metadata && (entityName === "user_mention" || entityName === "user")) {
|
||||
const userMention = ctx.metadata.userMentions?.find((u) => u.id === entityIdentifier || u.id === id);
|
||||
if (userMention) {
|
||||
displayText = userMention.display_name;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Text key={ctx.getKey()} style={pdfStyles.mention}>
|
||||
@{displayText}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
type InternalRenderContext = {
|
||||
parentType?: string;
|
||||
nestingLevel: number;
|
||||
listItemIndex: number;
|
||||
textAlign?: string | null;
|
||||
pdfContext: PDFRenderContext;
|
||||
};
|
||||
|
||||
const renderNodeWithContext = (node: TipTapNode, context: InternalRenderContext): ReactElement => {
|
||||
const { parentType, nestingLevel, listItemIndex, textAlign, pdfContext } = context;
|
||||
|
||||
const isListContainer = node.type === CORE_EXTENSIONS.BULLET_LIST || node.type === CORE_EXTENSIONS.ORDERED_LIST;
|
||||
|
||||
let childTextAlign = textAlign;
|
||||
if (node.type === CORE_EXTENSIONS.PARAGRAPH && node.attrs?.textAlign) {
|
||||
childTextAlign = node.attrs.textAlign as string;
|
||||
}
|
||||
|
||||
const nodeWithContext = {
|
||||
...node,
|
||||
attrs: {
|
||||
...node.attrs,
|
||||
_parentType: parentType,
|
||||
_nestingLevel: nestingLevel,
|
||||
_listItemIndex: listItemIndex,
|
||||
_textAlign: childTextAlign,
|
||||
_isHeader: node.content?.some((child) => child.type === CORE_EXTENSIONS.TABLE_HEADER),
|
||||
},
|
||||
};
|
||||
|
||||
let childNestingLevel = nestingLevel;
|
||||
if (isListContainer && parentType === CORE_EXTENSIONS.LIST_ITEM) {
|
||||
childNestingLevel = nestingLevel + 1;
|
||||
}
|
||||
|
||||
let currentListItemIndex = 0;
|
||||
const children: ReactElement[] =
|
||||
node.content?.map((child) => {
|
||||
const childContext: InternalRenderContext = {
|
||||
parentType: node.type,
|
||||
nestingLevel: childNestingLevel,
|
||||
listItemIndex: 0,
|
||||
textAlign: childTextAlign,
|
||||
pdfContext,
|
||||
};
|
||||
|
||||
if (isListContainer && child.type === CORE_EXTENSIONS.LIST_ITEM) {
|
||||
currentListItemIndex++;
|
||||
childContext.listItemIndex = currentListItemIndex;
|
||||
}
|
||||
|
||||
return renderNodeWithContext(child, childContext);
|
||||
}) || [];
|
||||
|
||||
const renderer = nodeRenderers[node.type];
|
||||
if (renderer) {
|
||||
return renderer(nodeWithContext, children, pdfContext);
|
||||
}
|
||||
|
||||
if (children.length > 0) {
|
||||
return <View key={pdfContext.getKey()}>{children}</View>;
|
||||
}
|
||||
|
||||
return <View key={pdfContext.getKey()} />;
|
||||
};
|
||||
|
||||
export const renderNode = (
|
||||
node: TipTapNode,
|
||||
parentType?: string,
|
||||
_index?: number,
|
||||
metadata?: PDFExportMetadata,
|
||||
getKey?: KeyGenerator
|
||||
): ReactElement => {
|
||||
const keyGen = getKey ?? createKeyGenerator();
|
||||
|
||||
return renderNodeWithContext(node, {
|
||||
parentType,
|
||||
nestingLevel: 0,
|
||||
listItemIndex: 0,
|
||||
pdfContext: { getKey: keyGen, metadata },
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { createRequire } from "module";
|
||||
import path from "path";
|
||||
import { Document, Font, Page, pdf, Text } from "@react-pdf/renderer";
|
||||
import { createKeyGenerator, renderNode } from "./node-renderers";
|
||||
import { pdfStyles } from "./styles";
|
||||
import type { PDFExportOptions, TipTapDocument } from "./types";
|
||||
|
||||
// Use createRequire for ESM compatibility to resolve font file paths
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// Resolve local font file paths from @fontsource/inter package
|
||||
const interFontDir = path.dirname(require.resolve("@fontsource/inter/package.json"));
|
||||
|
||||
Font.register({
|
||||
family: "Inter",
|
||||
fonts: [
|
||||
{
|
||||
src: path.join(interFontDir, "files/inter-latin-400-normal.woff"),
|
||||
fontWeight: 400,
|
||||
},
|
||||
{
|
||||
src: path.join(interFontDir, "files/inter-latin-400-italic.woff"),
|
||||
fontWeight: 400,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
{
|
||||
src: path.join(interFontDir, "files/inter-latin-600-normal.woff"),
|
||||
fontWeight: 600,
|
||||
},
|
||||
{
|
||||
src: path.join(interFontDir, "files/inter-latin-600-italic.woff"),
|
||||
fontWeight: 600,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
{
|
||||
src: path.join(interFontDir, "files/inter-latin-700-normal.woff"),
|
||||
fontWeight: 700,
|
||||
},
|
||||
{
|
||||
src: path.join(interFontDir, "files/inter-latin-700-italic.woff"),
|
||||
fontWeight: 700,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const createPdfDocument = (doc: TipTapDocument, options: PDFExportOptions = {}) => {
|
||||
const { title, author, subject, pageSize = "A4", pageOrientation = "portrait", metadata, noAssets } = options;
|
||||
|
||||
// Merge noAssets into metadata for use in node renderers
|
||||
const mergedMetadata = { ...metadata, noAssets };
|
||||
|
||||
const content = doc.content || [];
|
||||
const getKey = createKeyGenerator();
|
||||
const renderedContent = content.map((node, index) => renderNode(node, "doc", index, mergedMetadata, getKey));
|
||||
|
||||
return (
|
||||
<Document title={title} author={author} subject={subject}>
|
||||
<Page size={pageSize} orientation={pageOrientation} style={pdfStyles.page}>
|
||||
{title && <Text style={pdfStyles.title}>{title}</Text>}
|
||||
{renderedContent}
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
|
||||
export const renderPlaneDocToPdfBuffer = async (
|
||||
doc: TipTapDocument,
|
||||
options: PDFExportOptions = {}
|
||||
): Promise<Buffer> => {
|
||||
const pdfDocument = createPdfDocument(doc, options);
|
||||
const pdfInstance = pdf(pdfDocument);
|
||||
const blob = await pdfInstance.toBlob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
};
|
||||
|
||||
export const renderPlaneDocToPdfBlob = async (doc: TipTapDocument, options: PDFExportOptions = {}): Promise<Blob> => {
|
||||
const pdfDocument = createPdfDocument(doc, options);
|
||||
const pdfInstance = pdf(pdfDocument);
|
||||
return await pdfInstance.toBlob();
|
||||
};
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { StyleSheet } from "@react-pdf/renderer";
|
||||
import {
|
||||
BACKGROUND_COLORS,
|
||||
BORDER_COLORS,
|
||||
BRAND_COLORS,
|
||||
CODE_COLORS,
|
||||
LINK_COLORS,
|
||||
MENTION_COLORS,
|
||||
TEXT_COLORS,
|
||||
} from "./colors";
|
||||
|
||||
export const pdfStyles = StyleSheet.create({
|
||||
page: {
|
||||
padding: 40,
|
||||
fontFamily: "Inter",
|
||||
fontSize: 11,
|
||||
lineHeight: 1.6,
|
||||
color: TEXT_COLORS.primary,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 600,
|
||||
marginBottom: 20,
|
||||
color: TEXT_COLORS.primary,
|
||||
},
|
||||
heading1: {
|
||||
fontSize: 20,
|
||||
fontWeight: 600,
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
color: TEXT_COLORS.primary,
|
||||
},
|
||||
heading2: {
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
marginTop: 14,
|
||||
marginBottom: 6,
|
||||
color: TEXT_COLORS.primary,
|
||||
},
|
||||
heading3: {
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
marginTop: 12,
|
||||
marginBottom: 4,
|
||||
color: TEXT_COLORS.primary,
|
||||
},
|
||||
heading4: {
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
marginTop: 10,
|
||||
marginBottom: 4,
|
||||
color: TEXT_COLORS.secondary,
|
||||
},
|
||||
heading5: {
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
marginTop: 8,
|
||||
marginBottom: 4,
|
||||
color: TEXT_COLORS.secondary,
|
||||
},
|
||||
heading6: {
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
marginTop: 6,
|
||||
marginBottom: 4,
|
||||
color: TEXT_COLORS.tertiary,
|
||||
},
|
||||
paragraph: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
paragraphWrapper: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
blockquote: {
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: BORDER_COLORS.strong, // Matches .ProseMirror blockquote border-strong
|
||||
paddingLeft: 12,
|
||||
marginLeft: 0,
|
||||
marginVertical: 8,
|
||||
fontStyle: "normal", // Matches editor: font-style: normal
|
||||
fontWeight: 400, // Matches editor: font-weight: 400
|
||||
color: TEXT_COLORS.primary,
|
||||
breakInside: "avoid",
|
||||
},
|
||||
codeBlock: {
|
||||
backgroundColor: BACKGROUND_COLORS.layer1, // bg-layer-1 equivalent
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
fontFamily: "Courier",
|
||||
fontSize: 10,
|
||||
marginVertical: 8,
|
||||
color: TEXT_COLORS.primary,
|
||||
breakInside: "avoid",
|
||||
},
|
||||
codeInline: {
|
||||
backgroundColor: BACKGROUND_COLORS.layer1,
|
||||
padding: 2,
|
||||
paddingHorizontal: 4,
|
||||
borderRadius: 2,
|
||||
fontFamily: "Courier",
|
||||
fontSize: 10,
|
||||
color: CODE_COLORS.text, // Red for inline code
|
||||
},
|
||||
bulletList: {
|
||||
marginVertical: 8,
|
||||
paddingLeft: 0,
|
||||
},
|
||||
orderedList: {
|
||||
marginVertical: 8,
|
||||
paddingLeft: 0,
|
||||
},
|
||||
listItem: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: 6,
|
||||
marginBottom: 4,
|
||||
paddingRight: 10,
|
||||
breakInside: "avoid",
|
||||
},
|
||||
listItemBullet: {},
|
||||
listItemContent: {
|
||||
flex: 1,
|
||||
},
|
||||
taskList: {
|
||||
marginVertical: 8,
|
||||
},
|
||||
taskItem: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: 6,
|
||||
marginBottom: 4,
|
||||
alignItems: "flex-start",
|
||||
paddingRight: 10,
|
||||
breakInside: "avoid",
|
||||
},
|
||||
taskCheckbox: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: BORDER_COLORS.strong, // Matches editor: border-strong
|
||||
borderRadius: 2,
|
||||
marginTop: 2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
taskCheckboxChecked: {
|
||||
backgroundColor: BRAND_COLORS.default, // --background-color-accent-primary
|
||||
borderColor: BRAND_COLORS.default, // --border-color-accent-strong
|
||||
},
|
||||
table: {
|
||||
marginVertical: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: BORDER_COLORS.subtle1, // border-subtle-1
|
||||
},
|
||||
tableRow: {
|
||||
flexDirection: "row",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: BORDER_COLORS.subtle1,
|
||||
breakInside: "avoid",
|
||||
},
|
||||
tableHeaderRow: {
|
||||
backgroundColor: BACKGROUND_COLORS.surface2, // Slightly different from white
|
||||
flexDirection: "row",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: BORDER_COLORS.subtle1,
|
||||
},
|
||||
tableCell: {
|
||||
padding: 8,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: BORDER_COLORS.subtle1,
|
||||
flex: 1,
|
||||
},
|
||||
tableHeaderCell: {
|
||||
padding: 8,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: BORDER_COLORS.subtle1,
|
||||
flex: 1,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
horizontalRule: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: BORDER_COLORS.subtle1, // Matches div[data-type="horizontalRule"] border-subtle-1
|
||||
marginVertical: 16,
|
||||
},
|
||||
image: {
|
||||
maxWidth: "100%",
|
||||
marginVertical: 8,
|
||||
},
|
||||
imagePlaceholder: {
|
||||
backgroundColor: BACKGROUND_COLORS.layer1,
|
||||
padding: 16,
|
||||
borderRadius: 4,
|
||||
marginVertical: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: BORDER_COLORS.subtle,
|
||||
borderStyle: "dashed",
|
||||
},
|
||||
imagePlaceholderText: {
|
||||
color: TEXT_COLORS.tertiary,
|
||||
fontSize: 10,
|
||||
},
|
||||
callout: {
|
||||
backgroundColor: BACKGROUND_COLORS.layer3, // bg-layer-3 (default callout background)
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
marginVertical: 8,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
breakInside: "avoid",
|
||||
},
|
||||
calloutIconContainer: {
|
||||
marginRight: 10,
|
||||
marginTop: 2,
|
||||
},
|
||||
calloutContent: {
|
||||
flex: 1,
|
||||
color: TEXT_COLORS.primary, // text-primary
|
||||
},
|
||||
mention: {
|
||||
backgroundColor: MENTION_COLORS.background, // bg-accent-primary/20 equivalent
|
||||
color: MENTION_COLORS.text, // text-accent-primary
|
||||
padding: 2,
|
||||
paddingHorizontal: 4,
|
||||
borderRadius: 2,
|
||||
},
|
||||
link: {
|
||||
color: LINK_COLORS.primary, // --txt-link-primary
|
||||
textDecoration: "underline",
|
||||
},
|
||||
bold: {
|
||||
fontWeight: "bold",
|
||||
},
|
||||
italic: {
|
||||
fontStyle: "italic",
|
||||
},
|
||||
underline: {
|
||||
textDecoration: "underline",
|
||||
},
|
||||
strike: {
|
||||
textDecoration: "line-through",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Style } from "@react-pdf/types";
|
||||
|
||||
export type TipTapMark = {
|
||||
type: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TipTapNode = {
|
||||
type: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
content?: TipTapNode[];
|
||||
text?: string;
|
||||
marks?: TipTapMark[];
|
||||
};
|
||||
|
||||
export type TipTapDocument = {
|
||||
type: "doc";
|
||||
content?: TipTapNode[];
|
||||
};
|
||||
|
||||
export type KeyGenerator = () => string;
|
||||
|
||||
export type PDFRenderContext = {
|
||||
getKey: KeyGenerator;
|
||||
metadata?: PDFExportMetadata;
|
||||
};
|
||||
|
||||
export type PDFNodeRenderer = (
|
||||
node: TipTapNode,
|
||||
children: React.ReactElement[],
|
||||
context: PDFRenderContext
|
||||
) => React.ReactElement;
|
||||
|
||||
export type PDFMarkRenderer = (mark: TipTapMark, currentStyle: Style) => Style;
|
||||
|
||||
export type NodeRendererRegistry = Record<string, PDFNodeRenderer>;
|
||||
|
||||
export type MarkRendererRegistry = Record<string, PDFMarkRenderer>;
|
||||
|
||||
export type PDFExportOptions = {
|
||||
title?: string;
|
||||
author?: string;
|
||||
subject?: string;
|
||||
pageSize?: "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID";
|
||||
pageOrientation?: "portrait" | "landscape";
|
||||
metadata?: PDFExportMetadata;
|
||||
/** When true, images and other assets are excluded from the PDF */
|
||||
noAssets?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Metadata for resolving entity references in PDF export
|
||||
*/
|
||||
export type PDFExportMetadata = {
|
||||
/** User mentions (user_mention in mention node) */
|
||||
userMentions?: PDFUserMention[];
|
||||
/** Resolved image URLs: Map of asset ID to presigned URL */
|
||||
resolvedImageUrls?: Record<string, string>;
|
||||
/** When true, images and other assets are excluded from the PDF */
|
||||
noAssets?: boolean;
|
||||
};
|
||||
|
||||
export type PDFUserMention = {
|
||||
id: string;
|
||||
display_name: string;
|
||||
avatar_url?: string;
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { onStatelessPayload } from "@hocuspocus/server";
|
||||
import { DocumentCollaborativeEvents } from "@plane/editor/lib";
|
||||
import 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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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();
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Schema } from "effect";
|
||||
|
||||
export const PdfExportRequestBody = Schema.Struct({
|
||||
pageId: Schema.NonEmptyTrimmedString,
|
||||
workspaceSlug: Schema.NonEmptyTrimmedString,
|
||||
projectId: Schema.optional(Schema.NonEmptyTrimmedString),
|
||||
title: Schema.optional(Schema.String),
|
||||
author: Schema.optional(Schema.String),
|
||||
subject: Schema.optional(Schema.String),
|
||||
pageSize: Schema.optional(Schema.Literal("A4", "A3", "A2", "LETTER", "LEGAL", "TABLOID")),
|
||||
pageOrientation: Schema.optional(Schema.Literal("portrait", "landscape")),
|
||||
fileName: Schema.optional(Schema.String),
|
||||
noAssets: Schema.optional(Schema.Boolean),
|
||||
});
|
||||
|
||||
export type TPdfExportRequestBody = Schema.Schema.Type<typeof PdfExportRequestBody>;
|
||||
|
||||
export class PdfValidationError extends Schema.TaggedError<PdfValidationError>()("PdfValidationError", {
|
||||
message: Schema.NonEmptyTrimmedString,
|
||||
cause: Schema.optional(Schema.Unknown),
|
||||
}) {}
|
||||
|
||||
export class PdfAuthenticationError extends Schema.TaggedError<PdfAuthenticationError>()("PdfAuthenticationError", {
|
||||
message: Schema.NonEmptyTrimmedString,
|
||||
}) {}
|
||||
|
||||
export class PdfContentFetchError extends Schema.TaggedError<PdfContentFetchError>()("PdfContentFetchError", {
|
||||
message: Schema.NonEmptyTrimmedString,
|
||||
cause: Schema.optional(Schema.Unknown),
|
||||
}) {}
|
||||
|
||||
export class PdfMetadataFetchError extends Schema.TaggedError<PdfMetadataFetchError>()("PdfMetadataFetchError", {
|
||||
message: Schema.NonEmptyTrimmedString,
|
||||
source: Schema.Literal("user-mentions"),
|
||||
cause: Schema.optional(Schema.Unknown),
|
||||
}) {}
|
||||
|
||||
export class PdfImageProcessingError extends Schema.TaggedError<PdfImageProcessingError>()("PdfImageProcessingError", {
|
||||
message: Schema.NonEmptyTrimmedString,
|
||||
assetId: Schema.NonEmptyTrimmedString,
|
||||
cause: Schema.optional(Schema.Unknown),
|
||||
}) {}
|
||||
|
||||
export class PdfGenerationError extends Schema.TaggedError<PdfGenerationError>()("PdfGenerationError", {
|
||||
message: Schema.NonEmptyTrimmedString,
|
||||
cause: Schema.optional(Schema.Unknown),
|
||||
}) {}
|
||||
|
||||
export class PdfTimeoutError extends Schema.TaggedError<PdfTimeoutError>()("PdfTimeoutError", {
|
||||
message: Schema.NonEmptyTrimmedString,
|
||||
operation: Schema.NonEmptyTrimmedString,
|
||||
}) {}
|
||||
|
||||
export type PdfExportError =
|
||||
| PdfValidationError
|
||||
| PdfAuthenticationError
|
||||
| PdfContentFetchError
|
||||
| PdfMetadataFetchError
|
||||
| PdfImageProcessingError
|
||||
| PdfGenerationError
|
||||
| PdfTimeoutError;
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Server as HttpServer } from "http";
|
||||
import type { Hocuspocus } from "@hocuspocus/server";
|
||||
import compression from "compression";
|
||||
import cors from "cors";
|
||||
import type { Express, Request, Response, Router } from "express";
|
||||
import express 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { AxiosInstance } from "axios";
|
||||
import axios 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { logger } from "@plane/logger";
|
||||
import type { TDocumentPayload, TPage } from "@plane/types";
|
||||
// services
|
||||
import { AppError } from "@/lib/errors";
|
||||
import { APIService } from "../api.service";
|
||||
|
||||
export type TUserMention = {
|
||||
id: string;
|
||||
display_name: string;
|
||||
avatar_url?: string;
|
||||
};
|
||||
|
||||
export abstract class PageCoreService extends APIService {
|
||||
protected abstract basePath: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
async fetchDetails(pageId: string): Promise<TPage> {
|
||||
try {
|
||||
const response = await this.get(`${this.basePath}/pages/${pageId}/`, {
|
||||
headers: this.getHeader(),
|
||||
});
|
||||
return response?.data as TPage;
|
||||
} 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<Buffer> {
|
||||
try {
|
||||
const response = await this.get(`${this.basePath}/pages/${pageId}/description/`, {
|
||||
headers: {
|
||||
...this.getHeader(),
|
||||
"Content-Type": "application/octet-stream",
|
||||
},
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
const data = response?.data;
|
||||
if (!Buffer.isBuffer(data)) {
|
||||
throw new Error("Expected response to be a Buffer");
|
||||
}
|
||||
return 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: TDocumentPayload): Promise<any> {
|
||||
try {
|
||||
const response = await this.patch(`${this.basePath}/pages/${pageId}/description/`, data, {
|
||||
headers: this.getHeader(),
|
||||
});
|
||||
return response?.data as unknown;
|
||||
} catch (error) {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "updateDescriptionBinary", pageId },
|
||||
});
|
||||
logger.error("Failed to update page description binary", appError);
|
||||
throw appError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches user mentions for a page
|
||||
* @param pageId - The page ID
|
||||
* @returns Array of user mentions
|
||||
*/
|
||||
async fetchUserMentions(pageId: string): Promise<TUserMention[]> {
|
||||
try {
|
||||
const response = await this.get(`${this.basePath}/pages/${pageId}/mentions/`, {
|
||||
headers: this.getHeader(),
|
||||
params: {
|
||||
mention_type: "user_mention",
|
||||
},
|
||||
});
|
||||
return (response?.data as TUserMention[]) ?? [];
|
||||
} catch (error) {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "fetchUserMentions", pageId },
|
||||
});
|
||||
logger.error("Failed to fetch user mentions", appError);
|
||||
throw appError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an image asset ID to its actual URL by following the 302 redirect
|
||||
* @param workspaceSlug - The workspace slug
|
||||
* @param assetId - The asset UUID
|
||||
* @param projectId - Optional project ID for project-specific assets
|
||||
* @returns The resolved image URL (presigned S3 URL)
|
||||
*/
|
||||
async resolveImageAssetUrl(
|
||||
workspaceSlug: string,
|
||||
assetId: string,
|
||||
projectId?: string | null
|
||||
): Promise<string | null> {
|
||||
const path = projectId
|
||||
? `/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${assetId}/?disposition=inline`
|
||||
: `/api/assets/v2/workspaces/${workspaceSlug}/${assetId}/?disposition=inline`;
|
||||
|
||||
try {
|
||||
const response = await this.get(path, {
|
||||
headers: this.getHeader(),
|
||||
maxRedirects: 0,
|
||||
validateStatus: (status: number) => status >= 200 && status < 400,
|
||||
});
|
||||
// If we get a 302, the Location header contains the presigned URL
|
||||
if (response.status === 302 || response.status === 301) {
|
||||
return response.headers?.location || null;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Axios throws on 3xx when maxRedirects is 0, so we need to handle the redirect from the error
|
||||
if ((error as any).response?.status === 302 || (error as any).response?.status === 301) {
|
||||
return (error as any).response.headers?.location || null;
|
||||
}
|
||||
logger.error("Failed to resolve image asset URL", {
|
||||
assetId,
|
||||
workspaceSlug,
|
||||
error: (error as any).message,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves multiple image asset IDs to their actual URLs
|
||||
* @param workspaceSlug - The workspace slug
|
||||
* @param assetIds - Array of asset UUIDs
|
||||
* @param projectId - Optional project ID for project-specific assets
|
||||
* @returns Map of assetId to resolved URL
|
||||
*/
|
||||
async resolveImageAssetUrls(
|
||||
workspaceSlug: string,
|
||||
assetIds: string[],
|
||||
projectId?: string | null
|
||||
): Promise<Map<string, string>> {
|
||||
const urlMap = new Map<string, string>();
|
||||
|
||||
// Resolve all asset URLs in parallel
|
||||
const results = await Promise.allSettled(
|
||||
assetIds.map(async (assetId) => {
|
||||
const url = await this.resolveImageAssetUrl(workspaceSlug, assetId, projectId);
|
||||
return { assetId, url };
|
||||
})
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === "fulfilled" && result.value.url) {
|
||||
urlMap.set(result.value.assetId, result.value.url);
|
||||
}
|
||||
}
|
||||
|
||||
return urlMap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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.`);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Effect, Duration, Schedule, pipe } from "effect";
|
||||
import { PdfTimeoutError } from "@/schema/pdf-export";
|
||||
|
||||
/**
|
||||
* Wraps an effect with timeout and exponential backoff retry logic.
|
||||
* Preserves the environment type R for proper dependency injection.
|
||||
*/
|
||||
export const withTimeoutAndRetry =
|
||||
(operation: string, { timeoutMs = 5000, maxRetries = 2 }: { timeoutMs?: number; maxRetries?: number } = {}) =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | PdfTimeoutError, R> =>
|
||||
effect.pipe(
|
||||
Effect.timeoutFail({
|
||||
duration: Duration.millis(timeoutMs),
|
||||
onTimeout: () =>
|
||||
new PdfTimeoutError({
|
||||
message: `Operation "${operation}" timed out after ${timeoutMs}ms`,
|
||||
operation,
|
||||
}),
|
||||
}),
|
||||
Effect.retry(
|
||||
pipe(
|
||||
Schedule.exponential(Duration.millis(200)),
|
||||
Schedule.compose(Schedule.recurs(maxRetries)),
|
||||
Schedule.tapInput((error: E | PdfTimeoutError) =>
|
||||
Effect.logWarning("PDF_EXPORT: Retrying operation", { operation, error })
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Recovers from any error with a default fallback value.
|
||||
* Logs the error before recovering.
|
||||
*/
|
||||
export const recoverWithDefault =
|
||||
<A>(fallback: A) =>
|
||||
<E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, never, R> =>
|
||||
effect.pipe(
|
||||
Effect.tapError((error) => Effect.logWarning("PDF_EXPORT: Operation failed, using fallback", { error })),
|
||||
Effect.catchAll(() => Effect.succeed(fallback))
|
||||
);
|
||||
|
||||
/**
|
||||
* Wraps a promise-returning function with proper Effect error handling
|
||||
*/
|
||||
export const tryAsync = <A, E>(fn: () => Promise<A>, onError: (cause: unknown) => E): Effect.Effect<A, E> =>
|
||||
Effect.tryPromise({
|
||||
try: fn,
|
||||
catch: onError,
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export { PdfExportService, exportToPdf } from "./pdf-export.service";
|
||||
export * from "./effect-utils";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Effect } from "effect";
|
||||
import sharp from "sharp";
|
||||
import { getAllDocumentFormatsFromDocumentEditorBinaryData } from "@plane/editor/lib";
|
||||
import type { PDFExportMetadata, TipTapDocument } from "@/lib/pdf";
|
||||
import { renderPlaneDocToPdfBuffer } from "@/lib/pdf";
|
||||
import { getPageService } from "@/services/page/handler";
|
||||
import type { TDocumentTypes } from "@/types";
|
||||
import {
|
||||
PdfContentFetchError,
|
||||
PdfGenerationError,
|
||||
PdfImageProcessingError,
|
||||
PdfTimeoutError,
|
||||
} from "@/schema/pdf-export";
|
||||
import { withTimeoutAndRetry, recoverWithDefault, tryAsync } from "./effect-utils";
|
||||
import type { PdfExportInput, PdfExportResult, PageContent, MetadataResult } from "./types";
|
||||
|
||||
const IMAGE_CONCURRENCY = 4;
|
||||
const IMAGE_TIMEOUT_MS = 8000;
|
||||
const CONTENT_FETCH_TIMEOUT_MS = 7000;
|
||||
const PDF_RENDER_TIMEOUT_MS = 15000;
|
||||
const IMAGE_MAX_DIMENSION = 1200;
|
||||
|
||||
type TipTapNode = {
|
||||
type: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
content?: TipTapNode[];
|
||||
};
|
||||
|
||||
/**
|
||||
* PDF Export Service
|
||||
*/
|
||||
export class PdfExportService extends Effect.Service<PdfExportService>()("PdfExportService", {
|
||||
sync: () => ({
|
||||
/**
|
||||
* Determines document type
|
||||
*/
|
||||
getDocumentType: (_input: PdfExportInput): TDocumentTypes => {
|
||||
return "project_page";
|
||||
},
|
||||
|
||||
/**
|
||||
* Extracts image asset IDs from document content
|
||||
*/
|
||||
extractImageAssetIds: (doc: TipTapNode): string[] => {
|
||||
const assetIds: string[] = [];
|
||||
|
||||
const traverse = (node: TipTapNode) => {
|
||||
if ((node.type === "imageComponent" || node.type === "image") && node.attrs?.src) {
|
||||
const src = node.attrs.src as string;
|
||||
if (src && !src.startsWith("http") && !src.startsWith("data:")) {
|
||||
assetIds.push(src);
|
||||
}
|
||||
}
|
||||
if (node.content) {
|
||||
for (const child of node.content) {
|
||||
traverse(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
traverse(doc);
|
||||
return [...new Set(assetIds)];
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches page content (description binary) and parses it
|
||||
*/
|
||||
fetchPageContent: (
|
||||
pageService: ReturnType<typeof getPageService>,
|
||||
pageId: string,
|
||||
requestId: string
|
||||
): Effect.Effect<PageContent, PdfContentFetchError | PdfTimeoutError> =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.logDebug("PDF_EXPORT: Fetching page content", { requestId, pageId });
|
||||
|
||||
const descriptionBinary = yield* tryAsync(
|
||||
() => pageService.fetchDescriptionBinary(pageId),
|
||||
(cause) =>
|
||||
new PdfContentFetchError({
|
||||
message: "Failed to fetch page content",
|
||||
cause,
|
||||
})
|
||||
).pipe(
|
||||
withTimeoutAndRetry("fetch page content", {
|
||||
timeoutMs: CONTENT_FETCH_TIMEOUT_MS,
|
||||
maxRetries: 3,
|
||||
})
|
||||
);
|
||||
|
||||
if (!descriptionBinary) {
|
||||
return yield* Effect.fail(
|
||||
new PdfContentFetchError({
|
||||
message: "Page content not found",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const binaryData = new Uint8Array(descriptionBinary);
|
||||
const { contentJSON, titleHTML } = getAllDocumentFormatsFromDocumentEditorBinaryData(binaryData, true);
|
||||
|
||||
return {
|
||||
contentJSON: contentJSON as TipTapDocument,
|
||||
titleHTML: titleHTML || null,
|
||||
descriptionBinary,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Fetches user mentions for the page
|
||||
*/
|
||||
fetchUserMentions: (
|
||||
pageService: ReturnType<typeof getPageService>,
|
||||
pageId: string,
|
||||
requestId: string
|
||||
): Effect.Effect<MetadataResult> =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.logDebug("PDF_EXPORT: Fetching user mentions", { requestId });
|
||||
|
||||
const userMentionsRaw = yield* tryAsync(
|
||||
async () => {
|
||||
if (pageService.fetchUserMentions) {
|
||||
return await pageService.fetchUserMentions(pageId);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
() => []
|
||||
).pipe(recoverWithDefault([] as Array<{ id: string; display_name: string; avatar_url?: string }>));
|
||||
|
||||
return {
|
||||
userMentions: userMentionsRaw.map((u) => ({
|
||||
id: u.id,
|
||||
display_name: u.display_name,
|
||||
avatar_url: u.avatar_url,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Resolves and processes images for PDF embedding
|
||||
*/
|
||||
processImages: (
|
||||
pageService: ReturnType<typeof getPageService>,
|
||||
workspaceSlug: string,
|
||||
projectId: string | undefined,
|
||||
assetIds: string[],
|
||||
requestId: string
|
||||
): Effect.Effect<Record<string, string>> =>
|
||||
Effect.gen(function* () {
|
||||
if (assetIds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
yield* Effect.logDebug("PDF_EXPORT: Processing images", {
|
||||
requestId,
|
||||
count: assetIds.length,
|
||||
});
|
||||
|
||||
// Resolve URLs first
|
||||
const resolvedUrlMap = yield* tryAsync(
|
||||
async () => {
|
||||
const urlMap = new Map<string, string>();
|
||||
for (const assetId of assetIds) {
|
||||
const url = await pageService.resolveImageAssetUrl?.(workspaceSlug, assetId, projectId);
|
||||
if (url) urlMap.set(assetId, url);
|
||||
}
|
||||
return urlMap;
|
||||
},
|
||||
() => new Map<string, string>()
|
||||
).pipe(recoverWithDefault(new Map<string, string>()));
|
||||
|
||||
if (resolvedUrlMap.size === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Process each image
|
||||
const processSingleImage = ([assetId, url]: [string, string]) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* tryAsync(
|
||||
() => fetch(url),
|
||||
(cause) =>
|
||||
new PdfImageProcessingError({
|
||||
message: "Failed to fetch image",
|
||||
assetId,
|
||||
cause,
|
||||
})
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return yield* Effect.fail(
|
||||
new PdfImageProcessingError({
|
||||
message: `Image fetch returned ${response.status}`,
|
||||
assetId,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const arrayBuffer = yield* tryAsync(
|
||||
() => response.arrayBuffer(),
|
||||
(cause) =>
|
||||
new PdfImageProcessingError({
|
||||
message: "Failed to read image body",
|
||||
assetId,
|
||||
cause,
|
||||
})
|
||||
);
|
||||
|
||||
const processedBuffer = yield* tryAsync(
|
||||
() =>
|
||||
sharp(Buffer.from(arrayBuffer))
|
||||
.rotate()
|
||||
.flatten({ background: { r: 255, g: 255, b: 255 } })
|
||||
.resize(IMAGE_MAX_DIMENSION, IMAGE_MAX_DIMENSION, { fit: "inside", withoutEnlargement: true })
|
||||
.jpeg({ quality: 85 })
|
||||
.toBuffer(),
|
||||
(cause) =>
|
||||
new PdfImageProcessingError({
|
||||
message: "Failed to process image",
|
||||
assetId,
|
||||
cause,
|
||||
})
|
||||
);
|
||||
|
||||
const base64 = processedBuffer.toString("base64");
|
||||
return [assetId, `data:image/jpeg;base64,${base64}`] as const;
|
||||
}).pipe(
|
||||
withTimeoutAndRetry(`process image ${assetId}`, {
|
||||
timeoutMs: IMAGE_TIMEOUT_MS,
|
||||
maxRetries: 1,
|
||||
}),
|
||||
Effect.tapError((error) =>
|
||||
Effect.logWarning("PDF_EXPORT: Image processing failed", {
|
||||
requestId,
|
||||
assetId,
|
||||
error,
|
||||
})
|
||||
),
|
||||
Effect.catchAll(() => Effect.succeed(null as readonly [string, string] | null))
|
||||
);
|
||||
|
||||
const entries = Array.from(resolvedUrlMap.entries());
|
||||
const pairs = yield* Effect.forEach(entries, processSingleImage, {
|
||||
concurrency: IMAGE_CONCURRENCY,
|
||||
});
|
||||
|
||||
const filtered = pairs.filter((p): p is readonly [string, string] => p !== null);
|
||||
return Object.fromEntries(filtered);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Renders document to PDF buffer
|
||||
*/
|
||||
renderPdf: (
|
||||
contentJSON: TipTapDocument,
|
||||
metadata: PDFExportMetadata,
|
||||
options: {
|
||||
title?: string;
|
||||
author?: string;
|
||||
subject?: string;
|
||||
pageSize?: "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID";
|
||||
pageOrientation?: "portrait" | "landscape";
|
||||
noAssets?: boolean;
|
||||
},
|
||||
requestId: string
|
||||
): Effect.Effect<Buffer, PdfGenerationError | PdfTimeoutError> =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.logDebug("PDF_EXPORT: Rendering PDF", { requestId });
|
||||
|
||||
const pdfBuffer = yield* tryAsync(
|
||||
() =>
|
||||
renderPlaneDocToPdfBuffer(contentJSON, {
|
||||
title: options.title,
|
||||
author: options.author,
|
||||
subject: options.subject,
|
||||
pageSize: options.pageSize,
|
||||
pageOrientation: options.pageOrientation,
|
||||
metadata,
|
||||
noAssets: options.noAssets,
|
||||
}),
|
||||
(cause) =>
|
||||
new PdfGenerationError({
|
||||
message: "Failed to render PDF",
|
||||
cause,
|
||||
})
|
||||
).pipe(withTimeoutAndRetry("render PDF", { timeoutMs: PDF_RENDER_TIMEOUT_MS, maxRetries: 0 }));
|
||||
|
||||
yield* Effect.logInfo("PDF_EXPORT: PDF rendered successfully", {
|
||||
requestId,
|
||||
size: pdfBuffer.length,
|
||||
});
|
||||
|
||||
return pdfBuffer;
|
||||
}),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* Main export pipeline - orchestrates the entire PDF export process
|
||||
* Separate function to avoid circular dependency in service definition
|
||||
*/
|
||||
export const exportToPdf = (
|
||||
input: PdfExportInput
|
||||
): Effect.Effect<PdfExportResult, PdfContentFetchError | PdfGenerationError | PdfTimeoutError, PdfExportService> =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* PdfExportService;
|
||||
const { requestId, pageId, workspaceSlug, projectId, noAssets } = input;
|
||||
|
||||
yield* Effect.logInfo("PDF_EXPORT: Starting export", { requestId, pageId, workspaceSlug });
|
||||
|
||||
// Create page service
|
||||
const documentType = service.getDocumentType(input);
|
||||
const pageService = getPageService(documentType, {
|
||||
workspaceSlug,
|
||||
projectId: projectId || null,
|
||||
cookie: input.cookie,
|
||||
documentType,
|
||||
userId: "",
|
||||
});
|
||||
|
||||
// Fetch content
|
||||
const content = yield* service.fetchPageContent(pageService, pageId, requestId);
|
||||
|
||||
// Extract image asset IDs
|
||||
const imageAssetIds = service.extractImageAssetIds(content.contentJSON as TipTapNode);
|
||||
|
||||
// Fetch user mentions
|
||||
let metadata = yield* service.fetchUserMentions(pageService, pageId, requestId);
|
||||
|
||||
// Process images if needed
|
||||
if (!noAssets && imageAssetIds.length > 0) {
|
||||
const resolvedImages = yield* service.processImages(
|
||||
pageService,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
imageAssetIds,
|
||||
requestId
|
||||
);
|
||||
metadata = { ...metadata, resolvedImageUrls: resolvedImages };
|
||||
}
|
||||
|
||||
yield* Effect.logDebug("PDF_EXPORT: Metadata prepared", {
|
||||
requestId,
|
||||
userMentions: metadata.userMentions?.length ?? 0,
|
||||
resolvedImages: Object.keys(metadata.resolvedImageUrls ?? {}).length,
|
||||
});
|
||||
|
||||
// Render PDF
|
||||
const documentTitle = input.title || content.titleHTML || undefined;
|
||||
const pdfBuffer = yield* service.renderPdf(
|
||||
content.contentJSON,
|
||||
metadata,
|
||||
{
|
||||
title: documentTitle,
|
||||
author: input.author,
|
||||
subject: input.subject,
|
||||
pageSize: input.pageSize,
|
||||
pageOrientation: input.pageOrientation,
|
||||
noAssets,
|
||||
},
|
||||
requestId
|
||||
);
|
||||
|
||||
yield* Effect.logInfo("PDF_EXPORT: Export complete", {
|
||||
requestId,
|
||||
pageId,
|
||||
size: pdfBuffer.length,
|
||||
});
|
||||
|
||||
return {
|
||||
pdfBuffer,
|
||||
outputFileName: input.fileName || `page-${pageId}.pdf`,
|
||||
pageId,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { TipTapDocument, PDFUserMention } from "@/lib/pdf";
|
||||
|
||||
export interface PdfExportInput {
|
||||
readonly pageId: string;
|
||||
readonly workspaceSlug: string;
|
||||
readonly projectId?: string;
|
||||
readonly title?: string;
|
||||
readonly author?: string;
|
||||
readonly subject?: string;
|
||||
readonly pageSize?: "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID";
|
||||
readonly pageOrientation?: "portrait" | "landscape";
|
||||
readonly fileName?: string;
|
||||
readonly noAssets?: boolean;
|
||||
readonly cookie: string;
|
||||
readonly requestId: string;
|
||||
}
|
||||
|
||||
export interface PdfExportResult {
|
||||
readonly pdfBuffer: Buffer;
|
||||
readonly outputFileName: string;
|
||||
readonly pageId: string;
|
||||
}
|
||||
|
||||
export interface PageContent {
|
||||
readonly contentJSON: TipTapDocument;
|
||||
readonly titleHTML: string | null;
|
||||
readonly descriptionBinary: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata - includes user mentions
|
||||
*/
|
||||
export interface MetadataResult {
|
||||
readonly userMentions: PDFUserMention[];
|
||||
readonly resolvedImageUrls?: Record<string, string>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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.";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Hocuspocus } from "@hocuspocus/server";
|
||||
import { createRealtimeEvent } from "@plane/editor";
|
||||
import { logger } from "@plane/logger";
|
||||
import type { HocusPocusServerContext } 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: HocusPocusServerContext,
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Hocuspocus } from "@hocuspocus/server";
|
||||
import type { 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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user