feat(core): Initial support for two-way communication over websockets (#7570)
- Enable two-way communication with web sockets - Enable sending push messages to specific users - Add collaboration service for managing active users for workflow Missing things: - State is currently kept only in memory, making this not work in multi-master setups - Removing a user from active users in situations where they go inactive or we miss the "workflow closed" message - I think a timer based solution for this would cover most edge cases. I.e. have FE ping every X minutes, BE removes the user unless they have received a ping in Y minutes, where Y > X - FE changes to be added later by @MiloradFilipovic Github issue / Community forum post (link here to close automatically): --------- Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
23
packages/cli/src/collaboration/collaboration.message.ts
Normal file
23
packages/cli/src/collaboration/collaboration.message.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export type CollaborationMessage = WorkflowOpenedMessage | WorkflowClosedMessage;
|
||||
|
||||
export type WorkflowOpenedMessage = {
|
||||
type: 'workflowOpened';
|
||||
workflowId: string;
|
||||
};
|
||||
|
||||
export type WorkflowClosedMessage = {
|
||||
type: 'workflowClosed';
|
||||
workflowId: string;
|
||||
};
|
||||
|
||||
const isWorkflowMessage = (msg: unknown): msg is CollaborationMessage => {
|
||||
return typeof msg === 'object' && msg !== null && 'type' in msg;
|
||||
};
|
||||
|
||||
export const isWorkflowOpenedMessage = (msg: unknown): msg is WorkflowOpenedMessage => {
|
||||
return isWorkflowMessage(msg) && msg.type === 'workflowOpened';
|
||||
};
|
||||
|
||||
export const isWorkflowClosedMessage = (msg: unknown): msg is WorkflowClosedMessage => {
|
||||
return isWorkflowMessage(msg) && msg.type === 'workflowClosed';
|
||||
};
|
||||
87
packages/cli/src/collaboration/collaboration.service.ts
Normal file
87
packages/cli/src/collaboration/collaboration.service.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Workflow } from 'n8n-workflow';
|
||||
import { Service } from 'typedi';
|
||||
import { Push } from '../push';
|
||||
import { Logger } from '@/Logger';
|
||||
import type { WorkflowClosedMessage, WorkflowOpenedMessage } from './collaboration.message';
|
||||
import { isWorkflowClosedMessage, isWorkflowOpenedMessage } from './collaboration.message';
|
||||
import { UserService } from '../services/user.service';
|
||||
import type { IActiveWorkflowUsersChanged } from '../Interfaces';
|
||||
import type { OnPushMessageEvent } from '@/push/types';
|
||||
import { CollaborationState } from '@/collaboration/collaboration.state';
|
||||
|
||||
/**
|
||||
* Service for managing collaboration feature between users. E.g. keeping
|
||||
* track of active users for a workflow.
|
||||
*/
|
||||
@Service()
|
||||
export class CollaborationService {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly push: Push,
|
||||
private readonly state: CollaborationState,
|
||||
private readonly userService: UserService,
|
||||
) {
|
||||
if (!push.isBidirectional) {
|
||||
logger.warn(
|
||||
'Collaboration features are disabled because push is configured unidirectional. Use N8N_PUSH_BACKEND=websocket environment variable to enable them.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.push.on('message', async (event: OnPushMessageEvent) => {
|
||||
try {
|
||||
await this.handleUserMessage(event.userId, event.msg);
|
||||
} catch (error) {
|
||||
this.logger.error('Error handling user message', {
|
||||
error: error as unknown,
|
||||
msg: event.msg,
|
||||
userId: event.userId,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async handleUserMessage(userId: string, msg: unknown) {
|
||||
if (isWorkflowOpenedMessage(msg)) {
|
||||
await this.handleWorkflowOpened(userId, msg);
|
||||
} else if (isWorkflowClosedMessage(msg)) {
|
||||
await this.handleWorkflowClosed(userId, msg);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWorkflowOpened(userId: string, msg: WorkflowOpenedMessage) {
|
||||
const { workflowId } = msg;
|
||||
|
||||
this.state.addActiveWorkflowUser(workflowId, userId);
|
||||
|
||||
await this.sendWorkflowUsersChangedMessage(workflowId);
|
||||
}
|
||||
|
||||
private async handleWorkflowClosed(userId: string, msg: WorkflowClosedMessage) {
|
||||
const { workflowId } = msg;
|
||||
|
||||
this.state.removeActiveWorkflowUser(workflowId, userId);
|
||||
|
||||
await this.sendWorkflowUsersChangedMessage(workflowId);
|
||||
}
|
||||
|
||||
private async sendWorkflowUsersChangedMessage(workflowId: Workflow['id']) {
|
||||
const activeWorkflowUsers = this.state.getActiveWorkflowUsers(workflowId);
|
||||
const workflowUserIds = activeWorkflowUsers.map((user) => user.userId);
|
||||
|
||||
if (workflowUserIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const users = await this.userService.getByIds(this.userService.getManager(), workflowUserIds);
|
||||
|
||||
const msgData: IActiveWorkflowUsersChanged = {
|
||||
workflowId,
|
||||
activeUsers: users.map((user) => ({
|
||||
user,
|
||||
lastSeen: activeWorkflowUsers.find((activeUser) => activeUser.userId === user.id)!.lastSeen,
|
||||
})),
|
||||
};
|
||||
|
||||
this.push.sendToUsers('activeWorkflowUsersChanged', msgData, workflowUserIds);
|
||||
}
|
||||
}
|
||||
62
packages/cli/src/collaboration/collaboration.state.ts
Normal file
62
packages/cli/src/collaboration/collaboration.state.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { User } from '@/databases/entities/User';
|
||||
import type { Workflow } from 'n8n-workflow';
|
||||
import { Service } from 'typedi';
|
||||
|
||||
type ActiveWorkflowUser = {
|
||||
userId: User['id'];
|
||||
lastSeen: Date;
|
||||
};
|
||||
|
||||
type UserStateByUserId = Map<User['id'], ActiveWorkflowUser>;
|
||||
|
||||
type State = {
|
||||
activeUsersByWorkflowId: Map<Workflow['id'], UserStateByUserId>;
|
||||
};
|
||||
|
||||
/**
|
||||
* State management for the collaboration service
|
||||
*/
|
||||
@Service()
|
||||
export class CollaborationState {
|
||||
private state: State = {
|
||||
activeUsersByWorkflowId: new Map(),
|
||||
};
|
||||
|
||||
addActiveWorkflowUser(workflowId: Workflow['id'], userId: User['id']) {
|
||||
const { activeUsersByWorkflowId } = this.state;
|
||||
|
||||
let activeUsers = activeUsersByWorkflowId.get(workflowId);
|
||||
if (!activeUsers) {
|
||||
activeUsers = new Map();
|
||||
activeUsersByWorkflowId.set(workflowId, activeUsers);
|
||||
}
|
||||
|
||||
activeUsers.set(userId, {
|
||||
userId,
|
||||
lastSeen: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
removeActiveWorkflowUser(workflowId: Workflow['id'], userId: User['id']) {
|
||||
const { activeUsersByWorkflowId } = this.state;
|
||||
|
||||
const activeUsers = activeUsersByWorkflowId.get(workflowId);
|
||||
if (!activeUsers) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeUsers.delete(userId);
|
||||
if (activeUsers.size === 0) {
|
||||
activeUsersByWorkflowId.delete(workflowId);
|
||||
}
|
||||
}
|
||||
|
||||
getActiveWorkflowUsers(workflowId: Workflow['id']): ActiveWorkflowUser[] {
|
||||
const workflowState = this.state.activeUsersByWorkflowId.get(workflowId);
|
||||
if (!workflowState) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...workflowState.values()];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user