regression: downgrade to tiptap v2 (#7982)
* chore: downgrade to tiptap v2 * fix: revert back to hocuspocus * fix: collaboration events added * fix: lock unlock issues * fix: build errors * fix: type errors * fix: graceful shutdown --------- Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com>
This commit is contained in:
parent
59022b6beb
commit
64781be7d2
48 changed files with 2123 additions and 824 deletions
|
|
@ -20,11 +20,11 @@
|
|||
"author": "Plane Software Inc.",
|
||||
"dependencies": {
|
||||
"@dotenvx/dotenvx": "^1.49.0",
|
||||
"@hocuspocus/extension-database": "3.2.5",
|
||||
"@hocuspocus/extension-logger": "3.2.5",
|
||||
"@hocuspocus/extension-redis": "3.2.5",
|
||||
"@hocuspocus/server": "3.2.5",
|
||||
"@hocuspocus/transformer": "3.2.5",
|
||||
"@hocuspocus/extension-database": "2.15.2",
|
||||
"@hocuspocus/extension-logger": "2.15.2",
|
||||
"@hocuspocus/extension-redis": "2.15.2",
|
||||
"@hocuspocus/server": "2.15.2",
|
||||
"@hocuspocus/transformer": "2.15.2",
|
||||
"@plane/decorators": "workspace:*",
|
||||
"@plane/editor": "workspace:*",
|
||||
"@plane/logger": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -6,22 +6,17 @@ import {
|
|||
} from "@plane/editor";
|
||||
// logger
|
||||
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 normalizeToError = (error: unknown, fallbackMessage: string) => {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
const message = typeof error === "string" && error.trim().length > 0 ? error : fallbackMessage;
|
||||
|
||||
return new Error(message);
|
||||
};
|
||||
|
||||
const fetchDocument = async ({ context, documentName: pageId }: FetchPayloadWithContext) => {
|
||||
const fetchDocument = async ({ context, documentName: pageId, instance }: FetchPayloadWithContext) => {
|
||||
try {
|
||||
const service = getPageService(context.documentType, context);
|
||||
// fetch details
|
||||
|
|
@ -38,12 +33,22 @@ const fetchDocument = async ({ context, documentName: pageId }: FetchPayloadWith
|
|||
// return binary data
|
||||
return binaryData;
|
||||
} catch (error) {
|
||||
logger.error("DATABASE_EXTENSION: Error in fetching document", error);
|
||||
throw normalizeToError(error, `Failed to fetch document: ${pageId}`);
|
||||
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 }: StorePayloadWithContext) => {
|
||||
const storeDocument = async ({
|
||||
context,
|
||||
state: pageBinaryData,
|
||||
documentName: pageId,
|
||||
instance,
|
||||
}: StorePayloadWithContext) => {
|
||||
try {
|
||||
const service = getPageService(context.documentType, context);
|
||||
// convert binary data to all formats
|
||||
|
|
@ -57,8 +62,46 @@ const storeDocument = async ({ context, state: pageBinaryData, documentName: pag
|
|||
};
|
||||
await service.updateDescriptionBinary(pageId, payload);
|
||||
} catch (error) {
|
||||
logger.error("DATABASE_EXTENSION: Error in updating document:", error);
|
||||
throw normalizeToError(error, `Failed to update document: ${pageId}`);
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
203
apps/live/src/extensions/force-close-handler.ts
Normal file
203
apps/live/src/extensions/force-close-handler.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import type { Connection, Extension, Hocuspocus, onConfigurePayload } from "@hocuspocus/server";
|
||||
import { logger } from "@plane/logger";
|
||||
import { Redis } from "@/extensions/redis";
|
||||
import {
|
||||
AdminCommand,
|
||||
CloseCode,
|
||||
ForceCloseReason,
|
||||
getForceCloseMessage,
|
||||
isForceCloseCommand,
|
||||
type ClientForceCloseMessage,
|
||||
type ForceCloseCommandData,
|
||||
} from "@/types/admin-commands";
|
||||
|
||||
/**
|
||||
* Extension to handle force close commands from other servers via Redis admin channel
|
||||
*/
|
||||
export class ForceCloseHandler implements Extension {
|
||||
name = "ForceCloseHandler";
|
||||
priority = 999;
|
||||
|
||||
async onConfigure({ instance }: onConfigurePayload) {
|
||||
const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis) as Redis | undefined;
|
||||
|
||||
if (!redisExt) {
|
||||
logger.warn("[FORCE_CLOSE_HANDLER] Redis extension not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Register handler for force_close admin command
|
||||
redisExt.onAdminCommand<ForceCloseCommandData>(AdminCommand.FORCE_CLOSE, async (data) => {
|
||||
// Type guard for safety
|
||||
if (!isForceCloseCommand(data)) {
|
||||
logger.error("[FORCE_CLOSE_HANDLER] Received invalid force close command");
|
||||
return;
|
||||
}
|
||||
|
||||
const { docId, reason, code } = data;
|
||||
|
||||
const document = instance.documents.get(docId);
|
||||
if (!document) {
|
||||
// Not our document, ignore
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionCount = document.getConnectionsCount();
|
||||
logger.info(`[FORCE_CLOSE_HANDLER] Sending force close message to ${connectionCount} clients...`);
|
||||
|
||||
// Step 1: Send force close message to ALL clients first
|
||||
const forceCloseMessage: ClientForceCloseMessage = {
|
||||
type: "force_close",
|
||||
reason,
|
||||
code,
|
||||
message: getForceCloseMessage(reason),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
let messageSent = 0;
|
||||
document.connections.forEach(({ connection }: { connection: Connection }) => {
|
||||
try {
|
||||
connection.sendStateless(JSON.stringify(forceCloseMessage));
|
||||
messageSent++;
|
||||
} catch (error) {
|
||||
logger.error("[FORCE_CLOSE_HANDLER] Failed to send message:", error);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[FORCE_CLOSE_HANDLER] Sent force close message to ${messageSent}/${connectionCount} clients`);
|
||||
|
||||
// Wait a moment for messages to be delivered
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Step 2: Close connections
|
||||
logger.info(`[FORCE_CLOSE_HANDLER] Closing ${connectionCount} connections...`);
|
||||
|
||||
let closed = 0;
|
||||
document.connections.forEach(({ connection }: { connection: Connection }) => {
|
||||
try {
|
||||
connection.close({ code, reason });
|
||||
closed++;
|
||||
} catch (error) {
|
||||
logger.error("[FORCE_CLOSE_HANDLER] Failed to close connection:", error);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[FORCE_CLOSE_HANDLER] Closed ${closed}/${connectionCount} connections for ${docId}`);
|
||||
});
|
||||
|
||||
logger.info("[FORCE_CLOSE_HANDLER] Registered with Redis extension");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force close all connections to a document across all servers and unload it from memory.
|
||||
* Used for critical errors or admin operations.
|
||||
*
|
||||
* @param instance - The Hocuspocus server instance
|
||||
* @param pageId - The document ID to force close
|
||||
* @param reason - The reason for force closing
|
||||
* @param code - Optional WebSocket close code (defaults to FORCE_CLOSE)
|
||||
* @returns Promise that resolves when document is closed and unloaded
|
||||
* @throws Error if document not found in memory
|
||||
*/
|
||||
export const forceCloseDocumentAcrossServers = async (
|
||||
instance: Hocuspocus,
|
||||
pageId: string,
|
||||
reason: ForceCloseReason,
|
||||
code: CloseCode = CloseCode.FORCE_CLOSE
|
||||
): Promise<void> => {
|
||||
// STEP 1: VERIFY DOCUMENT EXISTS
|
||||
const document = instance.documents.get(pageId);
|
||||
|
||||
if (!document) {
|
||||
logger.info(`[FORCE_CLOSE] Document ${pageId} already unloaded - no action needed`);
|
||||
return; // Document already cleaned up, nothing to do
|
||||
}
|
||||
|
||||
const connectionsBefore = document.getConnectionsCount();
|
||||
logger.info(`[FORCE_CLOSE] Sending force close message to ${connectionsBefore} local clients...`);
|
||||
|
||||
const forceCloseMessage: ClientForceCloseMessage = {
|
||||
type: "force_close",
|
||||
reason,
|
||||
code,
|
||||
message: getForceCloseMessage(reason),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
let messageSentCount = 0;
|
||||
document.connections.forEach(({ connection }: { connection: Connection }) => {
|
||||
try {
|
||||
connection.sendStateless(JSON.stringify(forceCloseMessage));
|
||||
messageSentCount++;
|
||||
} catch (error) {
|
||||
logger.error("[FORCE_CLOSE] Failed to send message to client:", error);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[FORCE_CLOSE] Sent force close message to ${messageSentCount}/${connectionsBefore} clients`);
|
||||
|
||||
// Wait a moment for messages to be delivered
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// STEP 3: CLOSE LOCAL CONNECTIONS
|
||||
logger.info(`[FORCE_CLOSE] Closing ${connectionsBefore} local connections...`);
|
||||
|
||||
let closedCount = 0;
|
||||
document.connections.forEach(({ connection }: { connection: Connection }) => {
|
||||
try {
|
||||
connection.close({ code, reason });
|
||||
closedCount++;
|
||||
} catch (error) {
|
||||
logger.error("[FORCE_CLOSE] Failed to close local connection:", error);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[FORCE_CLOSE] Closed ${closedCount}/${connectionsBefore} local connections`);
|
||||
|
||||
// STEP 4: BROADCAST TO OTHER SERVERS
|
||||
const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis) as Redis | undefined;
|
||||
|
||||
if (redisExt) {
|
||||
const commandData: ForceCloseCommandData = {
|
||||
command: AdminCommand.FORCE_CLOSE,
|
||||
docId: pageId,
|
||||
reason,
|
||||
code,
|
||||
originServer: instance.configuration.name || "unknown",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const receivers = await redisExt.publishAdminCommand(commandData);
|
||||
logger.info(`[FORCE_CLOSE] Notified ${receivers} other server(s)`);
|
||||
} else {
|
||||
logger.warn("[FORCE_CLOSE] Redis extension not found, cannot notify other servers");
|
||||
}
|
||||
|
||||
// STEP 5: WAIT FOR OTHER SERVERS
|
||||
const waitTime = 800;
|
||||
logger.info(`[FORCE_CLOSE] Waiting ${waitTime}ms for other servers to close connections...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
||||
|
||||
// STEP 6: UNLOAD DOCUMENT after closing all the connections
|
||||
logger.info(`[FORCE_CLOSE] Unloading document from memory...`);
|
||||
|
||||
try {
|
||||
await instance.unloadDocument(document);
|
||||
logger.info(`[FORCE_CLOSE] Document unloaded successfully ✅`);
|
||||
} catch (unloadError: unknown) {
|
||||
logger.error("[FORCE_CLOSE] UNLOAD FAILED:", unloadError);
|
||||
logger.error(` Error: ${unloadError instanceof Error ? unloadError.message : "unknown"}`);
|
||||
}
|
||||
|
||||
// STEP 7: VERIFY UNLOAD
|
||||
const documentAfterUnload = instance.documents.get(pageId);
|
||||
|
||||
if (documentAfterUnload) {
|
||||
logger.error(
|
||||
`❌ [FORCE_CLOSE] Document still in memory!, Document ID: ${pageId}, Connections: ${documentAfterUnload.getConnectionsCount()}`
|
||||
);
|
||||
} else {
|
||||
logger.info(`✅ [FORCE_CLOSE] COMPLETE, Document: ${pageId}, Status: Successfully closed and unloaded`);
|
||||
}
|
||||
};
|
||||
|
|
@ -1,30 +1,134 @@
|
|||
import { Redis as HocuspocusRedis } from "@hocuspocus/extension-redis";
|
||||
import { OutgoingMessage } from "@hocuspocus/server";
|
||||
// redis
|
||||
import { OutgoingMessage, type onConfigurePayload } from "@hocuspocus/server";
|
||||
import { logger } from "@plane/logger";
|
||||
import { AppError } from "@/lib/errors";
|
||||
import { redisManager } from "@/redis";
|
||||
import { AdminCommand } from "@/types/admin-commands";
|
||||
import type { AdminCommandData, AdminCommandHandler } from "@/types/admin-commands";
|
||||
|
||||
const getRedisClient = () => {
|
||||
const redisClient = redisManager.getClient();
|
||||
if (!redisClient) {
|
||||
throw new Error("Redis client not initialized");
|
||||
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() });
|
||||
}
|
||||
|
||||
public broadcastToDocument(documentName: string, payload: any): Promise<number> {
|
||||
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);
|
||||
|
||||
return this.pub.publish(
|
||||
// we're accessing the private method of the hocuspocus redis extension
|
||||
this["pubKey"](documentName),
|
||||
// we're accessing the private method of the hocuspocus redis extension to encode the message
|
||||
this["encodeMessage"](message.toUint8Array())
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
50
apps/live/src/lib/auth-middleware.ts
Normal file
50
apps/live/src/lib/auth-middleware.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import type { Request, Response, NextFunction } from "express";
|
||||
import { logger } from "@plane/logger";
|
||||
import { env } from "@/env";
|
||||
|
||||
/**
|
||||
* Express middleware to verify secret key authentication for protected endpoints
|
||||
*
|
||||
* Checks for secret key in headers:
|
||||
* - x-admin-secret-key (preferred for admin endpoints)
|
||||
* - live-server-secret-key (for backward compatibility)
|
||||
*
|
||||
* @param req - Express request object
|
||||
* @param res - Express response object
|
||||
* @param next - Express next function
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Middleware } from "@plane/decorators";
|
||||
* import { requireSecretKey } from "@/lib/auth-middleware";
|
||||
*
|
||||
* @Get("/protected")
|
||||
* @Middleware(requireSecretKey)
|
||||
* async protectedEndpoint(req: Request, res: Response) {
|
||||
* // This will only execute if secret key is valid
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
// TODO - Move to hmac
|
||||
export const requireSecretKey = (req: Request, res: Response, next: NextFunction): void => {
|
||||
const secretKey = req.headers["live-server-secret-key"];
|
||||
|
||||
if (!secretKey || secretKey !== env.LIVE_SERVER_SECRET_KEY) {
|
||||
logger.warn(`
|
||||
⚠️ [AUTH] Unauthorized access attempt
|
||||
Endpoint: ${req.path}
|
||||
Method: ${req.method}
|
||||
IP: ${req.ip}
|
||||
User-Agent: ${req.headers["user-agent"]}
|
||||
`);
|
||||
|
||||
res.status(401).json({
|
||||
error: "Unauthorized",
|
||||
status: 401,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Secret key is valid, proceed to the route handler
|
||||
next();
|
||||
};
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
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
|
||||
|
|
@ -35,8 +36,10 @@ export const onAuthenticate = async ({
|
|||
userId = parsedToken.id;
|
||||
cookie = parsedToken.cookie;
|
||||
} catch (error) {
|
||||
// If token parsing fails, fallback to request headers
|
||||
logger.error("AUTH: Token parsing failed, using request headers:", 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) {
|
||||
|
|
@ -45,7 +48,9 @@ export const onAuthenticate = async ({
|
|||
}
|
||||
|
||||
if (!cookie || !userId) {
|
||||
throw new Error("Credentials not provided");
|
||||
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
|
||||
|
|
@ -67,7 +72,7 @@ export const handleAuthentication = async ({ cookie, userId }: { cookie: string;
|
|||
const userService = new UserService();
|
||||
const user = await userService.currentUser(cookie);
|
||||
if (user.id !== userId) {
|
||||
throw new Error("Authentication unsuccessful!");
|
||||
throw new AppError("Authentication unsuccessful: User ID mismatch", { code: "AUTH_USER_MISMATCH" });
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -77,7 +82,10 @@ export const handleAuthentication = async ({ cookie, userId }: { cookie: string;
|
|||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("AUTH: Token parsing failed, using request headers:", error);
|
||||
throw Error("Authentication unsuccessful!");
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "handleAuthentication" },
|
||||
});
|
||||
logger.error("Authentication failed", appError);
|
||||
throw new AppError("Authentication unsuccessful", { code: appError.code });
|
||||
}
|
||||
};
|
||||
|
|
|
|||
73
apps/live/src/lib/errors.ts
Normal file
73
apps/live/src/lib/errors.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { AxiosError } from "axios";
|
||||
|
||||
/**
|
||||
* Application error class that sanitizes and standardizes errors across the app.
|
||||
* Extracts only essential information from AxiosError to prevent massive log bloat
|
||||
* and sensitive data leaks (cookies, tokens, etc).
|
||||
*
|
||||
* Usage:
|
||||
* new AppError("Simple error message")
|
||||
* new AppError("Custom error", { code: "MY_CODE", statusCode: 400 })
|
||||
* new AppError(axiosError) // Auto-extracts essential info
|
||||
* new AppError(anyError) // Works with any error type
|
||||
*/
|
||||
export class AppError extends Error {
|
||||
statusCode?: number;
|
||||
method?: string;
|
||||
url?: string;
|
||||
code?: string;
|
||||
context?: Record<string, any>;
|
||||
|
||||
constructor(messageOrError: string | unknown, data?: Partial<Omit<AppError, "name" | "message">>) {
|
||||
// Handle error objects - extract essential info
|
||||
const error = messageOrError;
|
||||
|
||||
// Already AppError - return immediately for performance (no need to re-process)
|
||||
if (error instanceof AppError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
// Handle string message (simple case like regular Error)
|
||||
if (typeof messageOrError === "string") {
|
||||
super(messageOrError);
|
||||
this.name = "AppError";
|
||||
if (data) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// AxiosError - extract ONLY essential info (no config, no headers, no cookies)
|
||||
if (error && typeof error === "object" && "isAxiosError" in error) {
|
||||
const axiosError = error as AxiosError;
|
||||
const responseData = axiosError.response?.data as any;
|
||||
super(responseData?.message || axiosError.message);
|
||||
this.name = "AppError";
|
||||
this.statusCode = axiosError.response?.status;
|
||||
this.method = axiosError.config?.method?.toUpperCase();
|
||||
this.url = axiosError.config?.url;
|
||||
this.code = axiosError.code;
|
||||
return;
|
||||
}
|
||||
|
||||
// DOMException (AbortError from cancelled requests)
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
super(error.message);
|
||||
this.name = "AppError";
|
||||
this.code = "ABORT_ERROR";
|
||||
return;
|
||||
}
|
||||
|
||||
// Standard Error objects
|
||||
if (error instanceof Error) {
|
||||
super(error.message);
|
||||
this.name = "AppError";
|
||||
this.code = error.name;
|
||||
return;
|
||||
}
|
||||
|
||||
// Unknown error types - safe fallback
|
||||
super("Unknown error occurred");
|
||||
this.name = "AppError";
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import { DocumentCollaborativeEvents, type TDocumentEventsServer } from "@plane/
|
|||
* @param param0
|
||||
*/
|
||||
export const onStateless = async ({ payload, document }: onStatelessPayload) => {
|
||||
const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
|
||||
const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer]?.client;
|
||||
if (response) {
|
||||
document.broadcastStateless(response);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,13 +59,20 @@ export class RedisManager {
|
|||
return;
|
||||
}
|
||||
|
||||
// Configuration optimized for BOTH regular operations AND pub/sub
|
||||
// HocuspocusRedis uses .duplicate() which inherits these settings
|
||||
this.redisClient = new Redis(redisUrl, {
|
||||
lazyConnect: true,
|
||||
lazyConnect: false, // Connect immediately for reliability (duplicates inherit this)
|
||||
keepAlive: 30000,
|
||||
connectTimeout: 10000,
|
||||
commandTimeout: 5000,
|
||||
// enableOfflineQueue: false,
|
||||
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
|
||||
|
|
@ -94,10 +101,6 @@ export class RedisManager {
|
|||
this.isConnected = false;
|
||||
});
|
||||
|
||||
// Connect to Redis
|
||||
await this.redisClient.connect();
|
||||
|
||||
// Test the connection
|
||||
await this.redisClient.ping();
|
||||
logger.info("REDIS_MANAGER: Redis connection test successful");
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ export class Server {
|
|||
const manager = HocusPocusServerManager.getInstance();
|
||||
this.hocuspocusServer = await manager.initialize();
|
||||
logger.info("SERVER: HocusPocus setup completed");
|
||||
|
||||
this.setupRoutes(this.hocuspocusServer);
|
||||
this.setupNotFoundHandler();
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import axios, { AxiosInstance } from "axios";
|
||||
import { logger } from "@plane/logger";
|
||||
import { env } from "@/env";
|
||||
import { AppError } from "@/lib/errors";
|
||||
|
||||
export abstract class APIService {
|
||||
protected baseURL: string;
|
||||
|
|
@ -21,8 +21,7 @@ export abstract class APIService {
|
|||
this.axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
logger.error("AXIOS_ERROR:", error);
|
||||
return Promise.reject(error);
|
||||
return Promise.reject(new AppError(error));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { logger } from "@plane/logger";
|
||||
import { TPage } from "@plane/types";
|
||||
// services
|
||||
import { AppError } from "@/lib/errors";
|
||||
import { APIService } from "../api.service";
|
||||
|
||||
export type TPageDescriptionPayload = {
|
||||
|
|
@ -21,7 +23,11 @@ export abstract class PageCoreService extends APIService {
|
|||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "fetchDetails", pageId },
|
||||
});
|
||||
logger.error("Failed to fetch page details", appError);
|
||||
throw appError;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -35,7 +41,11 @@ export abstract class PageCoreService extends APIService {
|
|||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "fetchDescriptionBinary", pageId },
|
||||
});
|
||||
logger.error("Failed to fetch page description binary", appError);
|
||||
throw appError;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -50,7 +60,7 @@ export abstract class PageCoreService extends APIService {
|
|||
|
||||
// Early abort check
|
||||
if (abortSignal?.aborted) {
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
throw new AppError(new DOMException("Aborted", "AbortError"));
|
||||
}
|
||||
|
||||
// Create an abort listener that will reject the pending promise
|
||||
|
|
@ -58,7 +68,7 @@ export abstract class PageCoreService extends APIService {
|
|||
const abortPromise = new Promise((_, reject) => {
|
||||
if (abortSignal) {
|
||||
abortListener = () => {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
reject(new AppError(new DOMException("Aborted", "AbortError")));
|
||||
};
|
||||
abortSignal.addEventListener("abort", abortListener);
|
||||
}
|
||||
|
|
@ -66,16 +76,22 @@ export abstract class PageCoreService extends APIService {
|
|||
|
||||
try {
|
||||
return await Promise.race([
|
||||
this.patch(`${this.basePath}/pages/${pageId}`, data, {
|
||||
this.patch(`${this.basePath}/pages/${pageId}/`, data, {
|
||||
headers: this.getHeader(),
|
||||
signal: abortSignal,
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
if (error.name === "AbortError") {
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "updatePageProperties", pageId },
|
||||
});
|
||||
|
||||
if (appError.code === "ABORT_ERROR") {
|
||||
throw appError;
|
||||
}
|
||||
throw error;
|
||||
|
||||
logger.error("Failed to update page properties", appError);
|
||||
throw appError;
|
||||
}),
|
||||
abortPromise,
|
||||
]);
|
||||
|
|
@ -93,7 +109,11 @@ export abstract class PageCoreService extends APIService {
|
|||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "updateDescriptionBinary", pageId },
|
||||
});
|
||||
logger.error("Failed to update page description binary", appError);
|
||||
throw appError;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { AppError } from "@/lib/errors";
|
||||
import type { HocusPocusServerContext, TDocumentTypes } from "@/types";
|
||||
// services
|
||||
import { ProjectPageService } from "./project-page.service";
|
||||
|
|
@ -11,5 +12,5 @@ export const getPageService = (documentType: TDocumentTypes, context: HocusPocus
|
|||
});
|
||||
}
|
||||
|
||||
throw new Error(`Invalid document type ${documentType} provided.`);
|
||||
throw new AppError(`Invalid document type ${documentType} provided.`);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { AppError } from "@/lib/errors";
|
||||
import { PageService } from "./extended.service";
|
||||
|
||||
interface ProjectPageServiceParams {
|
||||
|
|
@ -13,9 +14,9 @@ export class ProjectPageService extends PageService {
|
|||
constructor(params: ProjectPageServiceParams) {
|
||||
super();
|
||||
const { workspaceSlug, projectId } = params;
|
||||
if (!workspaceSlug || !projectId) throw new Error("Missing required fields.");
|
||||
if (!workspaceSlug || !projectId) throw new AppError("Missing required fields.");
|
||||
// validate cookie
|
||||
if (!params.cookie) throw new Error("Cookie is required.");
|
||||
if (!params.cookie) throw new AppError("Cookie is required.");
|
||||
// set cookie
|
||||
this.setHeader("Cookie", params.cookie);
|
||||
// set base path
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
// 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 {
|
||||
|
|
@ -22,7 +24,11 @@ export class UserService extends APIService {
|
|||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "currentUser" },
|
||||
});
|
||||
logger.error("Failed to fetch current user", appError);
|
||||
throw appError;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
// eslint-disable-next-line import/order
|
||||
import { setupSentry } from "./instrument";
|
||||
setupSentry();
|
||||
|
||||
// eslint-disable-next-line import/order
|
||||
import { logger } from "@plane/logger";
|
||||
import { AppError } from "@/lib/errors";
|
||||
import { Server } from "./server";
|
||||
|
||||
let server: Server;
|
||||
|
|
@ -20,28 +21,41 @@ async function startServer() {
|
|||
|
||||
startServer();
|
||||
|
||||
// Graceful shutdown on unhandled rejection
|
||||
process.on("unhandledRejection", async (err: Error) => {
|
||||
logger.error(`UNHANDLED REJECTION!`, err);
|
||||
// Handle process signals
|
||||
process.on("SIGTERM", async () => {
|
||||
logger.info("Received SIGTERM signal. Initiating graceful shutdown...");
|
||||
try {
|
||||
// if (server) {
|
||||
// await server.destroy();
|
||||
// }
|
||||
} finally {
|
||||
// logger.info("Exiting process...");
|
||||
// process.exit(1);
|
||||
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);
|
||||
});
|
||||
|
||||
// Graceful shutdown on uncaught exception
|
||||
process.on("uncaughtException", async (err: Error) => {
|
||||
logger.error(`UNCAUGHT EXCEPTION!`, err);
|
||||
process.on("SIGINT", async () => {
|
||||
logger.info("Received SIGINT signal. Killing node process...");
|
||||
try {
|
||||
// if (server) {
|
||||
// await server.destroy();
|
||||
// }
|
||||
} finally {
|
||||
// logger.info("Exiting process...");
|
||||
// process.exit(1);
|
||||
if (server) {
|
||||
await server.destroy();
|
||||
}
|
||||
logger.info("Server shut down gracefully");
|
||||
} catch (error) {
|
||||
logger.error("Error during graceful shutdown:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (err: Error) => {
|
||||
const error = new AppError(err);
|
||||
logger.error(`[UNHANDLED_REJECTION]`, error);
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (err: Error) => {
|
||||
const error = new AppError(err);
|
||||
logger.error(`[UNCAUGHT_EXCEPTION]`, error);
|
||||
});
|
||||
|
|
|
|||
143
apps/live/src/types/admin-commands.ts
Normal file
143
apps/live/src/types/admin-commands.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
/**
|
||||
* Type-safe admin commands for server-to-server communication
|
||||
*/
|
||||
|
||||
/**
|
||||
* Force close error codes - reasons why a document is being force closed
|
||||
*/
|
||||
export enum ForceCloseReason {
|
||||
CRITICAL_ERROR = "critical_error",
|
||||
MEMORY_LEAK = "memory_leak",
|
||||
DOCUMENT_TOO_LARGE = "document_too_large",
|
||||
ADMIN_REQUEST = "admin_request",
|
||||
SERVER_SHUTDOWN = "server_shutdown",
|
||||
SECURITY_VIOLATION = "security_violation",
|
||||
CORRUPTION_DETECTED = "corruption_detected",
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket close codes
|
||||
* https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code
|
||||
*/
|
||||
export enum CloseCode {
|
||||
/** Normal closure; the connection successfully completed */
|
||||
NORMAL = 1000,
|
||||
/** The endpoint is going away (server shutdown or browser navigating away) */
|
||||
GOING_AWAY = 1001,
|
||||
/** Protocol error */
|
||||
PROTOCOL_ERROR = 1002,
|
||||
/** Unsupported data */
|
||||
UNSUPPORTED_DATA = 1003,
|
||||
/** Reserved (no status code was present) */
|
||||
NO_STATUS = 1005,
|
||||
/** Abnormal closure */
|
||||
ABNORMAL = 1006,
|
||||
/** Invalid frame payload data */
|
||||
INVALID_DATA = 1007,
|
||||
/** Policy violation */
|
||||
POLICY_VIOLATION = 1008,
|
||||
/** Message too big */
|
||||
MESSAGE_TOO_BIG = 1009,
|
||||
/** Client expected extension not negotiated */
|
||||
MANDATORY_EXTENSION = 1010,
|
||||
/** Server encountered unexpected condition */
|
||||
INTERNAL_ERROR = 1011,
|
||||
/** Custom: Force close requested */
|
||||
FORCE_CLOSE = 4000,
|
||||
/** Custom: Document too large */
|
||||
DOCUMENT_TOO_LARGE = 4001,
|
||||
/** Custom: Memory pressure */
|
||||
MEMORY_PRESSURE = 4002,
|
||||
/** Custom: Security violation */
|
||||
SECURITY_VIOLATION = 4003,
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin command types
|
||||
*/
|
||||
export enum AdminCommand {
|
||||
FORCE_CLOSE = "force_close",
|
||||
HEALTH_CHECK = "health_check",
|
||||
RESTART_DOCUMENT = "restart_document",
|
||||
}
|
||||
|
||||
/**
|
||||
* Force close command data structure
|
||||
*/
|
||||
export interface ForceCloseCommandData {
|
||||
command: AdminCommand.FORCE_CLOSE;
|
||||
docId: string;
|
||||
reason: ForceCloseReason;
|
||||
code: CloseCode;
|
||||
originServer: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check command data structure
|
||||
*/
|
||||
export interface HealthCheckCommandData {
|
||||
command: AdminCommand.HEALTH_CHECK;
|
||||
originServer: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all admin commands
|
||||
*/
|
||||
export type AdminCommandData = ForceCloseCommandData | HealthCheckCommandData;
|
||||
|
||||
/**
|
||||
* Client force close message structure (sent to clients via sendStateless)
|
||||
*/
|
||||
export interface ClientForceCloseMessage {
|
||||
type: "force_close";
|
||||
reason: ForceCloseReason;
|
||||
code: CloseCode;
|
||||
message?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin command handler function type
|
||||
*/
|
||||
export type AdminCommandHandler<T extends AdminCommandData = AdminCommandData> = (data: T) => Promise<void> | void;
|
||||
|
||||
/**
|
||||
* Type guard to check if data is a ForceCloseCommandData
|
||||
*/
|
||||
export function isForceCloseCommand(data: AdminCommandData): data is ForceCloseCommandData {
|
||||
return data.command === AdminCommand.FORCE_CLOSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if data is a HealthCheckCommandData
|
||||
*/
|
||||
export function isHealthCheckCommand(data: AdminCommandData): data is HealthCheckCommandData {
|
||||
return data.command === AdminCommand.HEALTH_CHECK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate force close reason
|
||||
*/
|
||||
export function isValidForceCloseReason(reason: string): reason is ForceCloseReason {
|
||||
return Object.values(ForceCloseReason).includes(reason as ForceCloseReason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable message for force close reason
|
||||
*/
|
||||
export function getForceCloseMessage(reason: ForceCloseReason): string {
|
||||
const messages: Record<ForceCloseReason, string> = {
|
||||
[ForceCloseReason.CRITICAL_ERROR]: "A critical error occurred. Please refresh the page.",
|
||||
[ForceCloseReason.MEMORY_LEAK]: "Memory limit exceeded. Please refresh the page.",
|
||||
[ForceCloseReason.DOCUMENT_TOO_LARGE]:
|
||||
"Content limit reached and live sync is off. Create a new page or use nested pages to continue syncing.",
|
||||
[ForceCloseReason.ADMIN_REQUEST]: "Connection closed by administrator. Please try again later.",
|
||||
[ForceCloseReason.SERVER_SHUTDOWN]: "Server is shutting down. Please reconnect in a moment.",
|
||||
[ForceCloseReason.SECURITY_VIOLATION]: "Security violation detected. Connection terminated.",
|
||||
[ForceCloseReason.CORRUPTION_DETECTED]: "Data corruption detected. Please refresh the page.",
|
||||
};
|
||||
|
||||
return messages[reason] || "Connection closed. Please refresh the page.";
|
||||
}
|
||||
38
apps/live/src/utils/broadcast-error.ts
Normal file
38
apps/live/src/utils/broadcast-error.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { type Hocuspocus } from "@hocuspocus/server";
|
||||
import { createRealtimeEvent } from "@plane/editor";
|
||||
import { logger } from "@plane/logger";
|
||||
import type { FetchPayloadWithContext, StorePayloadWithContext } from "@/types";
|
||||
import { broadcastMessageToPage } from "./broadcast-message";
|
||||
|
||||
// Helper to broadcast error to frontend
|
||||
export const broadcastError = async (
|
||||
hocuspocusServerInstance: Hocuspocus,
|
||||
pageId: string,
|
||||
errorMessage: string,
|
||||
errorType: "fetch" | "store",
|
||||
context: FetchPayloadWithContext["context"] | StorePayloadWithContext["context"],
|
||||
errorCode?: "content_too_large" | "page_locked" | "page_archived",
|
||||
shouldDisconnect?: boolean
|
||||
) => {
|
||||
try {
|
||||
const errorEvent = createRealtimeEvent({
|
||||
action: "error",
|
||||
page_id: pageId,
|
||||
parent_id: undefined,
|
||||
descendants_ids: [],
|
||||
data: {
|
||||
error_message: errorMessage,
|
||||
error_type: errorType,
|
||||
error_code: errorCode,
|
||||
should_disconnect: shouldDisconnect,
|
||||
user_id: context.userId || "",
|
||||
},
|
||||
workspace_slug: context.workspaceSlug || "",
|
||||
user_id: context.userId || "",
|
||||
});
|
||||
|
||||
await broadcastMessageToPage(hocuspocusServerInstance, pageId, errorEvent);
|
||||
} catch (broadcastError) {
|
||||
logger.error("Error broadcasting error message to frontend:", broadcastError);
|
||||
}
|
||||
};
|
||||
34
apps/live/src/utils/broadcast-message.ts
Normal file
34
apps/live/src/utils/broadcast-message.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { Hocuspocus } from "@hocuspocus/server";
|
||||
import { BroadcastedEvent } from "@plane/editor";
|
||||
import { logger } from "@plane/logger";
|
||||
import { Redis } from "@/extensions/redis";
|
||||
import { AppError } from "@/lib/errors";
|
||||
|
||||
export const broadcastMessageToPage = async (
|
||||
hocuspocusServerInstance: Hocuspocus,
|
||||
documentName: string,
|
||||
eventData: BroadcastedEvent
|
||||
): Promise<boolean> => {
|
||||
if (!hocuspocusServerInstance || !hocuspocusServerInstance.documents) {
|
||||
const appError = new AppError("HocusPocus server not available or initialized", {
|
||||
context: { operation: "broadcastMessageToPage", documentName },
|
||||
});
|
||||
logger.error("Error while broadcasting message:", appError);
|
||||
return false;
|
||||
}
|
||||
|
||||
const redisExtension = hocuspocusServerInstance.configuration.extensions.find((ext) => ext instanceof Redis);
|
||||
|
||||
if (!redisExtension) {
|
||||
logger.error("BROADCAST_MESSAGE_TO_PAGE: Redis extension not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await redisExtension.broadcastToDocument(documentName, eventData);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`BROADCAST_MESSAGE_TO_PAGE: Error broadcasting to ${documentName}:`, error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
@ -1,83 +1,21 @@
|
|||
import { getSchema } from "@tiptap/core";
|
||||
import { generateHTML, generateJSON } from "@tiptap/html";
|
||||
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
// plane editor
|
||||
import {
|
||||
getAllDocumentFormatsFromDocumentEditorBinaryData,
|
||||
getAllDocumentFormatsFromRichTextEditorBinaryData,
|
||||
getBinaryDataFromDocumentEditorHTMLString,
|
||||
getBinaryDataFromRichTextEditorHTMLString,
|
||||
} from "@plane/editor";
|
||||
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
|
||||
// plane types
|
||||
import { TDocumentPayload } from "@plane/types";
|
||||
|
||||
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
|
||||
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
||||
type TArgs = {
|
||||
document_html: string;
|
||||
variant: "rich" | "document";
|
||||
};
|
||||
|
||||
export const convertHTMLDocumentToAllFormats = (args: TArgs): TDocumentPayload => {
|
||||
const { document_html, variant } = args;
|
||||
|
||||
if (variant === "rich") {
|
||||
const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html);
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
||||
getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary);
|
||||
return {
|
||||
description: contentJSON,
|
||||
description_html: contentHTML,
|
||||
description_binary: contentBinaryEncoded,
|
||||
};
|
||||
}
|
||||
|
||||
if (variant === "document") {
|
||||
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
||||
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
|
||||
return {
|
||||
description: contentJSON,
|
||||
description_html: contentHTML,
|
||||
description_binary: contentBinaryEncoded,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Invalid variant provided: ${variant}`);
|
||||
};
|
||||
|
||||
export const getAllDocumentFormatsFromBinaryData = (
|
||||
description: Uint8Array
|
||||
): {
|
||||
contentBinaryEncoded: string;
|
||||
contentJSON: object;
|
||||
contentHTML: string;
|
||||
} => {
|
||||
// encode binary description data
|
||||
const base64Data = Buffer.from(description).toString("base64");
|
||||
const yDoc = new Y.Doc();
|
||||
Y.applyUpdate(yDoc, description);
|
||||
// convert to JSON
|
||||
const type = yDoc.getXmlFragment("default");
|
||||
const contentJSON = yXmlFragmentToProseMirrorRootNode(type, documentEditorSchema).toJSON();
|
||||
// convert to HTML
|
||||
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
||||
export const generateTitleProsemirrorJson = (text: string) => {
|
||||
return {
|
||||
contentBinaryEncoded: base64Data,
|
||||
contentJSON,
|
||||
contentHTML,
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 1 },
|
||||
...(text
|
||||
? {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const getBinaryDataFromHTMLString = (descriptionHTML: string): Uint8Array => {
|
||||
// convert HTML to JSON
|
||||
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", DOCUMENT_EDITOR_EXTENSIONS);
|
||||
// convert JSON to Y.Doc format
|
||||
const transformedData = prosemirrorJSONToYDoc(documentEditorSchema, contentJSON, "default");
|
||||
// convert Y.Doc to Uint8Array format
|
||||
return Y.encodeStateAsUpdate(transformedData);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -54,7 +54,6 @@ export type TPageActions =
|
|||
| "move";
|
||||
|
||||
type Props = {
|
||||
editorRef?: EditorRefApi | null;
|
||||
extraOptions?: (TContextMenuItem & { key: TPageActions })[];
|
||||
optionsOrder: TPageActions[];
|
||||
page: TPageInstance;
|
||||
|
|
@ -63,7 +62,7 @@ type Props = {
|
|||
};
|
||||
|
||||
export const PageActions: React.FC<Props> = observer((props) => {
|
||||
const { editorRef, extraOptions, optionsOrder, page, parentRef, storeType } = props;
|
||||
const { extraOptions, optionsOrder, page, parentRef, storeType } = props;
|
||||
// states
|
||||
const [deletePageModal, setDeletePageModal] = useState(false);
|
||||
const [movePageModal, setMovePageModal] = useState(false);
|
||||
|
|
@ -75,7 +74,6 @@ export const PageActions: React.FC<Props> = observer((props) => {
|
|||
});
|
||||
// page operations
|
||||
const { pageOperations } = usePageOperations({
|
||||
editorRef,
|
||||
page,
|
||||
});
|
||||
// derived values
|
||||
|
|
|
|||
|
|
@ -130,7 +130,6 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
|||
pageTitle={name ?? ""}
|
||||
/>
|
||||
<PageActions
|
||||
editorRef={editorRef}
|
||||
extraOptions={EXTRA_MENU_OPTIONS}
|
||||
optionsOrder={[
|
||||
"full-screen",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
|
|||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
await removePage(pageId)
|
||||
await removePage({ pageId })
|
||||
.then(() => {
|
||||
captureSuccess({
|
||||
eventName: PROJECT_PAGE_TRACKER_EVENTS.delete,
|
||||
|
|
|
|||
|
|
@ -6,51 +6,51 @@ import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
|||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
// Better type naming and structure
|
||||
type CollaborativeAction = {
|
||||
execute: (shouldSync?: boolean) => Promise<void>;
|
||||
export type CollaborativeAction = {
|
||||
execute: (shouldSync?: boolean, recursive?: boolean) => Promise<void>;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
type CollaborativeActionEvent =
|
||||
| { type: "sendMessageToServer"; message: TDocumentEventsServer }
|
||||
| { type: "sendMessageToServer"; message: TDocumentEventsServer; recursive?: boolean }
|
||||
| { type: "receivedMessageFromServer"; message: TDocumentEventsClient };
|
||||
|
||||
type Props = {
|
||||
editorRef?: EditorRefApi | null;
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
export const useCollaborativePageActions = (props: Props) => {
|
||||
const { editorRef, page } = props;
|
||||
const { page } = props;
|
||||
const editorRef = page.editor.editorRef;
|
||||
// currentUserAction local state to track if the current action is being processed, a
|
||||
// local action is basically the action performed by the current user to avoid double operations
|
||||
const [currentActionBeingProcessed, setCurrentActionBeingProcessed] = useState<TDocumentEventsClient | null>(null);
|
||||
|
||||
// @ts-expect-error - TODO: fix this
|
||||
const actionHandlerMap: Record<TDocumentEventsClient, CollaborativeAction> = useMemo(
|
||||
() => ({
|
||||
[DocumentCollaborativeEvents.lock.client]: {
|
||||
execute: (shouldSync) => page.lock(shouldSync),
|
||||
execute: (shouldSync?: boolean, recursive?: boolean) => page.lock({ shouldSync, recursive }),
|
||||
errorMessage: "Page could not be locked. Please try again later.",
|
||||
},
|
||||
[DocumentCollaborativeEvents.unlock.client]: {
|
||||
execute: (shouldSync) => page.unlock(shouldSync),
|
||||
execute: (shouldSync?: boolean, recursive?: boolean) => page.unlock({ shouldSync, recursive }),
|
||||
errorMessage: "Page could not be unlocked. Please try again later.",
|
||||
},
|
||||
[DocumentCollaborativeEvents.archive.client]: {
|
||||
execute: (shouldSync) => page.archive(shouldSync),
|
||||
execute: (shouldSync?: boolean) => page.archive({ shouldSync }),
|
||||
errorMessage: "Page could not be archived. Please try again later.",
|
||||
},
|
||||
[DocumentCollaborativeEvents.unarchive.client]: {
|
||||
execute: (shouldSync) => page.restore(shouldSync),
|
||||
execute: (shouldSync?: boolean) => page.restore({ shouldSync }),
|
||||
errorMessage: "Page could not be restored. Please try again later.",
|
||||
},
|
||||
[DocumentCollaborativeEvents["make-public"].client]: {
|
||||
execute: (shouldSync) => page.makePublic(shouldSync),
|
||||
execute: (shouldSync?: boolean) => page.makePublic({ shouldSync }),
|
||||
errorMessage: "Page could not be made public. Please try again later.",
|
||||
},
|
||||
[DocumentCollaborativeEvents["make-private"].client]: {
|
||||
execute: (shouldSync) => page.makePrivate(shouldSync),
|
||||
execute: (shouldSync?: boolean) => page.makePrivate({ shouldSync }),
|
||||
errorMessage: "Page could not be made private. Please try again later.",
|
||||
},
|
||||
}),
|
||||
|
|
@ -62,22 +62,22 @@ export const useCollaborativePageActions = (props: Props) => {
|
|||
const isPerformedByCurrentUser = event.type === "sendMessageToServer";
|
||||
const clientAction = isPerformedByCurrentUser ? DocumentCollaborativeEvents[event.message].client : event.message;
|
||||
const actionDetails = actionHandlerMap[clientAction];
|
||||
|
||||
try {
|
||||
await actionDetails.execute(isPerformedByCurrentUser);
|
||||
await actionDetails.execute(isPerformedByCurrentUser, isPerformedByCurrentUser ? event?.recursive : undefined);
|
||||
if (isPerformedByCurrentUser) {
|
||||
const serverEventName = getServerEventName(clientAction);
|
||||
if (serverEventName) {
|
||||
editorRef?.emitRealTimeUpdate(serverEventName);
|
||||
}
|
||||
setCurrentActionBeingProcessed(clientAction);
|
||||
}
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: actionDetails.errorMessage,
|
||||
});
|
||||
if (actionDetails?.errorMessage) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: actionDetails.errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[actionHandlerMap, editorRef]
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ export type TPageOperations = {
|
|||
};
|
||||
|
||||
type Props = {
|
||||
editorRef?: EditorRefApi | null;
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
|
|
|
|||
193
apps/web/core/hooks/use-realtime-page-events.tsx
Normal file
193
apps/web/core/hooks/use-realtime-page-events.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { useCallback, useMemo } from "react";
|
||||
// plane imports
|
||||
import type { EventToPayloadMap } from "@plane/editor";
|
||||
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
|
||||
// types
|
||||
import type { IUserLite } from "@plane/types";
|
||||
// components
|
||||
import type { TEditorBodyHandlers } from "@/components/pages/editor/editor-body";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import type { EPageStoreType } from "@/plane-web/hooks/store";
|
||||
import { usePageStore } from "@/plane-web/hooks/store";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
// Type for page update handlers with proper typing for action data
|
||||
export type PageUpdateHandler<T extends keyof EventToPayloadMap = keyof EventToPayloadMap> = (params: {
|
||||
pageIds: string[];
|
||||
data: EventToPayloadMap[T];
|
||||
performAction: boolean;
|
||||
}) => void;
|
||||
|
||||
// Type for custom event handlers that can be provided to override default behavior
|
||||
export type TCustomEventHandlers = {
|
||||
[K in keyof EventToPayloadMap]?: PageUpdateHandler<K>;
|
||||
};
|
||||
|
||||
interface UsePageEventsProps {
|
||||
page: TPageInstance;
|
||||
storeType: EPageStoreType;
|
||||
getUserDetails: (userId: string) => IUserLite | undefined;
|
||||
customRealtimeEventHandlers?: TCustomEventHandlers;
|
||||
handlers: TEditorBodyHandlers;
|
||||
}
|
||||
|
||||
export const useRealtimePageEvents = ({
|
||||
page,
|
||||
storeType,
|
||||
getUserDetails,
|
||||
customRealtimeEventHandlers,
|
||||
handlers,
|
||||
}: UsePageEventsProps) => {
|
||||
const router = useAppRouter();
|
||||
const { removePage, getPageById } = usePageStore(storeType);
|
||||
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
// Helper function to safely get user display text
|
||||
const getUserDisplayText = useCallback(
|
||||
(userId: string | undefined) => {
|
||||
if (!userId) return "";
|
||||
try {
|
||||
const userDetails = getUserDetails(userId as string);
|
||||
return userDetails?.display_name ? ` by ${userDetails.display_name}` : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
[getUserDetails]
|
||||
);
|
||||
|
||||
const ACTION_HANDLERS = useMemo<
|
||||
Partial<{
|
||||
[K in keyof EventToPayloadMap]: PageUpdateHandler<K>;
|
||||
}>
|
||||
>(
|
||||
() => ({
|
||||
archived: ({ pageIds, data }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageItem = getPageById(pageId);
|
||||
if (pageItem) pageItem.archive({ archived_at: data.archived_at, shouldSync: false });
|
||||
});
|
||||
},
|
||||
unarchived: ({ pageIds }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageItem = getPageById(pageId);
|
||||
if (pageItem) pageItem.restore({ shouldSync: false });
|
||||
});
|
||||
},
|
||||
locked: ({ pageIds }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageItem = getPageById(pageId);
|
||||
if (pageItem) pageItem.lock({ shouldSync: false, recursive: false });
|
||||
});
|
||||
},
|
||||
unlocked: ({ pageIds }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageItem = getPageById(pageId);
|
||||
if (pageItem) pageItem.unlock({ shouldSync: false, recursive: false });
|
||||
});
|
||||
},
|
||||
"made-public": ({ pageIds }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageItem = getPageById(pageId);
|
||||
if (pageItem) pageItem.makePublic({ shouldSync: false });
|
||||
});
|
||||
},
|
||||
"made-private": ({ pageIds }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageItem = getPageById(pageId);
|
||||
if (pageItem) pageItem.makePrivate({ shouldSync: false });
|
||||
});
|
||||
},
|
||||
deleted: ({ pageIds, data }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageItem = getPageById(pageId);
|
||||
if (pageItem) {
|
||||
removePage({ pageId, shouldSync: false });
|
||||
if (page.id === pageId && data?.user_id !== currentUser?.id) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Page deleted",
|
||||
message: `Page deleted${getUserDisplayText(data.user_id)}`,
|
||||
});
|
||||
router.push(handlers.getRedirectionLink());
|
||||
} else if (page.id === pageId) {
|
||||
router.push(handlers.getRedirectionLink());
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
property_updated: ({ pageIds, data }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageInstance = getPageById(pageId);
|
||||
const { name: updatedName, ...rest } = data;
|
||||
if (updatedName != null) pageInstance?.updateTitle(updatedName);
|
||||
pageInstance?.mutateProperties(rest);
|
||||
});
|
||||
},
|
||||
error: ({ pageIds, data }) => {
|
||||
const errorType = data.error_type;
|
||||
const errorMessage = data.error_message || "An error occurred";
|
||||
const errorCode = data.error_code;
|
||||
|
||||
if (page.id && pageIds.includes(page.id)) {
|
||||
// Show toast notification
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: errorType === "fetch" ? "Failed to load page" : "Failed to save page",
|
||||
message: errorMessage,
|
||||
});
|
||||
|
||||
// Handle specific error codes
|
||||
const pageInstance = getPageById(page.id);
|
||||
if (pageInstance) {
|
||||
if (errorCode === "page_locked") {
|
||||
// Lock the page if not already locked
|
||||
if (!pageInstance.is_locked) {
|
||||
pageInstance.mutateProperties({ is_locked: true });
|
||||
}
|
||||
} else if (errorCode === "page_archived") {
|
||||
// Mark page as archived if not already
|
||||
if (!pageInstance.archived_at) {
|
||||
pageInstance.mutateProperties({ archived_at: new Date().toISOString() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
...customRealtimeEventHandlers,
|
||||
}),
|
||||
[getPageById, page, router, getUserDisplayText, removePage, currentUser, customRealtimeEventHandlers, handlers]
|
||||
);
|
||||
|
||||
// The main function that will be returned from this hook
|
||||
const updatePageProperties = useCallback(
|
||||
<T extends keyof EventToPayloadMap>(
|
||||
pageIds: string | string[],
|
||||
actionType: T,
|
||||
data: EventToPayloadMap[T],
|
||||
performAction = false
|
||||
) => {
|
||||
// Convert to array if single string is passed
|
||||
const normalizedPageIds = Array.isArray(pageIds) ? pageIds : [pageIds];
|
||||
|
||||
if (normalizedPageIds.length === 0) return;
|
||||
|
||||
// Get the handler for this message type
|
||||
const handler = ACTION_HANDLERS[actionType];
|
||||
|
||||
if (handler) {
|
||||
// Now TypeScript knows that handler and data match in type
|
||||
handler({ pageIds: normalizedPageIds, data, performAction });
|
||||
} else {
|
||||
console.warn(`No handler for message type: ${actionType.toString()}`);
|
||||
}
|
||||
},
|
||||
[ACTION_HANDLERS]
|
||||
);
|
||||
|
||||
return { updatePageProperties };
|
||||
};
|
||||
|
|
@ -25,12 +25,12 @@ export type TBasePage = TPage & {
|
|||
update: (pageData: Partial<TPage>) => Promise<Partial<TPage> | undefined>;
|
||||
updateTitle: (title: string) => void;
|
||||
updateDescription: (document: TDocumentPayload) => Promise<void>;
|
||||
makePublic: (shouldSync?: boolean) => Promise<void>;
|
||||
makePrivate: (shouldSync?: boolean) => Promise<void>;
|
||||
lock: (shouldSync?: boolean) => Promise<void>;
|
||||
unlock: (shouldSync?: boolean) => Promise<void>;
|
||||
archive: (shouldSync?: boolean) => Promise<void>;
|
||||
restore: (shouldSync?: boolean) => Promise<void>;
|
||||
makePublic: (params: { shouldSync?: boolean }) => Promise<void>;
|
||||
makePrivate: (params: { shouldSync?: boolean }) => Promise<void>;
|
||||
lock: (params: { shouldSync?: boolean; recursive?: boolean }) => Promise<void>;
|
||||
unlock: (params: { shouldSync?: boolean; recursive?: boolean }) => Promise<void>;
|
||||
archive: (params: { shouldSync?: boolean; archived_at?: string | null }) => Promise<void>;
|
||||
restore: (params: { shouldSync?: boolean }) => Promise<void>;
|
||||
updatePageLogo: (value: TChangeHandlerProps) => Promise<void>;
|
||||
addToFavorites: () => Promise<void>;
|
||||
removePageFromFavorites: () => Promise<void>;
|
||||
|
|
@ -315,7 +315,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
|
|||
/**
|
||||
* @description make the page public
|
||||
*/
|
||||
makePublic = async (shouldSync: boolean = true) => {
|
||||
makePublic = async ({ shouldSync = true }) => {
|
||||
const pageAccess = this.access;
|
||||
runInAction(() => {
|
||||
this.access = EPageAccess.PUBLIC;
|
||||
|
|
@ -338,7 +338,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
|
|||
/**
|
||||
* @description make the page private
|
||||
*/
|
||||
makePrivate = async (shouldSync: boolean = true) => {
|
||||
makePrivate = async ({ shouldSync = true }) => {
|
||||
const pageAccess = this.access;
|
||||
runInAction(() => {
|
||||
this.access = EPageAccess.PRIVATE;
|
||||
|
|
@ -361,7 +361,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
|
|||
/**
|
||||
* @description lock the page
|
||||
*/
|
||||
lock = async (shouldSync: boolean = true) => {
|
||||
lock = async ({ shouldSync = true }) => {
|
||||
const pageIsLocked = this.is_locked;
|
||||
runInAction(() => (this.is_locked = true));
|
||||
|
||||
|
|
@ -378,7 +378,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
|
|||
/**
|
||||
* @description unlock the page
|
||||
*/
|
||||
unlock = async (shouldSync: boolean = true) => {
|
||||
unlock = async ({ shouldSync = true }) => {
|
||||
const pageIsLocked = this.is_locked;
|
||||
runInAction(() => (this.is_locked = false));
|
||||
|
||||
|
|
@ -395,12 +395,12 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
|
|||
/**
|
||||
* @description archive the page
|
||||
*/
|
||||
archive = async (shouldSync: boolean = true) => {
|
||||
archive = async ({ shouldSync = true, archived_at }: { shouldSync?: boolean; archived_at?: string | null }) => {
|
||||
if (!this.id) return undefined;
|
||||
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.archived_at = new Date().toISOString();
|
||||
this.archived_at = archived_at ?? new Date().toISOString();
|
||||
});
|
||||
|
||||
if (this.rootStore.favorite.entityMap[this.id]) this.rootStore.favorite.removeFavoriteFromStore(this.id);
|
||||
|
|
@ -422,7 +422,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
|
|||
/**
|
||||
* @description restore the page
|
||||
*/
|
||||
restore = async (shouldSync: boolean = true) => {
|
||||
restore = async ({ shouldSync = true }: { shouldSync?: boolean }) => {
|
||||
const archivedAtBeforeRestore = this.archived_at;
|
||||
|
||||
try {
|
||||
|
|
@ -438,6 +438,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
|
|||
runInAction(() => {
|
||||
this.archived_at = archivedAtBeforeRestore;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export interface IProjectPageStore {
|
|||
options?: { trackVisit?: boolean }
|
||||
) => Promise<TPage | undefined>;
|
||||
createPage: (pageData: Partial<TPage>) => Promise<TPage | undefined>;
|
||||
removePage: (pageId: string) => Promise<void>;
|
||||
removePage: (params: { pageId: string; shouldSync?: boolean }) => Promise<void>;
|
||||
movePage: (workspaceSlug: string, projectId: string, pageId: string, newProjectId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -321,7 +321,7 @@ export class ProjectPageStore implements IProjectPageStore {
|
|||
* @description delete a page
|
||||
* @param {string} pageId
|
||||
*/
|
||||
removePage = async (pageId: string) => {
|
||||
removePage = async ({ pageId, shouldSync = true }: { pageId: string; shouldSync?: boolean }) => {
|
||||
try {
|
||||
const { workspaceSlug, projectId } = this.store.router;
|
||||
if (!workspaceSlug || !projectId || !pageId) return undefined;
|
||||
|
|
|
|||
|
|
@ -38,29 +38,31 @@
|
|||
"@floating-ui/dom": "^1.7.1",
|
||||
"@floating-ui/react": "^0.26.4",
|
||||
"@headlessui/react": "^1.7.3",
|
||||
"@hocuspocus/provider": "3.2.5",
|
||||
"@hocuspocus/provider": "2.15.2",
|
||||
"@plane/constants": "workspace:*",
|
||||
"@plane/hooks": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
"@plane/ui": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
"@tiptap/core": "catalog:",
|
||||
"@tiptap/extension-blockquote": "^3.5.3",
|
||||
"@tiptap/extension-collaboration": "^3.5.3",
|
||||
"@tiptap/extension-emoji": "^3.5.3",
|
||||
"@tiptap/extension-image": "^3.5.3",
|
||||
"@tiptap/extension-list-item": "^3.5.3",
|
||||
"@tiptap/extension-mention": "^3.5.3",
|
||||
"@tiptap/extension-task-item": "^3.5.3",
|
||||
"@tiptap/extension-task-list": "^3.5.3",
|
||||
"@tiptap/extension-text-align": "^3.5.3",
|
||||
"@tiptap/extension-text-style": "^3.5.3",
|
||||
"@tiptap/extensions": "^3.5.3",
|
||||
"@tiptap/extension-blockquote": "^2.22.3",
|
||||
"@tiptap/extension-character-count": "^2.22.3",
|
||||
"@tiptap/extension-collaboration": "^2.22.3",
|
||||
"@tiptap/extension-emoji": "^2.22.3",
|
||||
"@tiptap/extension-image": "^2.22.3",
|
||||
"@tiptap/extension-list-item": "^2.22.3",
|
||||
"@tiptap/extension-mention": "^2.22.3",
|
||||
"@tiptap/extension-placeholder": "^2.22.3",
|
||||
"@tiptap/extension-task-item": "^2.22.3",
|
||||
"@tiptap/extension-task-list": "^2.22.3",
|
||||
"@tiptap/extension-text-align": "^2.22.3",
|
||||
"@tiptap/extension-text-style": "^2.22.3",
|
||||
"@tiptap/extension-underline": "^2.22.3",
|
||||
"@tiptap/html": "catalog:",
|
||||
"@tiptap/pm": "^3.5.3",
|
||||
"@tiptap/react": "^3.5.3",
|
||||
"@tiptap/starter-kit": "^3.5.3",
|
||||
"@tiptap/suggestion": "^3.5.3",
|
||||
"@tiptap/pm": "^2.22.3",
|
||||
"@tiptap/react": "^2.22.3",
|
||||
"@tiptap/starter-kit": "^2.22.3",
|
||||
"@tiptap/suggestion": "^2.22.3",
|
||||
"emoji-regex": "^10.3.0",
|
||||
"highlight.js": "^11.8.0",
|
||||
"is-emoji-supported": "^0.0.5",
|
||||
|
|
@ -70,7 +72,7 @@
|
|||
"lucide-react": "catalog:",
|
||||
"prosemirror-codemark": "^0.4.2",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"uuid": "catalog:",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"y-prosemirror": "^1.2.15",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { type Editor, isNodeSelection } from "@tiptap/core";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import { BubbleMenu, type BubbleMenuProps } from "@tiptap/react/menus";
|
||||
import { BubbleMenu, type BubbleMenuProps, useEditorState } from "@tiptap/react";
|
||||
import { FC, useEffect, useState, useRef } from "react";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
|
|
@ -119,7 +118,10 @@ export const EditorBubbleMenu: FC<Props> = (props) => {
|
|||
}
|
||||
return true;
|
||||
},
|
||||
options: {
|
||||
tippyOptions: {
|
||||
moveTransition: "transform 0.15s ease-out",
|
||||
duration: [300, 0],
|
||||
zIndex: 9,
|
||||
onShow: () => {
|
||||
if (editor.storage.link) {
|
||||
editor.storage.link.isBubbleMenuOpen = true;
|
||||
|
|
@ -134,13 +136,15 @@ export const EditorBubbleMenu: FC<Props> = (props) => {
|
|||
editor.commands.removeActiveDropbarExtension("bubble-menu");
|
||||
}, 0);
|
||||
},
|
||||
onHidden: () => {
|
||||
if (editor.storage.link) {
|
||||
editor.storage.link.isBubbleMenuOpen = false;
|
||||
}
|
||||
setTimeout(() => {
|
||||
editor.commands.removeActiveDropbarExtension("bubble-menu");
|
||||
}, 0);
|
||||
},
|
||||
},
|
||||
// TODO: Migrate these to floating UI options
|
||||
// tippyOptions: {
|
||||
// moveTransition: "transform 0.15s ease-out",
|
||||
// duration: [300, 0],
|
||||
// zIndex: 9,
|
||||
// },
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,91 @@
|
|||
import { EPageAccess } from "@plane/constants";
|
||||
import { TPage } from "@plane/types";
|
||||
import { CreatePayload, BaseActionPayload } from "@/types";
|
||||
|
||||
// Define all payload types for each event.
|
||||
export type ArchivedPayload = CreatePayload<{ archived_at: string | null }>;
|
||||
export type UnarchivedPayload = BaseActionPayload;
|
||||
export type LockedPayload = CreatePayload<{ is_locked: boolean }>;
|
||||
export type UnlockedPayload = BaseActionPayload;
|
||||
export type MadePublicPayload = CreatePayload<{ access: EPageAccess }>;
|
||||
export type MadePrivatePayload = CreatePayload<{ access: EPageAccess }>;
|
||||
export type DeletedPayload = CreatePayload<{ deleted_at: Date | null }>;
|
||||
export type DuplicatedPayload = CreatePayload<{ new_page_id: string }>;
|
||||
export type PropertyUpdatedPayload = CreatePayload<Partial<TPage>>;
|
||||
export type MovedPayload = CreatePayload<{
|
||||
new_project_id: string;
|
||||
new_page_id: string;
|
||||
}>;
|
||||
export type RestoredPayload = CreatePayload<{ deleted_page_ids?: string[] }>;
|
||||
export type ErrorPayload = CreatePayload<{
|
||||
error_message: string;
|
||||
error_type: "fetch" | "store";
|
||||
error_code?: "content_too_large" | "page_locked" | "page_archived";
|
||||
should_disconnect?: boolean;
|
||||
}>;
|
||||
|
||||
// Enhanced DocumentCollaborativeEvents with payload types.
|
||||
// Both the client name and server name are defined, and we add a "payloadType" property
|
||||
// so that we can later derive a mapping from client event to payload type.
|
||||
export const DocumentCollaborativeEvents = {
|
||||
lock: { client: "locked", server: "lock" },
|
||||
unlock: { client: "unlocked", server: "unlock" },
|
||||
archive: { client: "archived", server: "archive" },
|
||||
unarchive: { client: "unarchived", server: "unarchive" },
|
||||
"make-public": { client: "made-public", server: "make-public" },
|
||||
"make-private": { client: "made-private", server: "make-private" },
|
||||
lock: {
|
||||
client: "locked",
|
||||
server: "lock",
|
||||
payloadType: {} as LockedPayload,
|
||||
},
|
||||
unlock: {
|
||||
client: "unlocked",
|
||||
server: "unlock",
|
||||
payloadType: {} as UnlockedPayload,
|
||||
},
|
||||
archive: {
|
||||
client: "archived",
|
||||
server: "archive",
|
||||
payloadType: {} as ArchivedPayload,
|
||||
},
|
||||
unarchive: {
|
||||
client: "unarchived",
|
||||
server: "unarchive",
|
||||
payloadType: {} as UnarchivedPayload,
|
||||
},
|
||||
"make-public": {
|
||||
client: "made-public",
|
||||
server: "make-public",
|
||||
payloadType: {} as MadePublicPayload,
|
||||
},
|
||||
"make-private": {
|
||||
client: "made-private",
|
||||
server: "make-private",
|
||||
payloadType: {} as MadePrivatePayload,
|
||||
},
|
||||
delete: {
|
||||
client: "deleted",
|
||||
server: "delete",
|
||||
payloadType: {} as DeletedPayload,
|
||||
},
|
||||
move: {
|
||||
client: "moved",
|
||||
server: "move",
|
||||
payloadType: {} as MovedPayload,
|
||||
},
|
||||
duplicate: {
|
||||
client: "duplicated",
|
||||
server: "duplicate",
|
||||
payloadType: {} as DuplicatedPayload,
|
||||
},
|
||||
property_update: {
|
||||
client: "property_updated",
|
||||
server: "property_update",
|
||||
payloadType: {} as PropertyUpdatedPayload,
|
||||
},
|
||||
restore: {
|
||||
client: "restored",
|
||||
server: "restore",
|
||||
payloadType: {} as RestoredPayload,
|
||||
},
|
||||
error: {
|
||||
client: "error",
|
||||
server: "error",
|
||||
payloadType: {} as ErrorPayload,
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export const CodeBlockComponent: React.FC<Props> = ({ node }) => {
|
|||
</Tooltip>
|
||||
|
||||
<pre className="bg-custom-background-90 text-custom-text-100 rounded-lg p-4 my-2">
|
||||
<NodeViewContent<"code"> as="code" className="whitespace-pre-wrap" />
|
||||
<NodeViewContent as="code" className="whitespace-pre-wrap" />
|
||||
</pre>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { Underline } from "@tiptap/extension-underline";
|
||||
// plane editor imports
|
||||
import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props";
|
||||
// extensions
|
||||
|
|
@ -30,6 +31,7 @@ export const CoreEditorExtensionsWithoutProps = [
|
|||
CustomLinkExtension,
|
||||
ImageExtensionConfig,
|
||||
CustomImageExtensionConfig,
|
||||
Underline,
|
||||
TextStyle,
|
||||
TaskList.configure({
|
||||
HTMLAttributes: {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,32 @@ type LinkOptions = {
|
|||
};
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
[CORE_EXTENSIONS.CUSTOM_LINK]: {
|
||||
/**
|
||||
* Set a link mark
|
||||
*/
|
||||
setLink: (attributes: {
|
||||
href: string;
|
||||
target?: string | null;
|
||||
rel?: string | null;
|
||||
class?: string | null;
|
||||
}) => ReturnType;
|
||||
/**
|
||||
* Toggle a link mark
|
||||
*/
|
||||
toggleLink: (attributes: {
|
||||
href: string;
|
||||
target?: string | null;
|
||||
rel?: string | null;
|
||||
class?: string | null;
|
||||
}) => ReturnType;
|
||||
/**
|
||||
* Unset a link mark
|
||||
*/
|
||||
unsetLink: () => ReturnType;
|
||||
};
|
||||
}
|
||||
interface Storage {
|
||||
[CORE_EXTENSIONS.CUSTOM_LINK]: CustomLinkStorage;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { EmojiOptions } from "@tiptap/extension-emoji";
|
||||
import type { EmojiOptions, EmojiStorage } from "@tiptap/extension-emoji";
|
||||
import { ReactRenderer, type Editor } from "@tiptap/react";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
|
@ -12,7 +12,7 @@ const DEFAULT_EMOJIS = ["+1", "-1", "smile", "orange_heart", "eyes"];
|
|||
|
||||
export const emojiSuggestion: EmojiOptions["suggestion"] = {
|
||||
items: ({ editor, query }: { editor: Editor; query: string }): EmojiItem[] => {
|
||||
const { emojis, isSupported } = editor.storage.emoji;
|
||||
const { emojis, isSupported } = editor.storage.emoji as EmojiStorage;
|
||||
const filteredEmojis = emojis.filter((emoji) => {
|
||||
const hasEmoji = !!emoji?.emoji;
|
||||
const hasFallbackImage = !!emoji?.fallbackImage;
|
||||
|
|
@ -79,7 +79,7 @@ export const emojiSuggestion: EmojiOptions["suggestion"] = {
|
|||
component.updateProps(props);
|
||||
if (!props.clientRect) return;
|
||||
cleanup();
|
||||
cleanup = updateFloatingUIFloaterPosition(props.editor, component.element).cleanup;
|
||||
cleanup = updateFloatingUIFloaterPosition(props.editor, component.element as HTMLElement).cleanup;
|
||||
},
|
||||
onKeyDown: ({ event }) => {
|
||||
if ([...DROPDOWN_NAVIGATION_KEYS, "Escape"].includes(event.key)) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Extensions } from "@tiptap/core";
|
||||
import { CharacterCount } from "@tiptap/extension-character-count";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { CharacterCount } from "@tiptap/extensions";
|
||||
import { Underline } from "@tiptap/extension-underline";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
// extensions
|
||||
import {
|
||||
|
|
@ -75,6 +76,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
|||
ListKeymap({ tabIndex }),
|
||||
CustomLinkExtension,
|
||||
CustomTypographyExtension,
|
||||
Underline,
|
||||
TextStyle,
|
||||
TaskList.configure({
|
||||
HTMLAttributes: {
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ export const HeadingListExtension = Extension.create<unknown, HeadingExtensionSt
|
|||
this.editor.emit("update", {
|
||||
editor: this.editor,
|
||||
transaction: newState.tr,
|
||||
appendedTransactions: [],
|
||||
});
|
||||
|
||||
return null;
|
||||
|
|
@ -61,4 +60,8 @@ export const HeadingListExtension = Extension.create<unknown, HeadingExtensionSt
|
|||
|
||||
return [plugin];
|
||||
},
|
||||
|
||||
getHeadings() {
|
||||
return this.storage.headings;
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export const renderMentionsDropdown =
|
|||
component.updateProps(props);
|
||||
if (!props.clientRect) return;
|
||||
cleanup();
|
||||
cleanup = updateFloatingUIFloaterPosition(props.editor, component.element).cleanup;
|
||||
cleanup = updateFloatingUIFloaterPosition(props.editor, component.element as HTMLElement).cleanup;
|
||||
},
|
||||
onKeyDown: ({ event }) => {
|
||||
if ([...DROPDOWN_NAVIGATION_KEYS, "Escape"].includes(event.key)) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Placeholder } from "@tiptap/extensions";
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// types
|
||||
|
|
|
|||
|
|
@ -27,8 +27,6 @@ export const CustomStarterKitExtension = (args: TArgs) => {
|
|||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
blockquote: false,
|
||||
link: false,
|
||||
listKeymap: false,
|
||||
paragraph: {
|
||||
HTMLAttributes: {
|
||||
class: "editor-paragraph-block",
|
||||
|
|
@ -43,6 +41,6 @@ export const CustomStarterKitExtension = (args: TArgs) => {
|
|||
class:
|
||||
"text-custom-text-300 transition-all motion-reduce:transition-none motion-reduce:hover:transform-none duration-200 ease-[cubic-bezier(0.165, 0.84, 0.44, 1)]",
|
||||
},
|
||||
...(enableHistory ? {} : { undoRedo: false }),
|
||||
...(enableHistory ? {} : { history: false }),
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -94,11 +94,8 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
|
|||
?.chain()
|
||||
.setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true)
|
||||
.setMeta(CORE_EDITOR_META.INTENTIONAL_DELETION, true)
|
||||
.setContent(content, {
|
||||
emitUpdate,
|
||||
parseOptions: {
|
||||
preserveWhitespace: true,
|
||||
},
|
||||
.setContent(content, emitUpdate, {
|
||||
preserveWhitespace: true,
|
||||
})
|
||||
.run();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -93,11 +93,8 @@ export const useEditor = (props: TEditorHookProps) => {
|
|||
const { uploadInProgress: isUploadInProgress } = editor.storage.utility;
|
||||
if (!editor.isDestroyed && !isUploadInProgress) {
|
||||
try {
|
||||
editor.commands.setContent(value, {
|
||||
emitUpdate: false,
|
||||
parseOptions: {
|
||||
preserveWhitespace: true,
|
||||
},
|
||||
editor.commands.setContent(value, false, {
|
||||
preserveWhitespace: true,
|
||||
});
|
||||
if (editor.state.selection) {
|
||||
const docLength = editor.state.doc.content.size;
|
||||
|
|
|
|||
|
|
@ -57,7 +57,6 @@ export const TrackFileDeletionPlugin = (editor: Editor, deleteHandler: TFileHand
|
|||
const nodeFileSetDetails = NODE_FILE_MAP[nodeType];
|
||||
if (!nodeFileSetDetails || !src) return;
|
||||
try {
|
||||
// @ts-expect-error add proper types for storage
|
||||
editor.storage[nodeType]?.[nodeFileSetDetails.fileSetName]?.set(src, true);
|
||||
// update assets list storage value
|
||||
editor.commands.updateAssetsList?.({
|
||||
|
|
|
|||
|
|
@ -63,7 +63,6 @@ export const TrackFileRestorationPlugin = (editor: Editor, restoreHandler: TFile
|
|||
const src = node.attrs.src;
|
||||
const nodeFileSetDetails = NODE_FILE_MAP[nodeType];
|
||||
if (!nodeFileSetDetails) return;
|
||||
// @ts-expect-error add proper types for storage
|
||||
const extensionFileSetStorage = editor.storage[nodeType]?.[nodeFileSetDetails.fileSetName];
|
||||
const wasDeleted = extensionFileSetStorage?.get(src);
|
||||
if (!nodeFileSetDetails || !src) return;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ export const MarkdownClipboardPlugin = (editor: Editor): Plugin =>
|
|||
key: new PluginKey("markdownClipboard"),
|
||||
props: {
|
||||
clipboardTextSerializer: (slice) => {
|
||||
// @ts-expect-error tiptap-markdown types are not updated
|
||||
const markdownSerializer = editor.storage.markdown.serializer;
|
||||
const isTableRow = slice.content.firstChild?.type?.name === CORE_EXTENSIONS.TABLE_ROW;
|
||||
const nodeSelect = slice.openStart === 0 && slice.openEnd === 0;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,87 @@
|
|||
import { DocumentCollaborativeEvents } from "@/constants/document-collaborative-events";
|
||||
|
||||
export type TDocumentEventKey = keyof typeof DocumentCollaborativeEvents;
|
||||
export type TDocumentEventsClient = (typeof DocumentCollaborativeEvents)[TDocumentEventKey]["client"];
|
||||
export type TDocumentEventsServer = (typeof DocumentCollaborativeEvents)[TDocumentEventKey]["server"];
|
||||
// Base type for all action payloads
|
||||
export type BaseActionPayload = {
|
||||
user_id?: string;
|
||||
};
|
||||
|
||||
// Generic type for creating specific payloads
|
||||
export type CreatePayload<T = Record<string, never>> = BaseActionPayload & T;
|
||||
|
||||
export type TDocumentEventEmitter = {
|
||||
on: (event: string, callback: (message: { payload: TDocumentEventsClient }) => void) => void;
|
||||
off: (event: string, callback: (message: { payload: TDocumentEventsClient }) => void) => void;
|
||||
};
|
||||
|
||||
export type TDocumentEventKey = keyof typeof DocumentCollaborativeEvents;
|
||||
export type TDocumentEventsClient = (typeof DocumentCollaborativeEvents)[TDocumentEventKey]["client"];
|
||||
export type TDocumentEventsServer = (typeof DocumentCollaborativeEvents)[TDocumentEventKey]["server"];
|
||||
|
||||
// In this version, our union of all events (the client names) is:
|
||||
export type TAllEventTypes = TDocumentEventsClient;
|
||||
|
||||
// Create a mapping from each client event to its payload type using key remapping.
|
||||
export type EventToPayloadMap = {
|
||||
[K in keyof typeof DocumentCollaborativeEvents as (typeof DocumentCollaborativeEvents)[K]["client"]]: (typeof DocumentCollaborativeEvents)[K]["payloadType"];
|
||||
};
|
||||
|
||||
// Common fields for every realtime event
|
||||
export type CommonRealtimeFields = {
|
||||
affectedPages: {
|
||||
currentPage: string;
|
||||
parentPage: string | null;
|
||||
descendantPages: string[];
|
||||
};
|
||||
workspace_slug: string;
|
||||
project_id?: string;
|
||||
teamspace_id?: string;
|
||||
user_id: string;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
// Helper function to create a realtime event in a type‑safe way.
|
||||
export function createRealtimeEvent<T extends keyof EventToPayloadMap>(
|
||||
opts: ApiServerPayload<T>
|
||||
): CommonRealtimeFields & BroadcastedEvent<T> {
|
||||
return {
|
||||
affectedPages: {
|
||||
currentPage: opts.page_id || "",
|
||||
parentPage: opts.parent_id || null,
|
||||
descendantPages: opts.descendants_ids || [],
|
||||
},
|
||||
workspace_slug: opts.workspace_slug,
|
||||
project_id: opts.project_id || "",
|
||||
teamspace_id: opts.teamspace_id || "",
|
||||
user_id: opts.user_id,
|
||||
timestamp: new Date().toISOString(),
|
||||
action: opts.action,
|
||||
data: opts.data,
|
||||
};
|
||||
}
|
||||
|
||||
export type ApiServerPayload<T extends keyof EventToPayloadMap> = {
|
||||
action: T;
|
||||
descendants_ids: string[];
|
||||
page_id?: string;
|
||||
parent_id?: string;
|
||||
data: EventToPayloadMap[T];
|
||||
project_id?: string;
|
||||
teamspace_id?: string;
|
||||
workspace_slug: string;
|
||||
user_id: string;
|
||||
};
|
||||
|
||||
// Create a discriminated union for broadcast payloads.
|
||||
// For every key in EventToPayloadMap, we make a union member with the common fields.
|
||||
export type BroadcastPayloadUnion = {
|
||||
[K in keyof EventToPayloadMap]: ApiServerPayload<K>;
|
||||
}[keyof EventToPayloadMap];
|
||||
|
||||
export type BroadcastedEventUnion = {
|
||||
[K in keyof EventToPayloadMap]: BroadcastedEvent<K>;
|
||||
}[keyof EventToPayloadMap];
|
||||
|
||||
export type BroadcastedEvent<T extends keyof EventToPayloadMap = keyof EventToPayloadMap> = CommonRealtimeFields & {
|
||||
action: T;
|
||||
data: EventToPayloadMap[T];
|
||||
};
|
||||
|
|
|
|||
1364
pnpm-lock.yaml
generated
1364
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -29,8 +29,8 @@ catalog:
|
|||
tsdown: 0.15.5
|
||||
vite: 7.1.11
|
||||
uuid: 13.0.0
|
||||
"@tiptap/core": ^3.5.3
|
||||
"@tiptap/html": ^3.5.3
|
||||
"@tiptap/core": ^2.22.3
|
||||
"@tiptap/html": ^2.22.3
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- turbo
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue