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,150 @@
import { CollaborationService } from '@/collaboration/collaboration.service';
import type { Logger } from '@/Logger';
import type { User } from '@/databases/entities/User';
import type { UserService } from '@/services/user.service';
import { CollaborationState } from '@/collaboration/collaboration.state';
import type { Push } from '@/push';
import type {
WorkflowClosedMessage,
WorkflowOpenedMessage,
} from '@/collaboration/collaboration.message';
describe('CollaborationService', () => {
let collaborationService: CollaborationService;
let mockLogger: Logger;
let mockUserService: jest.Mocked<UserService>;
let state: CollaborationState;
let push: Push;
beforeEach(() => {
mockLogger = {
warn: jest.fn(),
error: jest.fn(),
} as unknown as jest.Mocked<Logger>;
mockUserService = {
getByIds: jest.fn(),
getManager: jest.fn(),
} as unknown as jest.Mocked<UserService>;
push = {
on: jest.fn(),
sendToUsers: jest.fn(),
} as unknown as Push;
state = new CollaborationState();
collaborationService = new CollaborationService(mockLogger, push, state, mockUserService);
});
describe('workflow opened message', () => {
const userId = 'test-user';
const workflowId = 'test-workflow';
const message: WorkflowOpenedMessage = {
type: 'workflowOpened',
workflowId,
};
const expectActiveUsersChangedMessage = (userIds: string[]) => {
expect(push.sendToUsers).toHaveBeenCalledWith(
'activeWorkflowUsersChanged',
{
workflowId,
activeUsers: [
{
user: { id: userId },
lastSeen: expect.any(Date),
},
],
},
[userId],
);
};
describe('user is not yet active', () => {
it('updates state correctly', async () => {
mockUserService.getByIds.mockResolvedValueOnce([{ id: userId } as User]);
await collaborationService.handleUserMessage(userId, message);
expect(state.getActiveWorkflowUsers(workflowId)).toEqual([
{
lastSeen: expect.any(Date),
userId,
},
]);
});
it('sends active workflow users changed message', async () => {
mockUserService.getByIds.mockResolvedValueOnce([{ id: userId } as User]);
await collaborationService.handleUserMessage(userId, message);
expectActiveUsersChangedMessage([userId]);
});
});
describe('user is already active', () => {
beforeEach(() => {
state.addActiveWorkflowUser(workflowId, userId);
});
it('updates state correctly', async () => {
mockUserService.getByIds.mockResolvedValueOnce([{ id: userId } as User]);
await collaborationService.handleUserMessage(userId, message);
expect(state.getActiveWorkflowUsers(workflowId)).toEqual([
{
lastSeen: expect.any(Date),
userId,
},
]);
});
it('sends active workflow users changed message', async () => {
mockUserService.getByIds.mockResolvedValueOnce([{ id: userId } as User]);
await collaborationService.handleUserMessage(userId, message);
expectActiveUsersChangedMessage([userId]);
});
});
});
describe('workflow closed message', () => {
const userId = 'test-user';
const workflowId = 'test-workflow';
const message: WorkflowClosedMessage = {
type: 'workflowClosed',
workflowId,
};
describe('user is active', () => {
beforeEach(() => {
state.addActiveWorkflowUser(workflowId, userId);
});
it('updates state correctly', async () => {
await collaborationService.handleUserMessage(userId, message);
expect(state.getActiveWorkflowUsers(workflowId)).toEqual([]);
});
it('does not send active workflow users changed message', async () => {
await collaborationService.handleUserMessage(userId, message);
expect(push.sendToUsers).not.toHaveBeenCalled();
});
});
describe('user is not active', () => {
it('updates state correctly', async () => {
await collaborationService.handleUserMessage(userId, message);
expect(state.getActiveWorkflowUsers(workflowId)).toEqual([]);
});
it('does not send active workflow users changed message', async () => {
await collaborationService.handleUserMessage(userId, message);
expect(push.sendToUsers).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -0,0 +1,161 @@
import Container from 'typedi';
import { EventEmitter } from 'events';
import type WebSocket from 'ws';
import { WebSocketPush } from '@/push/websocket.push';
import { Logger } from '@/Logger';
import type { User } from '@/databases/entities/User';
import type { PushDataExecutionRecovered } from '@/Interfaces';
import { mockInstance } from '../../integration/shared/utils';
jest.useFakeTimers();
class MockWebSocket extends EventEmitter {
public isAlive = true;
public ping = jest.fn();
public send = jest.fn();
public terminate = jest.fn();
public close = jest.fn();
}
const createMockWebSocket = () => new MockWebSocket() as unknown as jest.Mocked<WebSocket>;
describe('WebSocketPush', () => {
const sessionId1 = 'test-session1';
const sessionId2 = 'test-session2';
const userId: User['id'] = 'test-user';
mockInstance(Logger);
const webSocketPush = Container.get(WebSocketPush);
const mockWebSocket1 = createMockWebSocket();
const mockWebSocket2 = createMockWebSocket();
beforeEach(() => {
jest.resetAllMocks();
});
it('can add a connection', () => {
webSocketPush.add(sessionId1, userId, mockWebSocket1);
expect(mockWebSocket1.listenerCount('close')).toBe(1);
expect(mockWebSocket1.listenerCount('pong')).toBe(1);
expect(mockWebSocket1.listenerCount('message')).toBe(1);
});
it('closes a connection', () => {
webSocketPush.add(sessionId1, userId, mockWebSocket1);
mockWebSocket1.emit('close');
expect(mockWebSocket1.listenerCount('close')).toBe(0);
expect(mockWebSocket1.listenerCount('pong')).toBe(0);
expect(mockWebSocket1.listenerCount('message')).toBe(0);
});
it('sends data to one connection', () => {
webSocketPush.add(sessionId1, userId, mockWebSocket1);
webSocketPush.add(sessionId2, userId, mockWebSocket2);
const data: PushDataExecutionRecovered = {
type: 'executionRecovered',
data: {
executionId: 'test-execution-id',
},
};
webSocketPush.send('executionRecovered', data, sessionId1);
expect(mockWebSocket1.send).toHaveBeenCalledWith(
JSON.stringify({
type: 'executionRecovered',
data: {
type: 'executionRecovered',
data: {
executionId: 'test-execution-id',
},
},
}),
);
expect(mockWebSocket2.send).not.toHaveBeenCalled();
});
it('sends data to all connections', () => {
webSocketPush.add(sessionId1, userId, mockWebSocket1);
webSocketPush.add(sessionId2, userId, mockWebSocket2);
const data: PushDataExecutionRecovered = {
type: 'executionRecovered',
data: {
executionId: 'test-execution-id',
},
};
webSocketPush.broadcast('executionRecovered', data);
const expectedMsg = JSON.stringify({
type: 'executionRecovered',
data: {
type: 'executionRecovered',
data: {
executionId: 'test-execution-id',
},
},
});
expect(mockWebSocket1.send).toHaveBeenCalledWith(expectedMsg);
expect(mockWebSocket2.send).toHaveBeenCalledWith(expectedMsg);
});
it('sends data to all users connections', () => {
webSocketPush.add(sessionId1, userId, mockWebSocket1);
webSocketPush.add(sessionId2, userId, mockWebSocket2);
const data: PushDataExecutionRecovered = {
type: 'executionRecovered',
data: {
executionId: 'test-execution-id',
},
};
webSocketPush.sendToUsers('executionRecovered', data, [userId]);
const expectedMsg = JSON.stringify({
type: 'executionRecovered',
data: {
type: 'executionRecovered',
data: {
executionId: 'test-execution-id',
},
},
});
expect(mockWebSocket1.send).toHaveBeenCalledWith(expectedMsg);
expect(mockWebSocket2.send).toHaveBeenCalledWith(expectedMsg);
});
it('pings all connections', () => {
webSocketPush.add(sessionId1, userId, mockWebSocket1);
webSocketPush.add(sessionId2, userId, mockWebSocket2);
jest.runOnlyPendingTimers();
expect(mockWebSocket1.ping).toHaveBeenCalled();
expect(mockWebSocket2.ping).toHaveBeenCalled();
});
it('emits message event when connection receives data', () => {
const mockOnMessageReceived = jest.fn();
webSocketPush.on('message', mockOnMessageReceived);
webSocketPush.add(sessionId1, userId, mockWebSocket1);
webSocketPush.add(sessionId2, userId, mockWebSocket2);
const data = { test: 'data' };
const buffer = Buffer.from(JSON.stringify(data));
mockWebSocket1.emit('message', buffer);
expect(mockOnMessageReceived).toHaveBeenCalledWith({
msg: data,
sessionId: sessionId1,
userId,
});
});
});