Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28fa43c371 | ||
|
|
0ded9f1cbf | ||
|
|
a41bb7145a |
@@ -2645,6 +2645,8 @@ export default {
|
||||
connectionFailed:
|
||||
'Kunne ikke etablere forbindelse til serveren, forhåndsvisning af liveopdateringer vil ikke fungere.',
|
||||
connectionLost: 'Forbindelse til serveren mistet, forhåndsvisning af liveopdateringer vil ikke fungere.',
|
||||
connectionReconnecting: 'Forbindelse til serveren mistet, forsøger at genoprette forbindelsen…',
|
||||
connectionRestored: 'Forbindelse til serveren genoprettet, forhåndsvisning af liveopdateringer fungerer igen.',
|
||||
},
|
||||
permissions: {
|
||||
FolderCreation: 'Mappeoprettelse',
|
||||
|
||||
@@ -2850,6 +2850,8 @@ export default {
|
||||
viewPublishedContentDeclineButton: 'Stay in preview mode',
|
||||
connectionFailed: 'Could not establish a connection to the server, preview live updates will not work.',
|
||||
connectionLost: 'Connection to the server lost, preview live updates will not work.',
|
||||
connectionReconnecting: 'Connection to the server lost, trying to reconnect…',
|
||||
connectionRestored: 'Connection to the server restored, preview live updates are working again.',
|
||||
},
|
||||
permissions: {
|
||||
FolderCreation: 'Folder creation',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './server-connection.js';
|
||||
export * from './server.context-token.js';
|
||||
export * from './server.context.js';
|
||||
export * from './signalr-reconnect-policy.js';
|
||||
export * from './conditions/index.js';
|
||||
|
||||
export type * from './types.js';
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { UmbSignalRReconnectPolicy } from './signalr-reconnect-policy.js';
|
||||
import type { RetryContext } from '@umbraco-cms/backoffice/external/signalr';
|
||||
import { expect } from '@open-wc/testing';
|
||||
|
||||
const retryContext = (previousRetryCount: number): RetryContext => ({
|
||||
previousRetryCount,
|
||||
elapsedMilliseconds: 0,
|
||||
retryReason: new Error('test'),
|
||||
});
|
||||
|
||||
describe('UmbSignalRReconnectPolicy', () => {
|
||||
let policy: UmbSignalRReconnectPolicy;
|
||||
|
||||
beforeEach(() => {
|
||||
policy = new UmbSignalRReconnectPolicy();
|
||||
});
|
||||
|
||||
it('reconnects immediately on the first attempt', () => {
|
||||
expect(policy.nextRetryDelayInMilliseconds(retryContext(0))).to.equal(0);
|
||||
});
|
||||
|
||||
it('backs off over the next attempts', () => {
|
||||
expect(policy.nextRetryDelayInMilliseconds(retryContext(1))).to.equal(2000);
|
||||
expect(policy.nextRetryDelayInMilliseconds(retryContext(2))).to.equal(5000);
|
||||
expect(policy.nextRetryDelayInMilliseconds(retryContext(3))).to.equal(10000);
|
||||
});
|
||||
|
||||
it('caps the delay and keeps retrying indefinitely', () => {
|
||||
// Beyond the explicit schedule the delay is capped, and a number (never null) is always
|
||||
// returned so the connection never gives up.
|
||||
for (const count of [4, 10, 100, 10000]) {
|
||||
expect(policy.nextRetryDelayInMilliseconds(retryContext(count))).to.equal(30000);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { IRetryPolicy, RetryContext } from '@umbraco-cms/backoffice/external/signalr';
|
||||
|
||||
const RECONNECT_DELAYS_MS = [0, 2000, 5000, 10000];
|
||||
const MAX_RECONNECT_DELAY_MS = 30000;
|
||||
|
||||
/**
|
||||
* A SignalR retry policy that reconnects indefinitely with a capped backoff.
|
||||
* The default `withAutomaticReconnect()` policy gives up after ~60 seconds, which leaves an idle
|
||||
* backoffice (preview, server events) permanently disconnected.
|
||||
*/
|
||||
export class UmbSignalRReconnectPolicy implements IRetryPolicy {
|
||||
/**
|
||||
* Gets the delay before the next reconnect attempt, following the capped backoff schedule.
|
||||
* @param {RetryContext} retryContext - The context for the current retry, including the previous retry count.
|
||||
* @returns {number} The delay in milliseconds before the next reconnect attempt (never null, so retries continue indefinitely).
|
||||
* @memberof UmbSignalRReconnectPolicy
|
||||
*/
|
||||
nextRetryDelayInMilliseconds(retryContext: RetryContext): number {
|
||||
return RECONNECT_DELAYS_MS[retryContext.previousRetryCount] ?? MAX_RECONNECT_DELAY_MS;
|
||||
}
|
||||
}
|
||||
+19
-3
@@ -9,7 +9,7 @@ import {
|
||||
type HubConnection,
|
||||
type IHttpConnectionOptions,
|
||||
} from '@umbraco-cms/backoffice/external/signalr';
|
||||
import { UMB_SERVER_CONTEXT } from '@umbraco-cms/backoffice/server';
|
||||
import { UMB_SERVER_CONTEXT, UmbSignalRReconnectPolicy } from '@umbraco-cms/backoffice/server';
|
||||
import type { Observable } from '@umbraco-cms/backoffice/external/rxjs';
|
||||
import { filter, Subject } from '@umbraco-cms/backoffice/external/rxjs';
|
||||
import { UmbBooleanState } from '@umbraco-cms/backoffice/observable-api';
|
||||
@@ -81,7 +81,15 @@ export class UmbManagementApiServerEventContext extends UmbContextBase {
|
||||
});
|
||||
}
|
||||
|
||||
#initHubConnection(token: string) {
|
||||
async #initHubConnection(token: string) {
|
||||
// Make sure that no previous connection exists, otherwise an orphaned connection would keep
|
||||
// reconnecting in the background and emitting events. Await the stop so it can't race with
|
||||
// building the new connection.
|
||||
if (this.#connection) {
|
||||
await this.#connection.stop();
|
||||
this.#connection = undefined;
|
||||
}
|
||||
|
||||
const serverURL = this.#serverContext?.getServerUrl();
|
||||
|
||||
if (!serverURL) {
|
||||
@@ -102,7 +110,10 @@ export class UmbManagementApiServerEventContext extends UmbContextBase {
|
||||
hubOptions.transport = HttpTransportType.WebSockets;
|
||||
}
|
||||
|
||||
this.#connection = new HubConnectionBuilder().withUrl(serverEventHubUrl, hubOptions).build();
|
||||
this.#connection = new HubConnectionBuilder()
|
||||
.withUrl(serverEventHubUrl, hubOptions)
|
||||
.withAutomaticReconnect(new UmbSignalRReconnectPolicy())
|
||||
.build();
|
||||
|
||||
this.#connection.on('notify', (payload) => {
|
||||
const event: UmbManagementApiServerEventModel = {
|
||||
@@ -113,6 +124,11 @@ export class UmbManagementApiServerEventContext extends UmbContextBase {
|
||||
this.#events.next(event);
|
||||
});
|
||||
|
||||
// While reconnecting we treat the connection as down so cache invalidation consumers refetch
|
||||
// rather than trust a cache that may have missed events during the gap.
|
||||
this.#connection.onreconnecting(() => this.#isConnected.setValue(false));
|
||||
this.#connection.onreconnected(() => this.#isConnected.setValue(true));
|
||||
|
||||
this.#connection
|
||||
.start()
|
||||
.then(() => this.#isConnected.setValue(true))
|
||||
|
||||
@@ -5,7 +5,7 @@ import { UmbBooleanState, UmbStringState } from '@umbraco-cms/backoffice/observa
|
||||
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
|
||||
import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api';
|
||||
import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification';
|
||||
import { UMB_SERVER_CONTEXT } from '@umbraco-cms/backoffice/server';
|
||||
import { UMB_SERVER_CONTEXT, UmbSignalRReconnectPolicy } from '@umbraco-cms/backoffice/server';
|
||||
import type { HubConnection, IHttpConnectionOptions } from '@umbraco-cms/backoffice/external/signalr';
|
||||
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
|
||||
|
||||
@@ -104,10 +104,13 @@ export class UmbPreviewContext extends UmbContextBase {
|
||||
async #initHubConnection(serverUrl: string, serverContext?: typeof UMB_SERVER_CONTEXT.TYPE) {
|
||||
const previewHubUrl = `${serverUrl}/umbraco/PreviewHub`;
|
||||
|
||||
// Make sure that no previous connection exists.
|
||||
// Make sure that no previous connection exists, otherwise an orphaned connection would keep
|
||||
// reconnecting in the background. Clear the reference before stopping so the old connection's
|
||||
// onclose handler sees it is no longer the active one and stays silent.
|
||||
if (this.#connection) {
|
||||
await this.#connection.stop();
|
||||
const previousConnection = this.#connection;
|
||||
this.#connection = undefined;
|
||||
await previousConnection.stop();
|
||||
}
|
||||
|
||||
const skipNegotiation = serverContext?.getServerConnection()?.getSignalRSkipNegotiation() ?? false;
|
||||
@@ -119,7 +122,14 @@ export class UmbPreviewContext extends UmbContextBase {
|
||||
hubOptions.transport = HttpTransportType.WebSockets;
|
||||
}
|
||||
|
||||
this.#connection = new HubConnectionBuilder().withUrl(previewHubUrl, hubOptions).build();
|
||||
this.#connection = new HubConnectionBuilder()
|
||||
.withUrl(previewHubUrl, hubOptions)
|
||||
.withAutomaticReconnect(new UmbSignalRReconnectPolicy())
|
||||
.build();
|
||||
|
||||
// Capture this specific connection so its onclose handler can tell whether it is still the active
|
||||
// one; if it has since been replaced or cleared, the close was deliberate and should stay silent.
|
||||
const connection = this.#connection;
|
||||
|
||||
this.#connection.on('refreshed', (payload) => {
|
||||
if (payload === this.#unique.getValue()) {
|
||||
@@ -127,7 +137,34 @@ export class UmbPreviewContext extends UmbContextBase {
|
||||
}
|
||||
});
|
||||
|
||||
this.#connection.onreconnecting(() => {
|
||||
this.#notificationContext?.peek('warning', {
|
||||
data: {
|
||||
headline: this.#localize.term('general_preview'),
|
||||
message: this.#localize.term('preview_connectionReconnecting'),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
this.#connection.onreconnected(() => {
|
||||
// A 'refreshed' event may have been missed while disconnected, so reload the iframe to catch up.
|
||||
this.#setPreviewUrl({ rnd: Math.random() });
|
||||
this.#notificationContext?.peek('positive', {
|
||||
data: {
|
||||
headline: this.#localize.term('general_preview'),
|
||||
message: this.#localize.term('preview_connectionRestored'),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
this.#connection.onclose(() => {
|
||||
// With automatic reconnect, onclose only fires when we stop the connection ourselves (teardown,
|
||||
// exit, or replacing it) or for a close that cannot be recovered. Only warn when this is still
|
||||
// the active connection — i.e. an unexpected drop we did not initiate.
|
||||
if (this.#connection !== connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#notificationContext?.peek('warning', {
|
||||
data: {
|
||||
headline: this.#localize.term('general_preview'),
|
||||
|
||||
Reference in New Issue
Block a user