UNPKG

@vaadin/hilla-frontend

Version:

Hilla core frontend utils

293 lines 9.44 kB
import csrfInfoSource from "./CsrfInfoSource.js"; import { isClientMessage } from "./FluxMessages.js"; import { VAADIN_BROWSER_ENVIRONMENT } from "./utils.js"; export let State = function(State) { State["ACTIVE"] = "active"; State["INACTIVE"] = "inactive"; State["RECONNECTING"] = "reconnecting"; return State; }({}); /** * Possible options for dealing with lost subscriptions after a websocket is reopened. */ export let ActionOnLostSubscription = function(ActionOnLostSubscription) { /** * The subscription should be resubscribed using the same server method and parameters. */ ActionOnLostSubscription["RESUBSCRIBE"] = "resubscribe"; /** * The subscription should be removed. */ ActionOnLostSubscription["REMOVE"] = "remove"; return ActionOnLostSubscription; }({}); /** * Possible states of a flux subscription. */ export let FluxSubscriptionState = function(FluxSubscriptionState) { /** * The subscription is not connected and is trying to connect. */ FluxSubscriptionState["CONNECTING"] = "connecting"; /** * The subscription is connected and receiving updates. */ FluxSubscriptionState["CONNECTED"] = "connected"; /** * The subscription is closed and is not trying to reconnect. */ FluxSubscriptionState["CLOSED"] = "closed"; return FluxSubscriptionState; }({}); const atmospherePromise = VAADIN_BROWSER_ENVIRONMENT ? import("atmosphere.js") : undefined; /** * A representation of the underlying persistent network connection used for subscribing to Flux type endpoint methods. */ export class FluxConnection extends EventTarget { state = State.INACTIVE; wasClosed = false; #endpointInfos = new Map(); #nextId = 0; #onCompleteCallbacks = new Map(); #onErrorCallbacks = new Map(); #onNextCallbacks = new Map(); #onStateChangeCallbacks = new Map(); #statusOfSubscriptions = new Map(); #pendingMessages = []; #socket; #ready; constructor(connectPrefix, atmosphereOptions) { super(); this.#ready = this.#connectWebsocket(connectPrefix.replace(/connect$/u, ""), atmosphereOptions ?? {}); } #resubscribeIfWasClosed() { if (this.wasClosed) { this.wasClosed = false; const toBeRemoved = []; this.#endpointInfos.forEach((endpointInfo, id) => { if (endpointInfo.reconnect?.() === ActionOnLostSubscription.RESUBSCRIBE) { this.#setSubscriptionConnState(id, FluxSubscriptionState.CONNECTING); this.#send({ "@type": "subscribe", endpointName: endpointInfo.endpointName, id, methodName: endpointInfo.methodName, params: endpointInfo.params }); } else { toBeRemoved.push(id); } }); toBeRemoved.forEach((id) => this.#removeSubscription(id)); } } /** * Promise that resolves when the instance is initialized. */ get ready() { return this.#ready; } /** * 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, methodName, parameters) { const id = this.#nextId.toString(); this.#nextId += 1; const params = parameters ?? []; const msg = { "@type": "subscribe", endpointName, id, methodName, params }; this.#send(msg); this.#endpointInfos.set(id, { endpointName, methodName, params }); this.#setSubscriptionConnState(id, FluxSubscriptionState.CONNECTING); const hillaSubscription = { cancel: () => { if (!this.#endpointInfos.has(id)) { return; } const closeMessage = { "@type": "unsubscribe", id }; this.#send(closeMessage); this.#removeSubscription(id); }, context(context) { context.addController({ hostDisconnected() { hillaSubscription.cancel(); } }); return hillaSubscription; }, onComplete: (callback) => { this.#onCompleteCallbacks.set(id, callback); return hillaSubscription; }, onError: (callback) => { this.#onErrorCallbacks.set(id, callback); return hillaSubscription; }, onNext: (callback) => { this.#onNextCallbacks.set(id, callback); return hillaSubscription; }, onSubscriptionLost: (callback) => { if (this.#endpointInfos.has(id)) { this.#endpointInfos.get(id).reconnect = callback; } else { console.warn(`"onReconnect" value not set for subscription "${id}" because it was already canceled`); } return hillaSubscription; }, onConnectionStateChange: (callback) => { this.#onStateChangeCallbacks.set(id, callback); callback(new CustomEvent("subscription-state-change", { detail: { state: this.#statusOfSubscriptions.get(id) } })); return hillaSubscription; } }; return hillaSubscription; } async #connectWebsocket(prefix, atmosphereOptions) { if (!atmospherePromise) { return; } const extraHeaders = Object.fromEntries((await csrfInfoSource.get()).headerEntries); const pushUrl = "HILLA/push"; const url = prefix.length === 0 ? pushUrl : (prefix.endsWith("/") ? prefix : `${prefix}/`) + pushUrl; const atmosphere = (await atmospherePromise).default; this.#socket = atmosphere.subscribe?.({ contentType: "application/json; charset=UTF-8", enableProtocol: true, transport: "websocket", fallbackTransport: "websocket", headers: extraHeaders, maxReconnectOnClose: 1e7, reconnectInterval: 5e3, timeout: -1, trackMessageLength: true, url, onClose: () => { this.wasClosed = true; if (this.state !== State.INACTIVE) { this.state = State.INACTIVE; this.dispatchEvent(new CustomEvent("state-changed", { detail: { active: false } })); } }, onError: (response) => { console.error("error in push communication", response); }, onMessage: (response) => { if (response.responseBody) { this.#handleMessage(JSON.parse(response.responseBody)); } }, onMessagePublished: (response) => { if (response?.responseBody) { this.#handleMessage(JSON.parse(response.responseBody)); } }, onOpen: () => { if (this.state !== State.ACTIVE) { this.#resubscribeIfWasClosed(); this.state = State.ACTIVE; this.dispatchEvent(new CustomEvent("state-changed", { detail: { active: true } })); this.#sendPendingMessages(); } }, onReopen: () => { if (this.state !== State.ACTIVE) { this.#resubscribeIfWasClosed(); this.state = State.ACTIVE; this.dispatchEvent(new CustomEvent("state-changed", { detail: { active: true } })); this.#sendPendingMessages(); } }, onReconnect: () => { if (this.state !== State.RECONNECTING) { this.state = State.RECONNECTING; this.#endpointInfos.forEach((_, id) => { this.#setSubscriptionConnState(id, FluxSubscriptionState.CONNECTING); }); } }, onFailureToReconnect: () => { if (this.state !== State.INACTIVE) { this.state = State.INACTIVE; this.dispatchEvent(new CustomEvent("state-changed", { detail: { active: false } })); this.#endpointInfos.forEach((_, id) => this.#setSubscriptionConnState(id, FluxSubscriptionState.CLOSED)); } }, ...atmosphereOptions }); } #setSubscriptionConnState(id, state) { const currentState = this.#statusOfSubscriptions.get(id); if (!currentState) { this.#statusOfSubscriptions.set(id, state); this.#onStateChangeCallbacks.get(id)?.(new CustomEvent("subscription-state-change", { detail: { state: this.#statusOfSubscriptions.get(id) } })); } else if (currentState !== state) { this.#statusOfSubscriptions.set(id, state); this.#onStateChangeCallbacks.get(id)?.(new CustomEvent("subscription-state-change", { detail: { state: this.#statusOfSubscriptions.get(id) } })); } } #handleMessage(message) { if (isClientMessage(message)) { const { id } = message; const endpointInfo = this.#endpointInfos.get(id); if (message["@type"] === "update") { const callback = this.#onNextCallbacks.get(id); if (callback) { callback(message.item); } this.#setSubscriptionConnState(id, FluxSubscriptionState.CONNECTED); } else if (message["@type"] === "complete") { this.#onCompleteCallbacks.get(id)?.(); this.#removeSubscription(id); } else { const callback = this.#onErrorCallbacks.get(id); if (callback) { callback(message.message); } this.#removeSubscription(id); if (!callback) { throw new Error(endpointInfo ? `Error in ${endpointInfo.endpointName}.${endpointInfo.methodName}(${JSON.stringify(endpointInfo.params)}): ${message.message}` : `Error in unknown subscription: ${message.message}`); } } } else { throw new Error(`Unknown message from server: ${String(message)}`); } } #removeSubscription(id) { this.#setSubscriptionConnState(id, FluxSubscriptionState.CLOSED); this.#statusOfSubscriptions.delete(id); this.#onStateChangeCallbacks.delete(id); this.#onNextCallbacks.delete(id); this.#onCompleteCallbacks.delete(id); this.#onErrorCallbacks.delete(id); this.#endpointInfos.delete(id); } #send(message) { if (this.state === State.INACTIVE || !this.#socket) { this.#pendingMessages.push(message); } else { this.#socket.push?.(JSON.stringify(message)); } } #sendPendingMessages() { this.#pendingMessages.forEach((msg) => this.#send(msg)); this.#pendingMessages = []; } } //# sourceMappingURL=./FluxConnection.js.map