Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: reconnect web components after session expiration (#20407) (CP: 24.5) #20439

Merged
merged 2 commits into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ public DefaultRegistry(ApplicationConnection connection,
set(InitialPropertiesHandler.class, new InitialPropertiesHandler(this));

// Classes with dependencies, in correct order
set(Heartbeat.class, new Heartbeat(this));
Supplier<Heartbeat> heartbeatSupplier = () -> new Heartbeat(this);
set(Heartbeat.class, heartbeatSupplier);
set(ConnectionStateHandler.class,
new DefaultConnectionStateHandler(this));
set(XhrConnection.class, new XhrConnection(this));
Expand Down
70 changes: 60 additions & 10 deletions flow-client/src/main/java/com/vaadin/client/SystemErrorHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

import com.google.gwt.core.client.Scheduler;
import com.google.gwt.xhr.client.XMLHttpRequest;

import com.vaadin.client.bootstrap.ErrorMessage;
import com.vaadin.client.communication.MessageHandler;
import com.vaadin.client.gwt.elemental.js.util.Xhr;
Expand Down Expand Up @@ -144,47 +145,96 @@ public void handleUnrecoverableError(String caption, String message,
}
}

private boolean resyncInProgress = false;

/**
* Send GET async request to acquire new JSESSIONID, browser will set cookie
* automatically based on Set-Cookie response header.
*/
private void resynchronizeSession() {
if (resyncInProgress) {
Console.debug(
"Web components resynchronization already in progress");
return;
}
resyncInProgress = true;
String serviceUrl = registry.getApplicationConfiguration()
.getServiceUrl() + "web-component/web-component-bootstrap.js";

// Stop heart beat to prevent requests during resynchronization
registry.getHeartbeat().setInterval(-1);
if (registry.getPushConfiguration().isPushEnabled()) {
registry.getMessageSender().setPushEnabled(false, false);
}

String sessionResyncUri = SharedUtil.addGetParameter(serviceUrl,
ApplicationConstants.REQUEST_TYPE_PARAMETER,
ApplicationConstants.REQUEST_TYPE_WEBCOMPONENT_RESYNC);

Xhr.get(sessionResyncUri, new Xhr.Callback() {
Xhr.getWithCredentials(sessionResyncUri, new Xhr.Callback() {
@Override
public void onFail(XMLHttpRequest xhr, Exception exception) {
registry.getHeartbeat().setInterval(registry
.getApplicationConfiguration().getHeartbeatInterval());
handleError(exception);
}

@Override
public void onSuccess(XMLHttpRequest xhr) {

Console.log(
"Received xhr HTTP session resynchronization message: "
+ xhr.getResponseText());

registry.reset();
registry.getUILifecycle().setState(UILifecycle.UIState.RUNNING);
// Make sure heartbeat has not been restarted. This is
// especially important if the uiId gets reset after session
// expiration, to prevent multiple heartbeats requests for
// different ui
registry.getHeartbeat().setInterval(-1);

int uiId = registry.getApplicationConfiguration().getUIId();
ValueMap json = MessageHandler
.parseWrappedJson(xhr.getResponseText());
int newUiId = json.getInt(ApplicationConstants.UI_ID);
if (newUiId != uiId) {
Console.debug("UI ID switched from " + uiId + " to "
+ newUiId + " after resynchronization");
registry.getApplicationConfiguration().setUIId(newUiId);
}
registry.reset();

registry.getUILifecycle().setState(UILifecycle.UIState.RUNNING);
registry.getMessageHandler().handleMessage(json);
registry.getApplicationConfiguration()
.setUIId(json.getInt(ApplicationConstants.UI_ID));

Scheduler.get().scheduleDeferred(() -> Arrays
.stream(registry.getApplicationConfiguration()
.getExportedWebComponents())
.forEach(SystemErrorHandler.this::recreateNodes));
boolean pushEnabled = registry.getPushConfiguration()
.isPushEnabled();
if (pushEnabled) {
// PUSH connection might have been closed in response to
// sever session expiration. If PUSH is required, reconnect
// before recreating web components to make sure the
// connected events can be propagated to the server.
// PUSH reconnection is deferred to allow current request
// to complete and process the Set-Cookie header.
Scheduler.get().scheduleDeferred(() -> {
Console.debug("Re-establish PUSH connection");
registry.getMessageSender().setPushEnabled(true);
Scheduler.get().scheduleDeferred(
() -> recreateWebComponents());
});
} else {
Scheduler.get()
.scheduleDeferred(() -> recreateWebComponents());
}
}
});
}

private void recreateWebComponents() {
Arrays.stream(registry.getApplicationConfiguration()
.getExportedWebComponents())
.forEach(SystemErrorHandler.this::recreateNodes);
resyncInProgress = false;
}

private native void recreateNodes(String elementName)
/*-{
var elements = document.getElementsByTagName(elementName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,6 @@ public AtmospherePushConnection(Registry registry) {
} else {
config.setStringValue(key, value);
}

});

String pushServletMapping = getPushConfiguration()
Expand Down Expand Up @@ -686,6 +685,7 @@ protected final native AtmosphereConfiguration createConfig()
fallbackTransport: 'long-polling',
contentType: 'application/json; charset=UTF-8',
reconnectInterval: 5000,
withCredentials: true,
maxWebsocketErrorRetries: 12,
timeout: -1,
maxReconnectOnClose: 10000000,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -534,8 +534,11 @@ private void pauseHeartbeats() {
}

private void resumeHeartbeats() {
registry.getHeartbeat().setInterval(
registry.getApplicationConfiguration().getHeartbeatInterval());
// Resume heart beat only if it was not terminated (interval == -1)
if (registry.getHeartbeat().getInterval() >= 0) {
registry.getHeartbeat().setInterval(registry
.getApplicationConfiguration().getHeartbeatInterval());
}
}

private boolean redirectIfRefreshToken(String message) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import com.google.gwt.user.client.Timer;
import com.google.gwt.xhr.client.XMLHttpRequest;

import com.vaadin.client.Console;
import com.vaadin.client.Registry;
import com.vaadin.client.gwt.elemental.js.util.Xhr;
Expand Down Expand Up @@ -74,8 +75,13 @@ public Heartbeat(Registry registry) {
*/
public void send() {
timer.cancel();
if (interval < 0) {
Console.debug("Heartbeat terminated, skipping request");
return;
}

Console.debug("Sending heartbeat request...");

Xhr.post(uri, null, "text/plain; charset=utf-8", new Xhr.Callback() {

@Override
Expand All @@ -86,12 +92,19 @@ public void onSuccess(XMLHttpRequest xhr) {

@Override
public void onFail(XMLHttpRequest xhr, Exception e) {

// Handler should stop the application if heartbeat should no
// longer be sent
if (e == null) {
registry.getConnectionStateHandler()
.heartbeatInvalidStatusCode(xhr);
// Heartbeat has been terminated before response processing.
// Most likely a session expiration happened, and it has
// already been handled by another component.
if (interval < 0) {
Console.debug(
"Heartbeat terminated, ignoring failure.");
} else {
registry.getConnectionStateHandler()
.heartbeatInvalidStatusCode(xhr);
}
} else {
registry.getConnectionStateHandler().heartbeatException(xhr,
e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@
package com.vaadin.client.communication;

import com.google.gwt.core.client.GWT;
import com.vaadin.client.Console;

import com.vaadin.client.ConnectionIndicator;
import com.vaadin.client.Console;
import com.vaadin.client.Registry;
import com.vaadin.flow.shared.ApplicationConstants;

Expand Down Expand Up @@ -91,10 +92,14 @@ public void sendInvocationsToServer() {
return;
}

if (registry.getRequestResponseTracker().hasActiveRequest()
|| (push != null && !push.isActive())) {
boolean hasActiveRequest = registry.getRequestResponseTracker()
.hasActiveRequest();
if (hasActiveRequest || (push != null && !push.isActive())) {
// There is an active request or push is enabled but not active
// -> send when current request completes or push becomes active
Console.debug("Postpone sending invocations to server because of "
+ (hasActiveRequest ? "active request"
: "PUSH not active"));
} else {
doSendInvocationsToServer();
}
Expand Down Expand Up @@ -200,9 +205,11 @@ public void send(final JsonObject payload) {
// server after a reconnection.
// Reference will be cleaned up once the server confirms it has
// seen this message
Console.debug("send PUSH");
pushPendingMessage = payload;
push.push(payload);
} else {
Console.log("send XHR");
registry.getXhrConnection().send(payload);
}
}
Expand All @@ -215,7 +222,22 @@ public void send(final JsonObject payload) {
* <code>false</code> to disable the push connection.
*/
public void setPushEnabled(boolean enabled) {
if (enabled && push == null) {
setPushEnabled(enabled, true);
}

/**
* Sets the status for the push connection.
*
* @param enabled
* <code>true</code> to enable the push connection;
* <code>false</code> to disable the push connection.
* @param reEnableIfNeeded
* <code>true</code> if push should be re-enabled after
* disconnection if configuration changed; <code>false</code> to
* prevent reconnection.
*/
public void setPushEnabled(boolean enabled, boolean reEnableIfNeeded) {
if (enabled && (push == null || !push.isActive())) {
push = pushConnectionFactory.create(registry);
} else if (!enabled && push != null && push.isActive()) {
push.disconnect(() -> {
Expand All @@ -225,7 +247,8 @@ public void setPushEnabled(boolean enabled) {
* old connection to disconnect, now is the right time to open a
* new connection
*/
if (registry.getPushConfiguration().isPushEnabled()) {
if (reEnableIfNeeded
&& registry.getPushConfiguration().isPushEnabled()) {
setPushEnabled(true);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package com.vaadin.client.communication;

import com.google.gwt.core.client.Scheduler;

import com.vaadin.client.Console;
import com.vaadin.client.Registry;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import com.google.gwt.core.client.JavaScriptException;
import com.google.gwt.xhr.client.ReadyStateChangeHandler;
import com.google.gwt.xhr.client.XMLHttpRequest;

import com.vaadin.client.Console;

import elemental.client.Browser;
Expand Down Expand Up @@ -90,6 +91,23 @@ public static XMLHttpRequest get(String url, Callback callback) {
return request(create(), "GET", url, callback);
}

/**
* Send a GET request to the <code>url</code> including credentials in XHR,
* and dispatch updates to the <code>callback</code>.
*
* @param url
* the URL
* @param callback
* the callback to be notified
* @return a reference to the sent XmlHttpRequest
*/
public static XMLHttpRequest getWithCredentials(String url,
Callback callback) {
XMLHttpRequest request = create();
request.setWithCredentials(true);
return request(request, "GET", url, callback);
}

/**
* Send a GET request to the <code>url</code> and dispatch updates to the
* <code>callback</code>.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,8 @@ protected boolean handleWebComponentResyncRequest(BootstrapContext context,
json.put(ApplicationConstants.UI_ID, context.getUI().getUIId());
json.put(ApplicationConstants.UIDL_SECURITY_TOKEN_ID,
context.getUI().getCsrfToken());
json.put(ApplicationConstants.UIDL_PUSH_ID,
context.getUI().getSession().getPushId());
String responseString = "for(;;);[" + JsonUtil.stringify(json) + "]";

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ protected String bootstrapNpm(boolean productionMode) {
const delay = 200;
const poll = async () => {
try {
const response = await fetch(bootstrapSrc, { method: 'HEAD', headers: { 'X-DevModePoll': 'true' } });
const response = await fetch(bootstrapSrc, { method: 'HEAD', credentials: 'include', headers: { 'X-DevModePoll': 'true' } });
if (response.headers.has('X-DevModePending')) {
setTimeout(poll, delay);
} else {
Expand Down
3 changes: 3 additions & 0 deletions flow-tests/test-frontend/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@
<!-- npm and pnpm dev mode and prod mode -->
<!-- run production build before dev build as dev build has npm i in thread -->
<module>vite-embedded-webcomponent-resync</module>
<module>vite-embedded-webcomponent-resync-ws</module>
<module>vite-embedded-webcomponent-resync-wsxhr</module>
<module>vite-embedded-webcomponent-resync-longpolling</module>
<module>test-npm/pom-production.xml</module>
<module>test-npm</module>
<module>test-pnpm/pom-production.xml</module>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
!**/index.html
Loading
Loading