@vaadin/hilla-frontend
Version:
Hilla core frontend utils
293 lines • 9.44 kB
JavaScript
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