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:
Aaryan Khandelwal 2025-10-21 18:28:16 +05:30 committed by GitHub
parent 59022b6beb
commit 64781be7d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 2123 additions and 824 deletions

View file

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

View file

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

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

View file

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

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

View file

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

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

View file

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

View file

@ -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) {

View file

@ -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) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

View file

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

View file

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

View file

@ -130,7 +130,6 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
pageTitle={name ?? ""}
/>
<PageActions
editorRef={editorRef}
extraOptions={EXTRA_MENU_OPTIONS}
optionsOrder={[
"full-screen",

View file

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

View file

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

View file

@ -25,7 +25,6 @@ export type TPageOperations = {
};
type Props = {
editorRef?: EditorRefApi | null;
page: TPageInstance;
};

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

View file

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

View file

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

View file

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

View file

@ -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(() => {

View file

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

View file

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

View file

@ -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: {

View file

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

View file

@ -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)) {

View file

@ -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: {

View file

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

View file

@ -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)) {

View file

@ -1,4 +1,4 @@
import { Placeholder } from "@tiptap/extensions";
import { Placeholder } from "@tiptap/extension-placeholder";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// types

View file

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

View file

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

View file

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

View file

@ -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?.({

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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