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:
Tomi Turtiainen
2023-11-07 17:26:45 +02:00
committed by GitHub
parent a3a26109c6
commit ac877014ed
14 changed files with 641 additions and 45 deletions

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

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

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