ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: единый realtime слой Tasker
Добавлен NODE.DC realtime event stream для workspace/project/member/invite/profile событий. Обновлены frontend stores и live controller. Доработаны settings modal, member dropdown и confirm remove под NODE.DC UX.
This commit is contained in:
@@ -3,7 +3,7 @@ FROM node:22-alpine AS base
|
||||
|
||||
# Setup pnpm package manager with corepack and configure global bin directory for caching
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
# *****************************************************************************
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CollaborationController } from "./collaboration.controller";
|
||||
import { DocumentController } from "./document.controller";
|
||||
import { HealthController } from "./health.controller";
|
||||
import { IssueStreamController } from "./issue-stream.controller";
|
||||
import { NodeDCStreamController } from "./nodedc-stream.controller";
|
||||
import { PdfExportController } from "./pdf-export.controller";
|
||||
|
||||
export const CONTROLLERS = [
|
||||
@@ -15,5 +16,6 @@ export const CONTROLLERS = [
|
||||
DocumentController,
|
||||
HealthController,
|
||||
IssueStreamController,
|
||||
NodeDCStreamController,
|
||||
PdfExportController,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Request } from "express";
|
||||
import type Redis from "ioredis";
|
||||
import type { WebSocket as WSSocket } from "ws";
|
||||
// plane imports
|
||||
import { Controller, WebSocket as WSDecorator } from "@plane/decorators";
|
||||
import { logger } from "@plane/logger";
|
||||
// redis
|
||||
import { redisManager } from "@/redis";
|
||||
// services
|
||||
import { UserService } from "@/services/user.service";
|
||||
|
||||
const NODEDC_EVENT_CHANNEL_PREFIX = "plane:nodedc-events:user";
|
||||
const HEARTBEAT_INTERVAL_MS = 25_000;
|
||||
|
||||
const sendJson = (ws: WSSocket, payload: Record<string, unknown>) => {
|
||||
if (ws.readyState !== 1) return;
|
||||
ws.send(JSON.stringify(payload));
|
||||
};
|
||||
|
||||
@Controller("/nodedc")
|
||||
export class NodeDCStreamController {
|
||||
[key: string]: unknown;
|
||||
|
||||
@WSDecorator("/stream")
|
||||
handleConnection(ws: WSSocket, req: Request) {
|
||||
void this.handleNodeDCStream(ws, req);
|
||||
}
|
||||
|
||||
private async handleNodeDCStream(ws: WSSocket, req: Request) {
|
||||
const cookie = req.headers.cookie?.toString();
|
||||
|
||||
if (!cookie) {
|
||||
ws.close(1008, "Missing NODE.DC stream credentials");
|
||||
return;
|
||||
}
|
||||
|
||||
let subscriber: Redis | undefined;
|
||||
let heartbeat: NodeJS.Timeout | undefined;
|
||||
let isCleanedUp = false;
|
||||
|
||||
const cleanup = async () => {
|
||||
if (isCleanedUp) return;
|
||||
isCleanedUp = true;
|
||||
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
if (!subscriber) return;
|
||||
|
||||
try {
|
||||
await subscriber.unsubscribe();
|
||||
subscriber.disconnect();
|
||||
} catch (error) {
|
||||
logger.error("NODEDC_STREAM_CONTROLLER: Redis cleanup failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const userService = new UserService();
|
||||
const user = await userService.currentUser(cookie);
|
||||
const redisClient = redisManager.getClient();
|
||||
|
||||
if (!redisClient) {
|
||||
ws.close(1011, "NODE.DC stream unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = `${NODEDC_EVENT_CHANNEL_PREFIX}:${user.id}`;
|
||||
subscriber = redisClient.duplicate({ lazyConnect: true });
|
||||
await subscriber.connect();
|
||||
await subscriber.subscribe(channel);
|
||||
|
||||
subscriber.on("message", (_channel, message) => {
|
||||
try {
|
||||
const event = JSON.parse(message) as Record<string, unknown>;
|
||||
sendJson(ws, event);
|
||||
} catch (error) {
|
||||
logger.error("NODEDC_STREAM_CONTROLLER: Failed to forward event:", error);
|
||||
}
|
||||
});
|
||||
|
||||
subscriber.on("error", (error) => {
|
||||
logger.error("NODEDC_STREAM_CONTROLLER: Redis subscriber error:", error);
|
||||
ws.close(1011, "NODE.DC stream subscriber failed");
|
||||
});
|
||||
|
||||
heartbeat = setInterval(() => {
|
||||
sendJson(ws, { type: "nodedc.stream.ping", server_ts: new Date().toISOString() });
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
sendJson(ws, {
|
||||
type: "nodedc.stream.ready",
|
||||
user_id: user.id,
|
||||
server_ts: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("NODEDC_STREAM_CONTROLLER: WebSocket authentication failed:", error);
|
||||
ws.close(1008, "NODE.DC stream authentication failed");
|
||||
await cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
ws.on("message", (message) => {
|
||||
try {
|
||||
const payload = JSON.parse(message.toString()) as { type?: string };
|
||||
if (payload.type === "nodedc.stream.pong") return;
|
||||
} catch {
|
||||
// Client messages are optional for this stream.
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
void cleanup();
|
||||
});
|
||||
|
||||
ws.on("error", (error: Error) => {
|
||||
logger.error("NODEDC_STREAM_CONTROLLER: WebSocket connection error:", error);
|
||||
ws.close(1011, "NODE.DC stream connection failed");
|
||||
void cleanup();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user