mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
fix(editor): Fix issues with push connect reconnection (#13085)
This commit is contained in:
parent
e59d9830bf
commit
fff98b16bb
|
@ -889,7 +889,6 @@ export interface RootState {
|
||||||
endpointWebhook: string;
|
endpointWebhook: string;
|
||||||
endpointWebhookTest: string;
|
endpointWebhookTest: string;
|
||||||
endpointWebhookWaiting: string;
|
endpointWebhookWaiting: string;
|
||||||
pushConnectionActive: boolean;
|
|
||||||
timezone: string;
|
timezone: string;
|
||||||
executionTimeout: number;
|
executionTimeout: number;
|
||||||
maxExecutionTimeout: number;
|
maxExecutionTimeout: number;
|
||||||
|
|
|
@ -38,6 +38,13 @@ vi.mock('@/composables/useToast', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/pushConnection.store', () => ({
|
||||||
|
usePushConnectionStore: vi.fn().mockReturnValue({
|
||||||
|
isConnected: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
// Test data
|
// Test data
|
||||||
const mockNodes: INodeUi[] = [
|
const mockNodes: INodeUi[] = [
|
||||||
{
|
{
|
||||||
|
|
|
@ -11,6 +11,12 @@ vi.mock('vue-router', () => ({
|
||||||
RouterLink: vi.fn(),
|
RouterLink: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/pushConnection.store', () => ({
|
||||||
|
usePushConnectionStore: vi.fn().mockReturnValue({
|
||||||
|
isConnected: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
[STORES.SETTINGS]: {
|
[STORES.SETTINGS]: {
|
||||||
settings: {
|
settings: {
|
||||||
|
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import PushConnectionTracker from '@/components/PushConnectionTracker.vue';
|
||||||
|
import { STORES } from '@/constants';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { setActivePinia } from 'pinia';
|
||||||
|
|
||||||
|
let isConnected = true;
|
||||||
|
let isConnectionRequested = true;
|
||||||
|
|
||||||
|
vi.mock('@/stores/pushConnection.store', () => {
|
||||||
|
return {
|
||||||
|
usePushConnectionStore: vi.fn(() => ({
|
||||||
|
isConnected,
|
||||||
|
isConnectionRequested,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PushConnectionTracker', () => {
|
||||||
|
const render = () => {
|
||||||
|
const pinia = createTestingPinia({
|
||||||
|
stubActions: false,
|
||||||
|
initialState: {
|
||||||
|
[STORES.PUSH]: {
|
||||||
|
isConnected,
|
||||||
|
isConnectionRequested,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setActivePinia(pinia);
|
||||||
|
|
||||||
|
return createComponentRenderer(PushConnectionTracker)();
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should not render error when connected and connection requested', () => {
|
||||||
|
isConnected = true;
|
||||||
|
isConnectionRequested = true;
|
||||||
|
const { container } = render();
|
||||||
|
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render error when disconnected and connection requested', () => {
|
||||||
|
isConnected = false;
|
||||||
|
isConnectionRequested = true;
|
||||||
|
const { container } = render();
|
||||||
|
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render error when connected and connection not requested', () => {
|
||||||
|
isConnected = true;
|
||||||
|
isConnectionRequested = false;
|
||||||
|
const { container } = render();
|
||||||
|
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,16 +1,23 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||||
import { useRootStore } from '@/stores/root.store';
|
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
const rootStore = useRootStore();
|
const pushConnectionStore = usePushConnectionStore();
|
||||||
const pushConnectionActive = computed(() => rootStore.pushConnectionActive);
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
const showConnectionLostError = computed(() => {
|
||||||
|
// Only show the connection lost error if the connection has been requested
|
||||||
|
// and the connection is not currently connected. This is to prevent the
|
||||||
|
// connection error from being shown e.g. when user navigates directly to
|
||||||
|
// the workflow executions list, which doesn't open the connection.
|
||||||
|
return pushConnectionStore.isConnectionRequested && !pushConnectionStore.isConnected;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span>
|
<span>
|
||||||
<div v-if="!pushConnectionActive" class="push-connection-lost primary-color">
|
<div v-if="showConnectionLostError" class="push-connection-lost primary-color">
|
||||||
<n8n-tooltip placement="bottom-end">
|
<n8n-tooltip placement="bottom-end">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div v-n8n-html="i18n.baseText('pushConnectionTracker.cannotConnectToServer')"></div>
|
<div v-n8n-html="i18n.baseText('pushConnectionTracker.cannotConnectToServer')"></div>
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`PushConnectionTracker > should not render error when connected and connection not requested 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`PushConnectionTracker > should not render error when connected and connection requested 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`PushConnectionTracker > should render error when disconnected and connection requested 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
<div
|
||||||
|
class="push-connection-lost primary-color"
|
||||||
|
>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="el-tooltip__trigger"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="svg-inline--fa fa-exclamation-triangle fa-w-18"
|
||||||
|
data-icon="exclamation-triangle"
|
||||||
|
data-prefix="fas"
|
||||||
|
focusable="false"
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 576 512"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
class=""
|
||||||
|
d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Connection lost
|
||||||
|
</span>
|
||||||
|
<!--teleport start-->
|
||||||
|
<!--teleport end-->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
|
@ -11,7 +11,6 @@ import {
|
||||||
type ITaskData,
|
type ITaskData,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import { useRootStore } from '@/stores/root.store';
|
|
||||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||||
import type { IStartRunData, IWorkflowData } from '@/Interface';
|
import type { IStartRunData, IWorkflowData } from '@/Interface';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
@ -21,6 +20,7 @@ import { useToast } from './useToast';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { captor, mock } from 'vitest-mock-extended';
|
import { captor, mock } from 'vitest-mock-extended';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||||
|
|
||||||
vi.mock('@/stores/workflows.store', () => ({
|
vi.mock('@/stores/workflows.store', () => ({
|
||||||
useWorkflowsStore: vi.fn().mockReturnValue({
|
useWorkflowsStore: vi.fn().mockReturnValue({
|
||||||
|
@ -40,6 +40,12 @@ vi.mock('@/stores/workflows.store', () => ({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/pushConnection.store', () => ({
|
||||||
|
usePushConnectionStore: vi.fn().mockReturnValue({
|
||||||
|
isConnected: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@/composables/useTelemetry', () => ({
|
vi.mock('@/composables/useTelemetry', () => ({
|
||||||
useTelemetry: vi.fn().mockReturnValue({ track: vi.fn() }),
|
useTelemetry: vi.fn().mockReturnValue({ track: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
|
@ -90,7 +96,7 @@ vi.mock('vue-router', async (importOriginal) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('useRunWorkflow({ router })', () => {
|
describe('useRunWorkflow({ router })', () => {
|
||||||
let rootStore: ReturnType<typeof useRootStore>;
|
let pushConnectionStore: ReturnType<typeof usePushConnectionStore>;
|
||||||
let uiStore: ReturnType<typeof useUIStore>;
|
let uiStore: ReturnType<typeof useUIStore>;
|
||||||
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
||||||
let router: ReturnType<typeof useRouter>;
|
let router: ReturnType<typeof useRouter>;
|
||||||
|
@ -102,7 +108,7 @@ describe('useRunWorkflow({ router })', () => {
|
||||||
|
|
||||||
setActivePinia(pinia);
|
setActivePinia(pinia);
|
||||||
|
|
||||||
rootStore = useRootStore();
|
pushConnectionStore = usePushConnectionStore();
|
||||||
uiStore = useUIStore();
|
uiStore = useUIStore();
|
||||||
workflowsStore = useWorkflowsStore();
|
workflowsStore = useWorkflowsStore();
|
||||||
settingsStore = useSettingsStore();
|
settingsStore = useSettingsStore();
|
||||||
|
@ -120,7 +126,7 @@ describe('useRunWorkflow({ router })', () => {
|
||||||
it('should throw an error if push connection is not active', async () => {
|
it('should throw an error if push connection is not active', async () => {
|
||||||
const { runWorkflowApi } = useRunWorkflow({ router });
|
const { runWorkflowApi } = useRunWorkflow({ router });
|
||||||
|
|
||||||
rootStore.setPushConnectionInactive();
|
vi.mocked(pushConnectionStore).isConnected = false;
|
||||||
|
|
||||||
await expect(runWorkflowApi({} as IStartRunData)).rejects.toThrow(
|
await expect(runWorkflowApi({} as IStartRunData)).rejects.toThrow(
|
||||||
'workflowRun.noActiveConnectionToTheServer',
|
'workflowRun.noActiveConnectionToTheServer',
|
||||||
|
@ -130,7 +136,7 @@ describe('useRunWorkflow({ router })', () => {
|
||||||
it('should successfully run a workflow', async () => {
|
it('should successfully run a workflow', async () => {
|
||||||
const { runWorkflowApi } = useRunWorkflow({ router });
|
const { runWorkflowApi } = useRunWorkflow({ router });
|
||||||
|
|
||||||
rootStore.setPushConnectionActive();
|
vi.mocked(pushConnectionStore).isConnected = true;
|
||||||
|
|
||||||
const mockResponse = { executionId: '123', waitingForWebhook: false };
|
const mockResponse = { executionId: '123', waitingForWebhook: false };
|
||||||
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockResponse);
|
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockResponse);
|
||||||
|
@ -161,7 +167,7 @@ describe('useRunWorkflow({ router })', () => {
|
||||||
it('should handle workflow run failure', async () => {
|
it('should handle workflow run failure', async () => {
|
||||||
const { runWorkflowApi } = useRunWorkflow({ router });
|
const { runWorkflowApi } = useRunWorkflow({ router });
|
||||||
|
|
||||||
rootStore.setPushConnectionActive();
|
vi.mocked(pushConnectionStore).isConnected = true;
|
||||||
vi.mocked(workflowsStore).runWorkflow.mockRejectedValue(new Error('Failed to run workflow'));
|
vi.mocked(workflowsStore).runWorkflow.mockRejectedValue(new Error('Failed to run workflow'));
|
||||||
|
|
||||||
await expect(runWorkflowApi({} as IStartRunData)).rejects.toThrow('Failed to run workflow');
|
await expect(runWorkflowApi({} as IStartRunData)).rejects.toThrow('Failed to run workflow');
|
||||||
|
@ -171,7 +177,7 @@ describe('useRunWorkflow({ router })', () => {
|
||||||
it('should set waitingForWebhook if response indicates waiting', async () => {
|
it('should set waitingForWebhook if response indicates waiting', async () => {
|
||||||
const { runWorkflowApi } = useRunWorkflow({ router });
|
const { runWorkflowApi } = useRunWorkflow({ router });
|
||||||
|
|
||||||
rootStore.setPushConnectionActive();
|
vi.mocked(pushConnectionStore).isConnected = true;
|
||||||
const mockResponse = { executionId: '123', waitingForWebhook: true };
|
const mockResponse = { executionId: '123', waitingForWebhook: true };
|
||||||
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockResponse);
|
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
@ -210,6 +216,7 @@ describe('useRunWorkflow({ router })', () => {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should execute workflow has pin data and is active with single webhook trigger', async () => {
|
it('should execute workflow has pin data and is active with single webhook trigger', async () => {
|
||||||
const pinia = createTestingPinia({ stubActions: false });
|
const pinia = createTestingPinia({ stubActions: false });
|
||||||
setActivePinia(pinia);
|
setActivePinia(pinia);
|
||||||
|
@ -295,7 +302,7 @@ describe('useRunWorkflow({ router })', () => {
|
||||||
const mockExecutionResponse = { executionId: '123' };
|
const mockExecutionResponse = { executionId: '123' };
|
||||||
const { runWorkflow } = useRunWorkflow({ router });
|
const { runWorkflow } = useRunWorkflow({ router });
|
||||||
|
|
||||||
vi.mocked(rootStore).pushConnectionActive = true;
|
vi.mocked(pushConnectionStore).isConnected = true;
|
||||||
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
|
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
|
||||||
vi.mocked(workflowsStore).nodesIssuesExist = false;
|
vi.mocked(workflowsStore).nodesIssuesExist = false;
|
||||||
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue({
|
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue({
|
||||||
|
@ -405,7 +412,7 @@ describe('useRunWorkflow({ router })', () => {
|
||||||
workflow.getParentNodes.mockReturnValue([]);
|
workflow.getParentNodes.mockReturnValue([]);
|
||||||
|
|
||||||
vi.mocked(settingsStore).partialExecutionVersion = 1;
|
vi.mocked(settingsStore).partialExecutionVersion = 1;
|
||||||
vi.mocked(rootStore).pushConnectionActive = true;
|
vi.mocked(pushConnectionStore).isConnected = true;
|
||||||
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
|
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
|
||||||
vi.mocked(workflowsStore).nodesIssuesExist = false;
|
vi.mocked(workflowsStore).nodesIssuesExist = false;
|
||||||
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue(workflow);
|
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue(workflow);
|
||||||
|
@ -436,7 +443,7 @@ describe('useRunWorkflow({ router })', () => {
|
||||||
workflow.getParentNodes.mockReturnValue([]);
|
workflow.getParentNodes.mockReturnValue([]);
|
||||||
|
|
||||||
vi.mocked(settingsStore).partialExecutionVersion = 2;
|
vi.mocked(settingsStore).partialExecutionVersion = 2;
|
||||||
vi.mocked(rootStore).pushConnectionActive = true;
|
vi.mocked(pushConnectionStore).isConnected = true;
|
||||||
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
|
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
|
||||||
vi.mocked(workflowsStore).nodesIssuesExist = false;
|
vi.mocked(workflowsStore).nodesIssuesExist = false;
|
||||||
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue(workflow);
|
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue(workflow);
|
||||||
|
@ -465,7 +472,7 @@ describe('useRunWorkflow({ router })', () => {
|
||||||
workflow.getParentNodes.mockReturnValue([]);
|
workflow.getParentNodes.mockReturnValue([]);
|
||||||
|
|
||||||
vi.mocked(settingsStore).partialExecutionVersion = 2;
|
vi.mocked(settingsStore).partialExecutionVersion = 2;
|
||||||
vi.mocked(rootStore).pushConnectionActive = true;
|
vi.mocked(pushConnectionStore).isConnected = true;
|
||||||
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
|
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
|
||||||
vi.mocked(workflowsStore).nodesIssuesExist = false;
|
vi.mocked(workflowsStore).nodesIssuesExist = false;
|
||||||
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue(workflow);
|
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue(workflow);
|
||||||
|
|
|
@ -36,6 +36,7 @@ import { useI18n } from '@/composables/useI18n';
|
||||||
import { get } from 'lodash-es';
|
import { get } from 'lodash-es';
|
||||||
import { useExecutionsStore } from '@/stores/executions.store';
|
import { useExecutionsStore } from '@/stores/executions.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||||
|
|
||||||
const getDirtyNodeNames = (
|
const getDirtyNodeNames = (
|
||||||
runData: IRunData,
|
runData: IRunData,
|
||||||
|
@ -63,12 +64,13 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
|
const pushConnectionStore = usePushConnectionStore();
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const executionsStore = useExecutionsStore();
|
const executionsStore = useExecutionsStore();
|
||||||
// Starts to execute a workflow on server
|
// Starts to execute a workflow on server
|
||||||
async function runWorkflowApi(runData: IStartRunData): Promise<IExecutionPushResponse> {
|
async function runWorkflowApi(runData: IStartRunData): Promise<IExecutionPushResponse> {
|
||||||
if (!rootStore.pushConnectionActive) {
|
if (!pushConnectionStore.isConnected) {
|
||||||
// Do not start if the connection to server is not active
|
// Do not start if the connection to server is not active
|
||||||
// because then it can not receive the data as it executes.
|
// because then it can not receive the data as it executes.
|
||||||
throw new Error(i18n.baseText('workflowRun.noActiveConnectionToTheServer'));
|
throw new Error(i18n.baseText('workflowRun.noActiveConnectionToTheServer'));
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
/** Mocked EventSource class to help testing */
|
||||||
|
export class MockEventSource extends EventTarget {
|
||||||
|
constructor(public url: string) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
simulateConnectionOpen() {
|
||||||
|
this.dispatchEvent(new Event('open'));
|
||||||
|
}
|
||||||
|
|
||||||
|
simulateConnectionClose() {
|
||||||
|
this.dispatchEvent(new Event('close'));
|
||||||
|
}
|
||||||
|
|
||||||
|
simulateMessageEvent(data: string) {
|
||||||
|
this.dispatchEvent(new MessageEvent('message', { data }));
|
||||||
|
}
|
||||||
|
|
||||||
|
close = vi.fn();
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { WebSocketState } from '@/push-connection/useWebSocketClient';
|
||||||
|
|
||||||
|
/** Mocked WebSocket class to help testing */
|
||||||
|
export class MockWebSocket extends EventTarget {
|
||||||
|
readyState: number = WebSocketState.CONNECTING;
|
||||||
|
|
||||||
|
constructor(public url: string) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
simulateConnectionOpen() {
|
||||||
|
this.dispatchEvent(new Event('open'));
|
||||||
|
this.readyState = WebSocketState.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
simulateConnectionClose(code: number) {
|
||||||
|
this.dispatchEvent(new CloseEvent('close', { code }));
|
||||||
|
this.readyState = WebSocketState.CLOSED;
|
||||||
|
}
|
||||||
|
|
||||||
|
simulateMessageEvent(data: string) {
|
||||||
|
this.dispatchEvent(new MessageEvent('message', { data }));
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchErrorEvent() {
|
||||||
|
this.dispatchEvent(new Event('error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
send = vi.fn();
|
||||||
|
|
||||||
|
close = vi.fn();
|
||||||
|
}
|
|
@ -0,0 +1,153 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { useEventSourceClient } from '../useEventSourceClient';
|
||||||
|
import { MockEventSource } from './mockEventSource';
|
||||||
|
|
||||||
|
describe('useEventSourceClient', () => {
|
||||||
|
let mockEventSource: MockEventSource;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockEventSource = new MockEventSource('http://test.com');
|
||||||
|
|
||||||
|
// @ts-expect-error - mock EventSource
|
||||||
|
global.EventSource = vi.fn(() => mockEventSource);
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create EventSource connection with provided URL', () => {
|
||||||
|
const url = 'http://test.com';
|
||||||
|
const onMessage = vi.fn();
|
||||||
|
|
||||||
|
const { connect } = useEventSourceClient({ url, onMessage });
|
||||||
|
connect();
|
||||||
|
|
||||||
|
expect(EventSource).toHaveBeenCalledWith(url, { withCredentials: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update connection status on successful connection', () => {
|
||||||
|
const { connect, isConnected } = useEventSourceClient({
|
||||||
|
url: 'http://test.com',
|
||||||
|
onMessage: vi.fn(),
|
||||||
|
});
|
||||||
|
connect();
|
||||||
|
|
||||||
|
mockEventSource.simulateConnectionOpen();
|
||||||
|
|
||||||
|
expect(isConnected.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle incoming messages', () => {
|
||||||
|
const onMessage = vi.fn();
|
||||||
|
const { connect } = useEventSourceClient({ url: 'http://test.com', onMessage });
|
||||||
|
connect();
|
||||||
|
|
||||||
|
mockEventSource.simulateMessageEvent('test data');
|
||||||
|
|
||||||
|
expect(onMessage).toHaveBeenCalledWith('test data');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle disconnection', () => {
|
||||||
|
const { connect, disconnect, isConnected } = useEventSourceClient({
|
||||||
|
url: 'http://test.com',
|
||||||
|
onMessage: vi.fn(),
|
||||||
|
});
|
||||||
|
connect();
|
||||||
|
|
||||||
|
// Simulate successful connection
|
||||||
|
mockEventSource.simulateConnectionOpen();
|
||||||
|
expect(isConnected.value).toBe(true);
|
||||||
|
|
||||||
|
disconnect();
|
||||||
|
|
||||||
|
expect(isConnected.value).toBe(false);
|
||||||
|
expect(mockEventSource.close).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle connection loss', () => {
|
||||||
|
const { connect, isConnected } = useEventSourceClient({
|
||||||
|
url: 'http://test.com',
|
||||||
|
onMessage: vi.fn(),
|
||||||
|
});
|
||||||
|
connect();
|
||||||
|
expect(EventSource).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Simulate successful connection
|
||||||
|
mockEventSource.simulateConnectionOpen();
|
||||||
|
expect(isConnected.value).toBe(true);
|
||||||
|
|
||||||
|
// Simulate connection loss
|
||||||
|
mockEventSource.simulateConnectionClose();
|
||||||
|
expect(isConnected.value).toBe(false);
|
||||||
|
|
||||||
|
// Advance timer to trigger reconnect
|
||||||
|
vi.advanceTimersByTime(1_000);
|
||||||
|
expect(EventSource).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendMessage should be a noop function', () => {
|
||||||
|
const { connect, sendMessage } = useEventSourceClient({
|
||||||
|
url: 'http://test.com',
|
||||||
|
onMessage: vi.fn(),
|
||||||
|
});
|
||||||
|
connect();
|
||||||
|
|
||||||
|
// Simulate successful connection
|
||||||
|
mockEventSource.simulateConnectionOpen();
|
||||||
|
|
||||||
|
const message = 'test message';
|
||||||
|
// Should not throw error and should do nothing
|
||||||
|
expect(() => sendMessage(message)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should attempt reconnection with increasing delays', () => {
|
||||||
|
const { connect } = useEventSourceClient({
|
||||||
|
url: 'http://test.com',
|
||||||
|
onMessage: vi.fn(),
|
||||||
|
});
|
||||||
|
connect();
|
||||||
|
|
||||||
|
mockEventSource.simulateConnectionOpen();
|
||||||
|
mockEventSource.simulateConnectionClose();
|
||||||
|
|
||||||
|
// First reconnection attempt after 1 second
|
||||||
|
vi.advanceTimersByTime(1_000);
|
||||||
|
expect(EventSource).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
mockEventSource.simulateConnectionClose();
|
||||||
|
|
||||||
|
// Second reconnection attempt after 2 seconds
|
||||||
|
vi.advanceTimersByTime(2_000);
|
||||||
|
expect(EventSource).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reset connection attempts on successful connection', () => {
|
||||||
|
const { connect } = useEventSourceClient({
|
||||||
|
url: 'http://test.com',
|
||||||
|
onMessage: vi.fn(),
|
||||||
|
});
|
||||||
|
connect();
|
||||||
|
|
||||||
|
// First connection attempt
|
||||||
|
mockEventSource.simulateConnectionOpen();
|
||||||
|
mockEventSource.simulateConnectionClose();
|
||||||
|
|
||||||
|
// First reconnection attempt
|
||||||
|
vi.advanceTimersByTime(1_000);
|
||||||
|
expect(EventSource).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Successful connection
|
||||||
|
mockEventSource.simulateConnectionOpen();
|
||||||
|
|
||||||
|
// Connection lost again
|
||||||
|
mockEventSource.simulateConnectionClose();
|
||||||
|
|
||||||
|
// Should start with initial delay again
|
||||||
|
vi.advanceTimersByTime(1_000);
|
||||||
|
expect(EventSource).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { useHeartbeat } from '../useHeartbeat';
|
||||||
|
|
||||||
|
describe('useHeartbeat', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start heartbeat and call onHeartbeat at specified intervals', () => {
|
||||||
|
const onHeartbeat = vi.fn();
|
||||||
|
const interval = 1000;
|
||||||
|
|
||||||
|
const heartbeat = useHeartbeat({ interval, onHeartbeat });
|
||||||
|
heartbeat.startHeartbeat();
|
||||||
|
|
||||||
|
// Initially, the callback should not be called
|
||||||
|
expect(onHeartbeat).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Advance timer by interval
|
||||||
|
vi.advanceTimersByTime(interval);
|
||||||
|
expect(onHeartbeat).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Advance timer by another interval
|
||||||
|
vi.advanceTimersByTime(interval);
|
||||||
|
expect(onHeartbeat).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop heartbeat when stopHeartbeat is called', () => {
|
||||||
|
const onHeartbeat = vi.fn();
|
||||||
|
const interval = 1000;
|
||||||
|
|
||||||
|
const heartbeat = useHeartbeat({ interval, onHeartbeat });
|
||||||
|
heartbeat.startHeartbeat();
|
||||||
|
|
||||||
|
// Advance timer by interval
|
||||||
|
vi.advanceTimersByTime(interval);
|
||||||
|
expect(onHeartbeat).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Stop the heartbeat
|
||||||
|
heartbeat.stopHeartbeat();
|
||||||
|
|
||||||
|
// Advance timer by multiple intervals
|
||||||
|
vi.advanceTimersByTime(interval * 3);
|
||||||
|
expect(onHeartbeat).toHaveBeenCalledTimes(1); // Should still be 1
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be safe to call stopHeartbeat multiple times', () => {
|
||||||
|
const onHeartbeat = vi.fn();
|
||||||
|
const interval = 1000;
|
||||||
|
|
||||||
|
const heartbeat = useHeartbeat({ interval, onHeartbeat });
|
||||||
|
heartbeat.startHeartbeat();
|
||||||
|
|
||||||
|
// Stop multiple times
|
||||||
|
heartbeat.stopHeartbeat();
|
||||||
|
heartbeat.stopHeartbeat();
|
||||||
|
heartbeat.stopHeartbeat();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(interval * 2);
|
||||||
|
expect(onHeartbeat).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should restart heartbeat after stopping', () => {
|
||||||
|
const onHeartbeat = vi.fn();
|
||||||
|
const interval = 1000;
|
||||||
|
|
||||||
|
const heartbeat = useHeartbeat({ interval, onHeartbeat });
|
||||||
|
|
||||||
|
// First start
|
||||||
|
heartbeat.startHeartbeat();
|
||||||
|
vi.advanceTimersByTime(interval);
|
||||||
|
expect(onHeartbeat).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Stop
|
||||||
|
heartbeat.stopHeartbeat();
|
||||||
|
vi.advanceTimersByTime(interval);
|
||||||
|
expect(onHeartbeat).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Restart
|
||||||
|
heartbeat.startHeartbeat();
|
||||||
|
vi.advanceTimersByTime(interval);
|
||||||
|
expect(onHeartbeat).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,140 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { useWebSocketClient } from '../useWebSocketClient';
|
||||||
|
import { MockWebSocket } from './mockWebSocketClient';
|
||||||
|
|
||||||
|
describe('useWebSocketClient', () => {
|
||||||
|
let mockWebSocket: MockWebSocket;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockWebSocket = new MockWebSocket('ws://test.com');
|
||||||
|
|
||||||
|
// @ts-expect-error - mock WebSocket
|
||||||
|
global.WebSocket = vi.fn(() => mockWebSocket);
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create WebSocket connection with provided URL', () => {
|
||||||
|
const url = 'ws://test.com';
|
||||||
|
const onMessage = vi.fn();
|
||||||
|
|
||||||
|
const { connect } = useWebSocketClient({ url, onMessage });
|
||||||
|
connect();
|
||||||
|
|
||||||
|
expect(WebSocket).toHaveBeenCalledWith(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update connection status and start heartbeat on successful connection', () => {
|
||||||
|
const { connect, isConnected } = useWebSocketClient({
|
||||||
|
url: 'ws://test.com',
|
||||||
|
onMessage: vi.fn(),
|
||||||
|
});
|
||||||
|
connect();
|
||||||
|
|
||||||
|
mockWebSocket.simulateConnectionOpen();
|
||||||
|
|
||||||
|
expect(isConnected.value).toBe(true);
|
||||||
|
|
||||||
|
// Advance timer to trigger heartbeat
|
||||||
|
vi.advanceTimersByTime(30_000);
|
||||||
|
expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify({ type: 'heartbeat' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle incoming messages', () => {
|
||||||
|
const onMessage = vi.fn();
|
||||||
|
const { connect } = useWebSocketClient({ url: 'ws://test.com', onMessage });
|
||||||
|
connect();
|
||||||
|
|
||||||
|
mockWebSocket.simulateMessageEvent('test data');
|
||||||
|
|
||||||
|
expect(onMessage).toHaveBeenCalledWith('test data');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle disconnection', () => {
|
||||||
|
const { connect, disconnect, isConnected } = useWebSocketClient({
|
||||||
|
url: 'ws://test.com',
|
||||||
|
onMessage: vi.fn(),
|
||||||
|
});
|
||||||
|
connect();
|
||||||
|
|
||||||
|
// Simulate successful connection
|
||||||
|
mockWebSocket.simulateConnectionOpen();
|
||||||
|
|
||||||
|
expect(isConnected.value).toBe(true);
|
||||||
|
|
||||||
|
disconnect();
|
||||||
|
|
||||||
|
expect(isConnected.value).toBe(false);
|
||||||
|
expect(mockWebSocket.close).toHaveBeenCalledWith(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle connection loss', () => {
|
||||||
|
const { connect, isConnected } = useWebSocketClient({
|
||||||
|
url: 'ws://test.com',
|
||||||
|
onMessage: vi.fn(),
|
||||||
|
});
|
||||||
|
connect();
|
||||||
|
expect(WebSocket).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Simulate successful connection
|
||||||
|
mockWebSocket.simulateConnectionOpen();
|
||||||
|
|
||||||
|
expect(isConnected.value).toBe(true);
|
||||||
|
|
||||||
|
// Simulate connection loss
|
||||||
|
mockWebSocket.simulateConnectionClose(1006);
|
||||||
|
|
||||||
|
expect(isConnected.value).toBe(false);
|
||||||
|
// Advance timer to reconnect
|
||||||
|
vi.advanceTimersByTime(1_000);
|
||||||
|
expect(WebSocket).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw error when trying to send message while disconnected', () => {
|
||||||
|
const { sendMessage } = useWebSocketClient({ url: 'ws://test.com', onMessage: vi.fn() });
|
||||||
|
|
||||||
|
expect(() => sendMessage('test')).toThrow('Not connected to the server');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should attempt reconnection with increasing delays', () => {
|
||||||
|
const { connect } = useWebSocketClient({
|
||||||
|
url: 'http://test.com',
|
||||||
|
onMessage: vi.fn(),
|
||||||
|
});
|
||||||
|
connect();
|
||||||
|
|
||||||
|
mockWebSocket.simulateConnectionOpen();
|
||||||
|
mockWebSocket.simulateConnectionClose(1006);
|
||||||
|
|
||||||
|
// First reconnection attempt after 1 second
|
||||||
|
vi.advanceTimersByTime(1_000);
|
||||||
|
expect(WebSocket).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
mockWebSocket.simulateConnectionClose(1006);
|
||||||
|
|
||||||
|
// Second reconnection attempt after 2 seconds
|
||||||
|
vi.advanceTimersByTime(2_000);
|
||||||
|
expect(WebSocket).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should send message when connected', () => {
|
||||||
|
const { connect, sendMessage } = useWebSocketClient({
|
||||||
|
url: 'ws://test.com',
|
||||||
|
onMessage: vi.fn(),
|
||||||
|
});
|
||||||
|
connect();
|
||||||
|
|
||||||
|
// Simulate successful connection
|
||||||
|
mockWebSocket.simulateConnectionOpen();
|
||||||
|
|
||||||
|
const message = 'test message';
|
||||||
|
sendMessage(message);
|
||||||
|
|
||||||
|
expect(mockWebSocket.send).toHaveBeenCalledWith(message);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { useReconnectTimer } from '@/push-connection/useReconnectTimer';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export type UseEventSourceClientOptions = {
|
||||||
|
url: string;
|
||||||
|
onMessage: (data: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an EventSource connection to the server. Uses reconnection logic
|
||||||
|
* to reconnect if the connection is lost.
|
||||||
|
*/
|
||||||
|
export const useEventSourceClient = (options: UseEventSourceClientOptions) => {
|
||||||
|
const isConnected = ref(false);
|
||||||
|
const eventSource = ref<EventSource | null>(null);
|
||||||
|
|
||||||
|
const onConnected = () => {
|
||||||
|
isConnected.value = true;
|
||||||
|
reconnectTimer.resetConnectionAttempts();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onConnectionLost = () => {
|
||||||
|
console.warn('[EventSourceClient] Connection lost');
|
||||||
|
isConnected.value = false;
|
||||||
|
reconnectTimer.scheduleReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMessage = (event: MessageEvent) => {
|
||||||
|
options.onMessage(event.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnect = () => {
|
||||||
|
if (eventSource.value) {
|
||||||
|
reconnectTimer.stopReconnectTimer();
|
||||||
|
eventSource.value.close();
|
||||||
|
eventSource.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
// Ensure we disconnect any existing connection
|
||||||
|
disconnect();
|
||||||
|
|
||||||
|
eventSource.value = new EventSource(options.url, { withCredentials: true });
|
||||||
|
eventSource.value.addEventListener('open', onConnected);
|
||||||
|
eventSource.value.addEventListener('message', onMessage);
|
||||||
|
eventSource.value.addEventListener('close', onConnectionLost);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reconnectTimer = useReconnectTimer({
|
||||||
|
onAttempt: connect,
|
||||||
|
onAttemptScheduled: (delay) => {
|
||||||
|
console.log(`[EventSourceClient] Attempting to reconnect in ${delay}ms`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendMessage = (_: string) => {
|
||||||
|
// Noop, EventSource does not support sending messages
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isConnected,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
sendMessage,
|
||||||
|
};
|
||||||
|
};
|
32
packages/editor-ui/src/push-connection/useHeartbeat.ts
Normal file
32
packages/editor-ui/src/push-connection/useHeartbeat.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export type UseHeartbeatOptions = {
|
||||||
|
interval: number;
|
||||||
|
onHeartbeat: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a heartbeat timer using the given interval. The timer needs
|
||||||
|
* to be started and stopped manually.
|
||||||
|
*/
|
||||||
|
export const useHeartbeat = (options: UseHeartbeatOptions) => {
|
||||||
|
const { interval, onHeartbeat } = options;
|
||||||
|
|
||||||
|
const heartbeatTimer = ref<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const startHeartbeat = () => {
|
||||||
|
heartbeatTimer.value = setInterval(onHeartbeat, interval);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopHeartbeat = () => {
|
||||||
|
if (heartbeatTimer.value) {
|
||||||
|
clearInterval(heartbeatTimer.value);
|
||||||
|
heartbeatTimer.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
startHeartbeat,
|
||||||
|
stopHeartbeat,
|
||||||
|
};
|
||||||
|
};
|
48
packages/editor-ui/src/push-connection/useReconnectTimer.ts
Normal file
48
packages/editor-ui/src/push-connection/useReconnectTimer.ts
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
type UseReconnectTimerOptions = {
|
||||||
|
/** Callback that an attempt should be made */
|
||||||
|
onAttempt: () => void;
|
||||||
|
|
||||||
|
/** Callback that a future attempt was scheduled */
|
||||||
|
onAttemptScheduled: (delay: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A timer for exponential backoff reconnect attempts.
|
||||||
|
*/
|
||||||
|
export const useReconnectTimer = ({ onAttempt, onAttemptScheduled }: UseReconnectTimerOptions) => {
|
||||||
|
const initialReconnectDelay = 1000;
|
||||||
|
const maxReconnectDelay = 15_000;
|
||||||
|
|
||||||
|
const reconnectTimer = ref<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const reconnectAttempts = ref(0);
|
||||||
|
|
||||||
|
const scheduleReconnect = () => {
|
||||||
|
const delay = Math.min(initialReconnectDelay * 2 ** reconnectAttempts.value, maxReconnectDelay);
|
||||||
|
|
||||||
|
reconnectAttempts.value++;
|
||||||
|
|
||||||
|
onAttemptScheduled(delay);
|
||||||
|
reconnectTimer.value = setTimeout(() => {
|
||||||
|
onAttempt();
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopReconnectTimer = () => {
|
||||||
|
if (reconnectTimer.value) {
|
||||||
|
clearTimeout(reconnectTimer.value);
|
||||||
|
reconnectTimer.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetConnectionAttempts = () => {
|
||||||
|
reconnectAttempts.value = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
scheduleReconnect,
|
||||||
|
stopReconnectTimer,
|
||||||
|
resetConnectionAttempts,
|
||||||
|
};
|
||||||
|
};
|
106
packages/editor-ui/src/push-connection/useWebSocketClient.ts
Normal file
106
packages/editor-ui/src/push-connection/useWebSocketClient.ts
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
import { useHeartbeat } from '@/push-connection/useHeartbeat';
|
||||||
|
import { useReconnectTimer } from '@/push-connection/useReconnectTimer';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export type UseWebSocketClientOptions<T> = {
|
||||||
|
url: string;
|
||||||
|
onMessage: (data: T) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Defined here as not available in tests */
|
||||||
|
export const WebSocketState = {
|
||||||
|
CONNECTING: 0,
|
||||||
|
OPEN: 1,
|
||||||
|
CLOSING: 2,
|
||||||
|
CLOSED: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a WebSocket connection to the server. Uses reconnection logic
|
||||||
|
* to reconnect if the connection is lost.
|
||||||
|
*/
|
||||||
|
export const useWebSocketClient = <T>(options: UseWebSocketClientOptions<T>) => {
|
||||||
|
const isConnected = ref(false);
|
||||||
|
const socket = ref<WebSocket | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heartbeat timer to keep the connection alive. This is an additional
|
||||||
|
* mechanism to the protocol level ping/pong mechanism the server sends.
|
||||||
|
* This is used the ensure the client notices connection issues.
|
||||||
|
*/
|
||||||
|
const { startHeartbeat, stopHeartbeat } = useHeartbeat({
|
||||||
|
interval: 30_000,
|
||||||
|
onHeartbeat: () => {
|
||||||
|
socket.value?.send(JSON.stringify({ type: 'heartbeat' }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onConnected = () => {
|
||||||
|
socket.value?.removeEventListener('open', onConnected);
|
||||||
|
isConnected.value = true;
|
||||||
|
startHeartbeat();
|
||||||
|
reconnectTimer.resetConnectionAttempts();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onConnectionLost = (event: CloseEvent) => {
|
||||||
|
console.warn(`[WebSocketClient] Connection lost, code=${event.code ?? 'unknown'}`);
|
||||||
|
isConnected.value = false;
|
||||||
|
stopHeartbeat();
|
||||||
|
reconnectTimer.scheduleReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMessage = (event: MessageEvent) => {
|
||||||
|
options.onMessage(event.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (error: unknown) => {
|
||||||
|
console.warn('[WebSocketClient] Connection error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnect = () => {
|
||||||
|
if (socket.value) {
|
||||||
|
stopHeartbeat();
|
||||||
|
reconnectTimer.stopReconnectTimer();
|
||||||
|
socket.value.removeEventListener('message', onMessage);
|
||||||
|
socket.value.removeEventListener('error', onError);
|
||||||
|
socket.value.removeEventListener('close', onConnectionLost);
|
||||||
|
socket.value.close(1000);
|
||||||
|
socket.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
// Ensure we disconnect any existing connection
|
||||||
|
disconnect();
|
||||||
|
|
||||||
|
socket.value = new WebSocket(options.url);
|
||||||
|
socket.value.addEventListener('open', onConnected);
|
||||||
|
socket.value.addEventListener('message', onMessage);
|
||||||
|
socket.value.addEventListener('error', onError);
|
||||||
|
socket.value.addEventListener('close', onConnectionLost);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reconnectTimer = useReconnectTimer({
|
||||||
|
onAttempt: connect,
|
||||||
|
onAttemptScheduled: (delay) => {
|
||||||
|
console.log(`[WebSocketClient] Attempting to reconnect in ${delay}ms`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendMessage = (serializedMessage: string) => {
|
||||||
|
if (!isConnected.value || !socket.value) {
|
||||||
|
throw new Error('Not connected to the server');
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.value.send(serializedMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isConnected,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
sendMessage,
|
||||||
|
};
|
||||||
|
};
|
|
@ -1,22 +1,12 @@
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref, computed } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import type { PushMessage } from '@n8n/api-types';
|
import type { PushMessage } from '@n8n/api-types';
|
||||||
|
|
||||||
import { STORES, TIME } from '@/constants';
|
import { STORES } from '@/constants';
|
||||||
import { useSettingsStore } from './settings.store';
|
import { useSettingsStore } from './settings.store';
|
||||||
import { useRootStore } from './root.store';
|
import { useRootStore } from './root.store';
|
||||||
|
import { useWebSocketClient } from '@/push-connection/useWebSocketClient';
|
||||||
export interface PushState {
|
import { useEventSourceClient } from '@/push-connection/useEventSourceClient';
|
||||||
pushRef: string;
|
|
||||||
pushSource: WebSocket | EventSource | null;
|
|
||||||
reconnectTimeout: NodeJS.Timeout | null;
|
|
||||||
retryTimeout: NodeJS.Timeout | null;
|
|
||||||
pushMessageQueue: Array<{ event: Event; retriesLeft: number }>;
|
|
||||||
connectRetries: number;
|
|
||||||
lostConnection: boolean;
|
|
||||||
outgoingQueue: unknown[];
|
|
||||||
isConnectionOpen: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OnPushMessageHandler = (event: PushMessage) => void;
|
export type OnPushMessageHandler = (event: PushMessage) => void;
|
||||||
|
|
||||||
|
@ -27,18 +17,20 @@ export const usePushConnectionStore = defineStore(STORES.PUSH, () => {
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
const pushRef = computed(() => rootStore.pushRef);
|
/**
|
||||||
const pushSource = ref<WebSocket | EventSource | null>(null);
|
* Queue of messages to be sent to the server. Messages are queued if
|
||||||
const reconnectTimeout = ref<NodeJS.Timeout | null>(null);
|
* the connection is down.
|
||||||
const connectRetries = ref(0);
|
*/
|
||||||
const lostConnection = ref(false);
|
|
||||||
const outgoingQueue = ref<unknown[]>([]);
|
const outgoingQueue = ref<unknown[]>([]);
|
||||||
const isConnectionOpen = ref(false);
|
|
||||||
|
/** Whether the connection has been requested */
|
||||||
|
const isConnectionRequested = ref(false);
|
||||||
|
|
||||||
const onMessageReceivedHandlers = ref<OnPushMessageHandler[]>([]);
|
const onMessageReceivedHandlers = ref<OnPushMessageHandler[]>([]);
|
||||||
|
|
||||||
const addEventListener = (handler: OnPushMessageHandler) => {
|
const addEventListener = (handler: OnPushMessageHandler) => {
|
||||||
onMessageReceivedHandlers.value.push(handler);
|
onMessageReceivedHandlers.value.push(handler);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
const index = onMessageReceivedHandlers.value.indexOf(handler);
|
const index = onMessageReceivedHandlers.value.indexOf(handler);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
|
@ -47,103 +39,30 @@ export const usePushConnectionStore = defineStore(STORES.PUSH, () => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
function onConnectionError() {
|
const useWebSockets = settingsStore.pushBackend === 'websocket';
|
||||||
pushDisconnect();
|
|
||||||
connectRetries.value++;
|
|
||||||
reconnectTimeout.value = setTimeout(
|
|
||||||
attemptReconnect,
|
|
||||||
Math.min(connectRetries.value * 2000, 8 * TIME.SECOND), // maximum 8 seconds backoff
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close connection to server
|
|
||||||
*/
|
|
||||||
function pushDisconnect() {
|
|
||||||
if (pushSource.value !== null) {
|
|
||||||
pushSource.value.removeEventListener('error', onConnectionError);
|
|
||||||
pushSource.value.removeEventListener('close', onConnectionError);
|
|
||||||
pushSource.value.removeEventListener('message', pushMessageReceived);
|
|
||||||
if (pushSource.value.readyState < 2) pushSource.value.close();
|
|
||||||
pushSource.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
isConnectionOpen.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to server to receive data via a WebSocket or EventSource
|
|
||||||
*/
|
|
||||||
function pushConnect() {
|
|
||||||
// always close the previous connection so that we do not end up with multiple connections
|
|
||||||
pushDisconnect();
|
|
||||||
|
|
||||||
if (reconnectTimeout.value) {
|
|
||||||
clearTimeout(reconnectTimeout.value);
|
|
||||||
reconnectTimeout.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const useWebSockets = settingsStore.pushBackend === 'websocket';
|
|
||||||
|
|
||||||
|
const getConnectionUrl = () => {
|
||||||
const restUrl = rootStore.restUrl;
|
const restUrl = rootStore.restUrl;
|
||||||
const url = `/push?pushRef=${pushRef.value}`;
|
const url = `/push?pushRef=${rootStore.pushRef}`;
|
||||||
|
|
||||||
if (useWebSockets) {
|
if (useWebSockets) {
|
||||||
const { protocol, host } = window.location;
|
const { protocol, host } = window.location;
|
||||||
const baseUrl = restUrl.startsWith('http')
|
const baseUrl = restUrl.startsWith('http')
|
||||||
? restUrl.replace(/^http/, 'ws')
|
? restUrl.replace(/^http/, 'ws')
|
||||||
: `${protocol === 'https:' ? 'wss' : 'ws'}://${host + restUrl}`;
|
: `${protocol === 'https:' ? 'wss' : 'ws'}://${host + restUrl}`;
|
||||||
pushSource.value = new WebSocket(`${baseUrl}${url}`);
|
return `${baseUrl}${url}`;
|
||||||
} else {
|
} else {
|
||||||
pushSource.value = new EventSource(`${restUrl}${url}`, { withCredentials: true });
|
return `${restUrl}${url}`;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
pushSource.value.addEventListener('open', onConnectionSuccess, false);
|
|
||||||
pushSource.value.addEventListener('message', pushMessageReceived, false);
|
|
||||||
pushSource.value.addEventListener(useWebSockets ? 'close' : 'error', onConnectionError, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function attemptReconnect() {
|
|
||||||
pushConnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeAndSend(message: unknown) {
|
|
||||||
if (pushSource.value && 'send' in pushSource.value) {
|
|
||||||
pushSource.value.send(JSON.stringify(message));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onConnectionSuccess() {
|
|
||||||
isConnectionOpen.value = true;
|
|
||||||
connectRetries.value = 0;
|
|
||||||
lostConnection.value = false;
|
|
||||||
rootStore.setPushConnectionActive();
|
|
||||||
pushSource.value?.removeEventListener('open', onConnectionSuccess);
|
|
||||||
|
|
||||||
if (outgoingQueue.value.length) {
|
|
||||||
for (const message of outgoingQueue.value) {
|
|
||||||
serializeAndSend(message);
|
|
||||||
}
|
|
||||||
outgoingQueue.value = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function send(message: unknown) {
|
|
||||||
if (!isConnectionOpen.value) {
|
|
||||||
outgoingQueue.value.push(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
serializeAndSend(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process a newly received message
|
* Process a newly received message
|
||||||
*/
|
*/
|
||||||
async function pushMessageReceived(event: Event) {
|
async function onMessage(data: unknown) {
|
||||||
let receivedData: PushMessage;
|
let receivedData: PushMessage;
|
||||||
try {
|
try {
|
||||||
// @ts-ignore
|
receivedData = JSON.parse(data as string);
|
||||||
receivedData = JSON.parse(event.data);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -151,19 +70,55 @@ export const usePushConnectionStore = defineStore(STORES.PUSH, () => {
|
||||||
onMessageReceivedHandlers.value.forEach((handler) => handler(receivedData));
|
onMessageReceivedHandlers.value.forEach((handler) => handler(receivedData));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const url = getConnectionUrl();
|
||||||
|
|
||||||
|
const client = useWebSockets
|
||||||
|
? useWebSocketClient({ url, onMessage })
|
||||||
|
: useEventSourceClient({ url, onMessage });
|
||||||
|
|
||||||
|
function serializeAndSend(message: unknown) {
|
||||||
|
client.sendMessage(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushConnect = () => {
|
||||||
|
isConnectionRequested.value = true;
|
||||||
|
client.connect();
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushDisconnect = () => {
|
||||||
|
isConnectionRequested.value = false;
|
||||||
|
client.disconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(client.isConnected, (didConnect) => {
|
||||||
|
if (!didConnect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send any buffered messages
|
||||||
|
if (outgoingQueue.value.length) {
|
||||||
|
for (const message of outgoingQueue.value) {
|
||||||
|
serializeAndSend(message);
|
||||||
|
}
|
||||||
|
outgoingQueue.value = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Removes all buffered messages from the sent queue */
|
||||||
const clearQueue = () => {
|
const clearQueue = () => {
|
||||||
outgoingQueue.value = [];
|
outgoingQueue.value = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isConnected = computed(() => client.isConnected.value);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pushRef,
|
isConnected,
|
||||||
pushSource,
|
isConnectionRequested,
|
||||||
isConnectionOpen,
|
|
||||||
onMessageReceivedHandlers,
|
onMessageReceivedHandlers,
|
||||||
addEventListener,
|
addEventListener,
|
||||||
pushConnect,
|
pushConnect,
|
||||||
pushDisconnect,
|
pushDisconnect,
|
||||||
send,
|
send: serializeAndSend,
|
||||||
clearQueue,
|
clearQueue,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
@ -20,7 +20,6 @@ export const useRootStore = defineStore(STORES.ROOT, () => {
|
||||||
endpointWebhook: 'webhook',
|
endpointWebhook: 'webhook',
|
||||||
endpointWebhookTest: 'webhook-test',
|
endpointWebhookTest: 'webhook-test',
|
||||||
endpointWebhookWaiting: 'webhook-waiting',
|
endpointWebhookWaiting: 'webhook-waiting',
|
||||||
pushConnectionActive: true,
|
|
||||||
timezone: 'America/New_York',
|
timezone: 'America/New_York',
|
||||||
executionTimeout: -1,
|
executionTimeout: -1,
|
||||||
maxExecutionTimeout: Number.MAX_SAFE_INTEGER,
|
maxExecutionTimeout: Number.MAX_SAFE_INTEGER,
|
||||||
|
@ -66,8 +65,6 @@ export const useRootStore = defineStore(STORES.ROOT, () => {
|
||||||
|
|
||||||
const versionCli = computed(() => state.value.versionCli);
|
const versionCli = computed(() => state.value.versionCli);
|
||||||
|
|
||||||
const pushConnectionActive = computed(() => state.value.pushConnectionActive);
|
|
||||||
|
|
||||||
const OAuthCallbackUrls = computed(() => state.value.oauthCallbackUrls);
|
const OAuthCallbackUrls = computed(() => state.value.oauthCallbackUrls);
|
||||||
|
|
||||||
const webhookTestUrl = computed(
|
const webhookTestUrl = computed(
|
||||||
|
@ -105,14 +102,6 @@ export const useRootStore = defineStore(STORES.ROOT, () => {
|
||||||
state.value.urlBaseWebhook = url;
|
state.value.urlBaseWebhook = url;
|
||||||
};
|
};
|
||||||
|
|
||||||
const setPushConnectionActive = () => {
|
|
||||||
state.value.pushConnectionActive = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setPushConnectionInactive = () => {
|
|
||||||
state.value.pushConnectionActive = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setUrlBaseEditor = (urlBaseEditor: string) => {
|
const setUrlBaseEditor = (urlBaseEditor: string) => {
|
||||||
const url = urlBaseEditor.endsWith('/') ? urlBaseEditor : `${urlBaseEditor}/`;
|
const url = urlBaseEditor.endsWith('/') ? urlBaseEditor : `${urlBaseEditor}/`;
|
||||||
state.value.urlBaseEditor = url;
|
state.value.urlBaseEditor = url;
|
||||||
|
@ -198,13 +187,10 @@ export const useRootStore = defineStore(STORES.ROOT, () => {
|
||||||
pushRef,
|
pushRef,
|
||||||
defaultLocale,
|
defaultLocale,
|
||||||
binaryDataMode,
|
binaryDataMode,
|
||||||
pushConnectionActive,
|
|
||||||
OAuthCallbackUrls,
|
OAuthCallbackUrls,
|
||||||
executionTimeout,
|
executionTimeout,
|
||||||
maxExecutionTimeout,
|
maxExecutionTimeout,
|
||||||
timezone,
|
timezone,
|
||||||
setPushConnectionInactive,
|
|
||||||
setPushConnectionActive,
|
|
||||||
setUrlBaseWebhook,
|
setUrlBaseWebhook,
|
||||||
setUrlBaseEditor,
|
setUrlBaseEditor,
|
||||||
setEndpointForm,
|
setEndpointForm,
|
||||||
|
|
Loading…
Reference in a new issue