From d2f770976ed5d205a2b92a92d1014c38601b216f Mon Sep 17 00:00:00 2001 From: Anton Platonov Date: Tue, 24 May 2022 11:40:42 +0300 Subject: [PATCH] feat: Emit state change events from FluxConnection (#405) (#434) * feat: Emit state change events from FluxConnection These can be used to implement reconnect logic for Fluxes but also to update the UI to indicate that server pushes are not being received * Use EventTarget * Extend EventTarget * Merge with an interface Co-authored-by: Anton Platonov (cherry picked from commit 134dbb5481ffb84cfaa32625912b4f2488ebf7a6) Co-authored-by: Artur --- .../hilla-frontend/mocks/socket.io-client.ts | 13 ++-- packages/ts/hilla-frontend/src/Connect.ts | 18 ++++-- .../ts/hilla-frontend/src/FluxConnection.ts | 59 ++++++++++++++++++- packages/ts/hilla-frontend/src/index.ts | 2 +- .../ts/hilla-frontend/test/Connect.test.ts | 10 ++-- .../test/FluxConnection.test.ts | 44 ++++++++++++++ 6 files changed, 127 insertions(+), 19 deletions(-) diff --git a/packages/ts/hilla-frontend/mocks/socket.io-client.ts b/packages/ts/hilla-frontend/mocks/socket.io-client.ts index 7ecc6bb9d6..090bd3ef8a 100644 --- a/packages/ts/hilla-frontend/mocks/socket.io-client.ts +++ b/packages/ts/hilla-frontend/mocks/socket.io-client.ts @@ -2,15 +2,20 @@ interface Socket {} export const io = (path: string, options: any): Socket => { const sentMessages = []; - const incomingMessages = []; + const eventHandlers = {}; return { - on: (type: string, event: any) => { - incomingMessages.push({ type, event }); + on: (event: string, listener: any) => { + if (!eventHandlers[event]) { + eventHandlers[event] = []; + } + eventHandlers[event].push(listener); + }, + emit: (event: string, ...args: any[]) => { + (eventHandlers[event] || []).forEach((l: any) => l(args)); }, send: (...args: any[]) => { sentMessages.push(...args); }, sentMessages, - incomingMessages, }; }; diff --git a/packages/ts/hilla-frontend/src/Connect.ts b/packages/ts/hilla-frontend/src/Connect.ts index 5d5ae9949e..9d3cd53ae7 100644 --- a/packages/ts/hilla-frontend/src/Connect.ts +++ b/packages/ts/hilla-frontend/src/Connect.ts @@ -249,7 +249,7 @@ function isFlowLoaded(): boolean { } /** - * Hilla Connect client class is a low-level network calling utility. It stores + * A low-level network calling utility. It stores * a prefix and facilitates remote calls to endpoint class methods * on the Hilla backend. * @@ -281,7 +281,7 @@ export class ConnectClient { */ public middlewares: Middleware[] = []; - private fluxConnection: FluxConnection | undefined = undefined; + private _fluxConnection: FluxConnection | undefined = undefined; /** * @param options Constructor options. @@ -426,10 +426,16 @@ export class ConnectClient { * @returns {} A subscription used to handles values as they become available. */ public subscribe(endpoint: string, method: string, params?: any): Subscription { - if (!this.fluxConnection) { - this.fluxConnection = new FluxConnection(); - } - return this.fluxConnection.subscribe(endpoint, method, params ? Object.values(params) : []); } + + /** + * Gets a representation of the underlying persistent network connection used for subscribing to Flux type endpoint methods. + */ + get fluxConnection(): FluxConnection { + if (!this._fluxConnection) { + this._fluxConnection = new FluxConnection(); + } + return this._fluxConnection; + } } diff --git a/packages/ts/hilla-frontend/src/FluxConnection.ts b/packages/ts/hilla-frontend/src/FluxConnection.ts index 75c1875845..1665164816 100644 --- a/packages/ts/hilla-frontend/src/FluxConnection.ts +++ b/packages/ts/hilla-frontend/src/FluxConnection.ts @@ -5,7 +5,27 @@ import type { Subscription } from './Connect'; import { getCsrfTokenHeadersForEndpointRequest } from './CsrfUtils'; import type { ClientMessage, ServerCloseMessage, ServerConnectMessage, ServerMessage } from './FluxMessages'; -export class FluxConnection { +export enum State { + ACTIVE = 'active', + INACTIVE = 'inactive', +} + +type ActiveEvent = CustomEvent<{ active: boolean }>; +interface EventMap { + 'state-changed': ActiveEvent; +} + +type ListenerType = + | ((this: FluxConnection, ev: EventMap[T]) => any) + | { + handleEvent(ev: EventMap[T]): void; + } + | null; + +/** + * A representation of the underlying persistent network connection used for subscribing to Flux type endpoint methods. + */ +export class FluxConnection extends EventTarget { private nextId = 0; private endpointInfos = new Map(); private onNextCallbacks = new Map void>(); @@ -13,8 +33,10 @@ export class FluxConnection { private onErrorCallbacks = new Map void>(); private socket!: Socket; + public state: State = State.INACTIVE; constructor() { + super(); if (!(window as any).Vaadin?.featureFlags?.hillaPush) { // Remove when removing feature flag throw new Error( @@ -30,6 +52,24 @@ export class FluxConnection { this.socket.on('message', (message) => { this.handleMessage(JSON.parse(message)); }); + this.socket.on('disconnect', () => { + // https://socket.io/docs/v4/client-api/#event-disconnect + if (this.state === State.ACTIVE) { + this.state = State.INACTIVE; + this.dispatchEvent(new CustomEvent('state-changed', { detail: { active: false } })); + } + }); + this.socket.on('connect_error', () => { + // https://socket.io/docs/v4/client-api/#event-connect_error + }); + + this.socket.on('connect', () => { + // https://socket.io/docs/v4/client-api/#event-connect + if (this.state === State.INACTIVE) { + this.state = State.ACTIVE; + this.dispatchEvent(new CustomEvent('state-changed', { detail: { active: true } })); + } + }); } private handleMessage(message: ClientMessage) { @@ -73,10 +113,18 @@ export class FluxConnection { this.socket.send(message); } - subscribe(endpointName: string, methodName: string, maybeParams?: Array): Subscription { + /** + * Subscribes to the flux returned by the given endpoint name + method name using the given parameters. + * + * @param endpointName the endpoint to connect to + * @param methodName the method in the endpoint to connect to + * @param parameters the parameters to use + * @returns a subscription + */ + subscribe(endpointName: string, methodName: string, parameters?: Array): Subscription { const id: string = this.nextId.toString(); this.nextId += 1; - const params = maybeParams || []; + const params = parameters || []; const msg: ServerConnectMessage = { '@type': 'subscribe', id, endpointName, methodName, params }; const endpointInfo = `${endpointName}.${methodName}(${JSON.stringify(params)})`; @@ -117,3 +165,8 @@ export class FluxConnection { return hillaSubscription; } } + +export interface FluxConnection { + addEventListener(type: T, listener: ListenerType): void; + removeEventListener(type: T, listener: ListenerType): void; +} diff --git a/packages/ts/hilla-frontend/src/index.ts b/packages/ts/hilla-frontend/src/index.ts index d04c1ec637..f0e91f5566 100644 --- a/packages/ts/hilla-frontend/src/index.ts +++ b/packages/ts/hilla-frontend/src/index.ts @@ -1,6 +1,6 @@ export * from './Authentication.js'; export * from './Connect.js'; -export { FluxConnection } from './FluxConnection'; +export { FluxConnection, State } from './FluxConnection'; const $wnd = window as any; /* c8 ignore next 2 */ diff --git a/packages/ts/hilla-frontend/test/Connect.test.ts b/packages/ts/hilla-frontend/test/Connect.test.ts index 712b792c9a..028918ca27 100644 --- a/packages/ts/hilla-frontend/test/Connect.test.ts +++ b/packages/ts/hilla-frontend/test/Connect.test.ts @@ -550,22 +550,22 @@ describe('ConnectClient', () => { }); it('should create a fluxConnection', async () => { - (client as any).fluxConnection = undefined; // NOSONAR + (client as any)._fluxConnection = undefined; // NOSONAR client.subscribe('FooEndpoint', 'fooMethod'); - expect((client as any).fluxConnection).to.not.equal(undefined); + expect((client as any)._fluxConnection).to.not.equal(undefined); }); it('should reuse the fluxConnection', async () => { client.subscribe('FooEndpoint', 'fooMethod'); const { fluxConnection } = client as any; client.subscribe('FooEndpoint', 'barMethod'); - expect((client as any).fluxConnection).to.equal(fluxConnection); + expect((client as any)._fluxConnection).to.equal(fluxConnection); }); it('should call FluxConnection', async () => { - (client as any).fluxConnection = new FluxConnection(); + (client as any)._fluxConnection = new FluxConnection(); let called = 0; - (client as any).fluxConnection.subscribe = (endpointName: any, methodName: any, params: any) => { + (client as any)._fluxConnection.subscribe = (endpointName: any, methodName: any, params: any) => { called += 1; expect(endpointName).to.equal('FooEndpoint'); expect(methodName).to.equal('fooMethod'); diff --git a/packages/ts/hilla-frontend/test/FluxConnection.test.ts b/packages/ts/hilla-frontend/test/FluxConnection.test.ts index 30032502d6..ce96ae2ead 100644 --- a/packages/ts/hilla-frontend/test/FluxConnection.test.ts +++ b/packages/ts/hilla-frontend/test/FluxConnection.test.ts @@ -256,4 +256,48 @@ describe('FluxConnection', () => { fakeElement.disconnectedCallback(); expectNoDataRetained(fluxConnectionAny); }); + it('dispatches an active event on socket.io connect', () => { + const { socket } = fluxConnectionAny; + socket.connected = false; + let events = 0; + fluxConnection.addEventListener('state-changed', (e) => { + if (e.detail.active) { + events += 1; + } + }); + socket.connected = true; + socket.emit('connect'); + expect(events).to.equal(1); + }); + it('dispatches an active event on socket.io reconnect', () => { + const { socket } = fluxConnectionAny; + socket.connected = false; + let events = 0; + fluxConnection.addEventListener('state-changed', (e) => { + if (e.detail.active) { + events += 1; + } + }); + socket.connected = true; + socket.emit('connect'); + socket.connected = false; + socket.emit('disconnect'); + socket.connected = true; + socket.emit('connect'); + expect(events).to.equal(2); + }); + it('dispatches an inactive event on socket.io disconnect', () => { + const { socket } = fluxConnectionAny; + let events = 0; + fluxConnection.addEventListener('state-changed', (e) => { + if (!e.detail.active) { + events += 1; + } + }); + socket.connected = true; + socket.emit('connect'); + socket.connected = false; + socket.emit('disconnect'); + expect(events).to.equal(1); + }); });