ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: realtime канал карточек задач
This commit is contained in:
@@ -7,6 +7,13 @@
|
||||
import { CollaborationController } from "./collaboration.controller";
|
||||
import { DocumentController } from "./document.controller";
|
||||
import { HealthController } from "./health.controller";
|
||||
import { IssueStreamController } from "./issue-stream.controller";
|
||||
import { PdfExportController } from "./pdf-export.controller";
|
||||
|
||||
export const CONTROLLERS = [CollaborationController, DocumentController, HealthController, PdfExportController];
|
||||
export const CONTROLLERS = [
|
||||
CollaborationController,
|
||||
DocumentController,
|
||||
HealthController,
|
||||
IssueStreamController,
|
||||
PdfExportController,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 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 { ProjectMemberService } from "@/services/project-member.service";
|
||||
import { UserService } from "@/services/user.service";
|
||||
|
||||
const ISSUE_EVENT_CHANNEL_PREFIX = "plane:issue-events:project";
|
||||
const HEARTBEAT_INTERVAL_MS = 25_000;
|
||||
|
||||
type TIssueRealtimeEvent = {
|
||||
event_id?: string;
|
||||
type?: string;
|
||||
project_id?: string;
|
||||
};
|
||||
|
||||
const getQueryValue = (value: unknown) => (typeof value === "string" && value.trim() ? value.trim() : undefined);
|
||||
|
||||
const sendJson = (ws: WSSocket, payload: Record<string, unknown>) => {
|
||||
if (ws.readyState !== 1) return;
|
||||
ws.send(JSON.stringify(payload));
|
||||
};
|
||||
|
||||
@Controller("/issues")
|
||||
export class IssueStreamController {
|
||||
[key: string]: unknown;
|
||||
|
||||
@WSDecorator("/stream")
|
||||
handleConnection(ws: WSSocket, req: Request) {
|
||||
void this.handleIssueStream(ws, req);
|
||||
}
|
||||
|
||||
private async handleIssueStream(ws: WSSocket, req: Request) {
|
||||
const workspaceSlug = getQueryValue(req.query.workspaceSlug);
|
||||
const projectId = getQueryValue(req.query.projectId);
|
||||
const cookie = req.headers.cookie?.toString();
|
||||
|
||||
if (!workspaceSlug || !projectId || !cookie) {
|
||||
ws.close(1008, "Missing issue stream credentials");
|
||||
return;
|
||||
}
|
||||
|
||||
let subscriber: Redis | undefined;
|
||||
let heartbeat: NodeJS.Timeout | undefined;
|
||||
|
||||
const cleanup = async () => {
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
|
||||
if (subscriber) {
|
||||
try {
|
||||
await subscriber.unsubscribe();
|
||||
subscriber.disconnect();
|
||||
} catch (error) {
|
||||
logger.error("ISSUE_STREAM_CONTROLLER: Redis cleanup failed:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const userService = new UserService();
|
||||
const projectMemberService = new ProjectMemberService();
|
||||
const user = await userService.currentUser(cookie);
|
||||
|
||||
await projectMemberService.currentProjectMember(cookie, workspaceSlug, projectId);
|
||||
|
||||
const redisClient = redisManager.getClient();
|
||||
if (!redisClient) {
|
||||
ws.close(1011, "Issue stream unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = `${ISSUE_EVENT_CHANNEL_PREFIX}:${projectId}`;
|
||||
subscriber = redisClient.duplicate({ lazyConnect: true });
|
||||
await subscriber.connect();
|
||||
await subscriber.subscribe(channel);
|
||||
|
||||
subscriber.on("message", (_channel, message) => {
|
||||
try {
|
||||
const event = JSON.parse(message) as TIssueRealtimeEvent;
|
||||
if (event.project_id !== projectId || !event.type?.startsWith("issue.")) return;
|
||||
|
||||
sendJson(ws, event as Record<string, unknown>);
|
||||
} catch (error) {
|
||||
logger.error("ISSUE_STREAM_CONTROLLER: Failed to forward issue event:", error);
|
||||
}
|
||||
});
|
||||
|
||||
subscriber.on("error", (error) => {
|
||||
logger.error("ISSUE_STREAM_CONTROLLER: Redis subscriber error:", error);
|
||||
ws.close(1011, "Issue stream subscriber failed");
|
||||
});
|
||||
|
||||
heartbeat = setInterval(() => {
|
||||
sendJson(ws, { type: "issue.stream.ping", server_ts: new Date().toISOString() });
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
sendJson(ws, {
|
||||
type: "issue.stream.ready",
|
||||
project_id: projectId,
|
||||
user_id: user.id,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("ISSUE_STREAM_CONTROLLER: WebSocket authentication failed:", error);
|
||||
ws.close(1008, "Issue stream authentication failed");
|
||||
await cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
ws.on("message", (message) => {
|
||||
try {
|
||||
const payload = JSON.parse(message.toString()) as { type?: string };
|
||||
if (payload.type === "issue.stream.pong") return;
|
||||
} catch {
|
||||
// Client messages are optional for this stream.
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
void cleanup();
|
||||
});
|
||||
|
||||
ws.on("error", (error: Error) => {
|
||||
logger.error("ISSUE_STREAM_CONTROLLER: WebSocket connection error:", error);
|
||||
ws.close(1011, "Issue stream connection failed");
|
||||
void cleanup();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// services
|
||||
import { APIService } from "@/services/api.service";
|
||||
|
||||
export class ProjectMemberService extends APIService {
|
||||
async currentProjectMember(cookie: string, workspaceSlug: string, projectId: string) {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/project-members/me/`, {
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
},
|
||||
}).then((response) => response?.data);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user