From 0a6050d904a8febab918dc5006c3eccad62766cf Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Sat, 2 May 2026 09:27:13 +0200 Subject: [PATCH 01/10] WIP --- .../Server/ConfigurationServerController.cs | 32 ++++++++++++++- .../SignalRTransportTypeExtensions.cs | 39 +++++++++++++++++++ .../Routing/BackOfficeAreaRoutes.cs | 24 ++++++++++-- .../Routing/PreviewRoutes.cs | 23 +++++++++-- .../ServerConfigurationResponseModel.cs | 5 +++ .../SignalRClientSettingsResponseModel.cs | 10 +++++ .../Models/SignalRClientSettings.cs | 25 ++++++++++++ .../Configuration/Models/SignalRSettings.cs | 36 +++++++++++++++++ .../Models/SignalRTransportType.cs | 35 +++++++++++++++++ src/Umbraco.Core/Constants-Configuration.cs | 5 +++ .../UmbracoBuilder.Configuration.cs | 3 +- .../packages/core/server/server-connection.ts | 14 +++++++ .../global-context/server-event.context.ts | 24 +++++++++--- .../preview/context/preview.context.ts | 18 ++++++--- .../Customizations/UmbracoCustomizations.cs | 9 ++++- .../Routing/BackOfficeAreaRoutesTests.cs | 6 ++- .../Routing/PreviewRoutesTests.cs | 4 +- 17 files changed, 288 insertions(+), 24 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Extensions/SignalRTransportTypeExtensions.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Server/SignalRClientSettingsResponseModel.cs create mode 100644 src/Umbraco.Core/Configuration/Models/SignalRClientSettings.cs create mode 100644 src/Umbraco.Core/Configuration/Models/SignalRSettings.cs create mode 100644 src/Umbraco.Core/Configuration/Models/SignalRTransportType.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Server/ConfigurationServerController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Server/ConfigurationServerController.cs index 5fe99133a744..0f78c5c255ad 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Server/ConfigurationServerController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Server/ConfigurationServerController.cs @@ -28,6 +28,7 @@ public class ConfigurationServerController : ServerControllerBase private readonly GlobalSettings _globalSettings; private readonly IBackOfficeExternalLoginProviders _externalLoginProviders; private readonly IHostingEnvironment _hostingEnvironment; + private readonly SignalRSettings _signalRSettings; /// /// Initializes a new instance of the class. @@ -36,13 +37,38 @@ public class ConfigurationServerController : ServerControllerBase /// The global settings options. /// The external login providers for back office. /// The hosting environment. + /// The SignalR settings options. [ActivatorUtilitiesConstructor] - public ConfigurationServerController(IOptions securitySettings, IOptions globalSettings, IBackOfficeExternalLoginProviders externalLoginProviders, IHostingEnvironment hostingEnvironment) + public ConfigurationServerController( + IOptions securitySettings, + IOptions globalSettings, + IBackOfficeExternalLoginProviders externalLoginProviders, + IHostingEnvironment hostingEnvironment, + IOptions signalRSettings) { _securitySettings = securitySettings.Value; _globalSettings = globalSettings.Value; _externalLoginProviders = externalLoginProviders; _hostingEnvironment = hostingEnvironment; + _signalRSettings = signalRSettings.Value; + } + + /// + /// Initializes a new instance of the class. + /// + /// The options. + /// The options. + /// The external login providers used for back office authentication. + /// The hosting environment. + [Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 20.")] + public ConfigurationServerController(IOptions securitySettings, IOptions globalSettings, IBackOfficeExternalLoginProviders externalLoginProviders, IHostingEnvironment hostingEnvironment) + : this( + securitySettings, + globalSettings, + externalLoginProviders, + hostingEnvironment, + StaticServiceProvider.Instance.GetRequiredService>()) + { } /// @@ -78,6 +104,10 @@ public Task Configuration(CancellationToken cancellationToken) VersionCheckPeriod = _globalSettings.VersionCheckPeriod, AllowLocalLogin = _externalLoginProviders.HasDenyLocalLogin() is false, UmbracoCssPath = _hostingEnvironment.ToAbsolute(_globalSettings.UmbracoCssPath), + SignalR = new SignalRClientSettingsResponseModel + { + SkipNegotiation = _signalRSettings.Client.SkipNegotiation, + }, }; return Task.FromResult(Ok(responseModel)); diff --git a/src/Umbraco.Cms.Api.Management/Extensions/SignalRTransportTypeExtensions.cs b/src/Umbraco.Cms.Api.Management/Extensions/SignalRTransportTypeExtensions.cs new file mode 100644 index 000000000000..dabb33e15dae --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Extensions/SignalRTransportTypeExtensions.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Http.Connections; +using Umbraco.Cms.Core.Configuration.Models; + +namespace Umbraco.Cms.Api.Management.Extensions; + +/// +/// Extension methods for converting +/// to . +/// +internal static class SignalRTransportTypeExtensions +{ + /// + /// Converts a flags value to the equivalent + /// flags value by mapping each flag individually. + /// + /// The Umbraco transport type flags to convert. + /// The equivalent flags value. + internal static HttpTransportType ToHttpTransportType(this SignalRTransportType transportType) + { + var result = HttpTransportType.None; + + if (transportType.HasFlag(SignalRTransportType.WebSockets)) + { + result |= HttpTransportType.WebSockets; + } + + if (transportType.HasFlag(SignalRTransportType.ServerSentEvents)) + { + result |= HttpTransportType.ServerSentEvents; + } + + if (transportType.HasFlag(SignalRTransportType.LongPolling)) + { + result |= HttpTransportType.LongPolling; + } + + return result; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs b/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs index 136e0c148301..6e337810395a 100644 --- a/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs +++ b/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs @@ -1,8 +1,12 @@ using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http.Connections; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; using Umbraco.Cms.Api.Management.Controllers.Security; +using Umbraco.Cms.Api.Management.Extensions; using Umbraco.Cms.Api.Management.ServerEvents; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.Routing; using Umbraco.Extensions; @@ -15,12 +19,16 @@ namespace Umbraco.Cms.Api.Management.Routing; public sealed class BackOfficeAreaRoutes : IAreaRoutes { private readonly IRuntimeState _runtimeState; + private readonly SignalRSettings _signalRSettings; /// /// Initializes a new instance of the class. /// - public BackOfficeAreaRoutes(IRuntimeState runtimeState) - => _runtimeState = runtimeState; + public BackOfficeAreaRoutes(IRuntimeState runtimeState, IOptions signalRSettings) + { + _runtimeState = runtimeState; + _signalRSettings = signalRSettings.Value; + } /// @@ -30,8 +38,16 @@ public void CreateRoutes(IEndpointRouteBuilder endpoints) { MapMinimalBackOffice(endpoints); - endpoints.MapHub(Constants.System.UmbracoPathSegment + Constants.Web.BackofficeSignalRHub); - endpoints.MapHub(Constants.System.UmbracoPathSegment + Constants.Web.ServerEventSignalRHub); + endpoints.MapHub(Constants.System.UmbracoPathSegment + Constants.Web.BackofficeSignalRHub, ConfigureHubEndpoint); + endpoints.MapHub(Constants.System.UmbracoPathSegment + Constants.Web.ServerEventSignalRHub, ConfigureHubEndpoint); + } + } + + private void ConfigureHubEndpoint(HttpConnectionDispatcherOptions options) + { + if (_signalRSettings.Transports.HasValue) + { + options.Transports = _signalRSettings.Transports.Value.ToHttpTransportType(); } } diff --git a/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs b/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs index 4d66c819f204..ea605c448204 100644 --- a/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs +++ b/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs @@ -1,7 +1,11 @@ using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http.Connections; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Api.Management.Extensions; using Umbraco.Cms.Api.Management.Preview; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.Routing; @@ -13,13 +17,18 @@ namespace Umbraco.Cms.Api.Management.Routing; public sealed class PreviewRoutes : IAreaRoutes { private readonly IRuntimeState _runtimeState; + private readonly SignalRSettings _signalRSettings; /// /// Initializes a new instance of the class, configuring preview routing based on the application's runtime state. /// /// An instance representing the current runtime state of the Umbraco application. - public PreviewRoutes(IRuntimeState runtimeState) - => _runtimeState = runtimeState; + /// The SignalR settings options. + public PreviewRoutes(IRuntimeState runtimeState, IOptions signalRSettings) + { + _runtimeState = runtimeState; + _signalRSettings = signalRSettings.Value; + } /// /// Creates the preview routes on the specified endpoint route builder. @@ -29,7 +38,15 @@ public void CreateRoutes(IEndpointRouteBuilder endpoints) { if (_runtimeState.Level is RuntimeLevel.Install or RuntimeLevel.Upgrade or RuntimeLevel.Upgrading or RuntimeLevel.Run) { - endpoints.MapHub(GetPreviewHubRoute()); + endpoints.MapHub(GetPreviewHubRoute(), ConfigureHubEndpoint); + } + } + + private void ConfigureHubEndpoint(HttpConnectionDispatcherOptions options) + { + if (_signalRSettings.Transports.HasValue) + { + options.Transports = _signalRSettings.Transports.Value.ToHttpTransportType(); } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Server/ServerConfigurationResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Server/ServerConfigurationResponseModel.cs index 390e17e962f9..4add42e6fee6 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Server/ServerConfigurationResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Server/ServerConfigurationResponseModel.cs @@ -21,4 +21,9 @@ public class ServerConfigurationResponseModel /// Gets or sets the relative or absolute path to the Umbraco CSS file used by the application. /// public string UmbracoCssPath { get; set; } = string.Empty; + + /// + /// Gets or sets the client-side SignalR settings. + /// + public SignalRClientSettingsResponseModel SignalR { get; set; } = new(); } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Server/SignalRClientSettingsResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Server/SignalRClientSettingsResponseModel.cs new file mode 100644 index 000000000000..cc4422f2d3a9 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Server/SignalRClientSettingsResponseModel.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Server; + +/// +/// Represents client-side SignalR settings returned by the server configuration endpoint. +/// +public class SignalRClientSettingsResponseModel +{ + /// Gets or sets a value indicating whether the client should skip the SignalR negotiate round-trip. + public bool SkipNegotiation { get; set; } +} diff --git a/src/Umbraco.Core/Configuration/Models/SignalRClientSettings.cs b/src/Umbraco.Core/Configuration/Models/SignalRClientSettings.cs new file mode 100644 index 000000000000..a573239c0e86 --- /dev/null +++ b/src/Umbraco.Core/Configuration/Models/SignalRClientSettings.cs @@ -0,0 +1,25 @@ +using System.ComponentModel; + +namespace Umbraco.Cms.Core.Configuration.Models; + +/// +/// Client-side SignalR settings that are forwarded to the backoffice frontend +/// via the server configuration endpoint. +/// +public class SignalRClientSettings +{ + internal const bool StaticSkipNegotiation = false; + + /// + /// Gets or sets a value indicating whether the client should skip the SignalR negotiate + /// round-trip and connect directly via WebSockets. + /// + /// + /// When set to true the client will set both skipNegotiation and + /// transport: WebSockets on the hub connection, which avoids the negotiate HTTP + /// request that causes failures in load-balanced deployments without sticky sessions. + /// This is safe for self-hosted SignalR but must not be used with Azure SignalR Service. + /// + [DefaultValue(StaticSkipNegotiation)] + public bool SkipNegotiation { get; set; } = StaticSkipNegotiation; +} diff --git a/src/Umbraco.Core/Configuration/Models/SignalRSettings.cs b/src/Umbraco.Core/Configuration/Models/SignalRSettings.cs new file mode 100644 index 000000000000..4d70ede64805 --- /dev/null +++ b/src/Umbraco.Core/Configuration/Models/SignalRSettings.cs @@ -0,0 +1,36 @@ +namespace Umbraco.Cms.Core.Configuration.Models; + +/// +/// Typed configuration options for SignalR settings. +/// +/// +/// +/// Server-side transport restrictions are applied to every Umbraco SignalR hub endpoint +/// via HttpConnectionDispatcherOptions.Transports. Client-side settings in +/// are forwarded to the backoffice frontend through the +/// /umbraco/management/api/v1/server/configuration endpoint. +/// +/// +/// Downstream packages (e.g. Umbraco Cloud) can configure these settings via +/// IConfigureOptions<SignalRSettings> or appsettings.json +/// under Umbraco:CMS:SignalR. +/// +/// +[UmbracoOptions(Constants.Configuration.ConfigSignalR)] +public class SignalRSettings +{ + /// + /// Gets or sets the transport protocols the server will accept for SignalR connections. + /// + /// + /// When null (the default), all transports are accepted (framework default behaviour). + /// Set to to restrict connections to WebSockets + /// only, which is required for load-balanced deployments without sticky sessions. + /// + public SignalRTransportType? Transports { get; set; } + + /// + /// Gets or sets the client-side SignalR settings forwarded to the backoffice frontend. + /// + public SignalRClientSettings Client { get; set; } = new(); +} diff --git a/src/Umbraco.Core/Configuration/Models/SignalRTransportType.cs b/src/Umbraco.Core/Configuration/Models/SignalRTransportType.cs new file mode 100644 index 000000000000..425147ba023a --- /dev/null +++ b/src/Umbraco.Core/Configuration/Models/SignalRTransportType.cs @@ -0,0 +1,35 @@ +namespace Umbraco.Cms.Core.Configuration.Models; + +/// +/// Specifies the transport protocols available for SignalR connections. +/// +/// +/// This enum mirrors Microsoft.AspNetCore.Http.Connections.HttpTransportType so that +/// can live in Umbraco.Core, which does not reference the +/// ASP.NET Core shared framework. The integer values are kept identical to allow safe +/// flag-by-flag conversion via +/// SignalRTransportTypeExtensions.ToHttpTransportType in the web layer. +/// +[Flags] +public enum SignalRTransportType +{ + /// + /// No transport. + /// + None = 0, + + /// + /// The WebSockets transport. + /// + WebSockets = 1, + + /// + /// The Server-Sent Events transport. + /// + ServerSentEvents = 2, + + /// + /// The HTTP Long Polling transport. + /// + LongPolling = 4, +} diff --git a/src/Umbraco.Core/Constants-Configuration.cs b/src/Umbraco.Core/Constants-Configuration.cs index f441ac20daf4..75b0fecca43a 100644 --- a/src/Umbraco.Core/Constants-Configuration.cs +++ b/src/Umbraco.Core/Constants-Configuration.cs @@ -302,6 +302,11 @@ public static class Configuration /// public const string ConfigWebsite = ConfigPrefix + "Website"; + /// + /// The configuration key for SignalR settings. + /// + public const string ConfigSignalR = ConfigPrefix + "SignalR"; + /// /// Contains constants for named options used in configuration. /// diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs index 66358f147633..7ab343b1bc8b 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs @@ -103,7 +103,8 @@ public static IUmbracoBuilder AddConfiguration(this IUmbracoBuilder builder) .AddUmbracoOptions() .AddUmbracoOptions() .AddUmbracoOptions() - .AddUmbracoOptions(); + .AddUmbracoOptions() + .AddUmbracoOptions(); // Configure connection string and ensure it's updated when the configuration changes builder.Services.AddSingleton, ConfigureConnectionStrings>(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts b/src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts index 024cdee97cbb..eebc5dd690c5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts @@ -23,11 +23,22 @@ export class UmbServerConnection extends UmbControllerBase { #umbracoCssPath = new UmbStringState(undefined); umbracoCssPath = this.#umbracoCssPath.asObservable(); + #signalRSkipNegotiation = false; + constructor(host: UmbControllerHost, serverUrl: string) { super(host); this.#url = serverUrl; } + /** + * Gets whether the server has configured SignalR to skip the negotiate round-trip. + * @returns {boolean} + * @memberof UmbServerConnection + */ + getSignalRSkipNegotiation() { + return this.#signalRSkipNegotiation; + } + /** * Connects to the server. * @memberof UmbServerConnection @@ -90,5 +101,8 @@ export class UmbServerConnection extends UmbControllerBase { this.#allowLocalLogin.setValue(data?.allowLocalLogin ?? false); this.#allowPasswordReset.setValue(data?.allowPasswordReset ?? false); this.#umbracoCssPath.setValue(data?.umbracoCssPath); + // TODO: Remove the type assertion once the generated types include the signalR property. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.#signalRSkipNegotiation = (data as any)?.signalR?.skipNegotiation === true; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/server-event.context.ts b/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/server-event.context.ts index 79fb2b7ffb33..7295ac3b5ea1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/server-event.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/server-event.context.ts @@ -3,7 +3,12 @@ import type { UmbManagementApiServerEventModel } from './types.js'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UMB_AUTH_CONTEXT } from '@umbraco-cms/backoffice/auth'; -import { HubConnectionBuilder, type HubConnection } from '@umbraco-cms/backoffice/external/signalr'; +import { + HubConnectionBuilder, + HttpTransportType, + type HubConnection, + type IHttpConnectionOptions, +} from '@umbraco-cms/backoffice/external/signalr'; import { UMB_SERVER_CONTEXT } from '@umbraco-cms/backoffice/server'; import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; import { filter, Subject } from '@umbraco-cms/backoffice/external/rxjs'; @@ -86,11 +91,18 @@ export class UmbManagementApiServerEventContext extends UmbContextBase { // TODO: get the url from a server config? const serverEventHubUrl = `${serverURL}/umbraco/serverEventHub`; - this.#connection = new HubConnectionBuilder() - .withUrl(serverEventHubUrl, { - accessTokenFactory: () => token, - }) - .build(); + const skipNegotiation = this.#serverContext?.getServerConnection()?.getSignalRSkipNegotiation() ?? false; + + const hubOptions: IHttpConnectionOptions = { + accessTokenFactory: () => token, + }; + + if (skipNegotiation) { + hubOptions.skipNegotiation = true; + hubOptions.transport = HttpTransportType.WebSockets; + } + + this.#connection = new HubConnectionBuilder().withUrl(serverEventHubUrl, hubOptions).build(); this.#connection.on('notify', (payload) => { const event: UmbManagementApiServerEventModel = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/preview/context/preview.context.ts b/src/Umbraco.Web.UI.Client/src/packages/preview/context/preview.context.ts index 7e315c3a31e6..8c1cd7c3a542 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/preview/context/preview.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/preview/context/preview.context.ts @@ -1,12 +1,12 @@ import { UmbPreviewRepository } from '../repository/index.js'; import { UMB_PREVIEW_CONTEXT } from './preview.context-token.js'; -import { HubConnectionBuilder } from '@umbraco-cms/backoffice/external/signalr'; +import { HubConnectionBuilder, HttpTransportType } from '@umbraco-cms/backoffice/external/signalr'; import { UmbBooleanState, UmbStringState } from '@umbraco-cms/backoffice/observable-api'; 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 type { HubConnection } from '@umbraco-cms/backoffice/external/signalr'; +import type { HubConnection, IHttpConnectionOptions } from '@umbraco-cms/backoffice/external/signalr'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; interface UmbPreviewIframeArgs { @@ -80,7 +80,8 @@ export class UmbPreviewContext extends UmbContextBase { this.#setPreviewUrl({ serverUrl }); - this.#initHubConnection(serverUrl); + const skipNegotiation = serverContext?.getServerConnection()?.getSignalRSkipNegotiation() ?? false; + this.#initHubConnection(serverUrl, skipNegotiation); }); this.consumeContext(UMB_NOTIFICATION_CONTEXT, (notificationContext) => { @@ -101,7 +102,7 @@ export class UmbPreviewContext extends UmbContextBase { } } - async #initHubConnection(serverUrl: string) { + async #initHubConnection(serverUrl: string, skipNegotiation: boolean = false) { const previewHubUrl = `${serverUrl}/umbraco/PreviewHub`; // Make sure that no previous connection exists. @@ -110,7 +111,14 @@ export class UmbPreviewContext extends UmbContextBase { this.#connection = undefined; } - this.#connection = new HubConnectionBuilder().withUrl(previewHubUrl).build(); + const hubOptions: IHttpConnectionOptions = {}; + + if (skipNegotiation) { + hubOptions.skipNegotiation = true; + hubOptions.transport = HttpTransportType.WebSockets; + } + + this.#connection = new HubConnectionBuilder().withUrl(previewHubUrl, hubOptions).build(); this.#connection.on('refreshed', (payload) => { if (payload === this.#unique.getValue()) { diff --git a/tests/Umbraco.Tests.UnitTests/AutoFixture/Customizations/UmbracoCustomizations.cs b/tests/Umbraco.Tests.UnitTests/AutoFixture/Customizations/UmbracoCustomizations.cs index 2593f08295b9..9cd7303fe7f9 100644 --- a/tests/Umbraco.Tests.UnitTests/AutoFixture/Customizations/UmbracoCustomizations.cs +++ b/tests/Umbraco.Tests.UnitTests/AutoFixture/Customizations/UmbracoCustomizations.cs @@ -2,6 +2,7 @@ using AutoFixture.Kernel; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; using Moq; using Umbraco.Cms.Api.Management.Controllers.Security; using Umbraco.Cms.Api.Management.Routing; @@ -47,10 +48,14 @@ public void Customize(IFixture fixture) x.With(settings => settings.ApplicationVirtualPath, string.Empty)); fixture.Customize(u => u.FromFactory( - () => new BackOfficeAreaRoutes(Mock.Of(x => x.Level == RuntimeLevel.Run)))); + () => new BackOfficeAreaRoutes( + Mock.Of(x => x.Level == RuntimeLevel.Run), + Options.Create(new SignalRSettings())))); fixture.Customize(u => u.FromFactory( - () => new PreviewRoutes(Mock.Of(x => x.Level == RuntimeLevel.Run)))); + () => new PreviewRoutes( + Mock.Of(x => x.Level == RuntimeLevel.Run), + Options.Create(new SignalRSettings())))); var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() }; fixture.Customize(x => x.FromFactory(() => httpContextAccessor.HttpContext)); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Routing/BackOfficeAreaRoutesTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Routing/BackOfficeAreaRoutesTests.cs index c374d07ae842..033ec0960dc3 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Routing/BackOfficeAreaRoutesTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Routing/BackOfficeAreaRoutesTests.cs @@ -2,11 +2,13 @@ // See LICENSE for more details. using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; using Moq; using NUnit.Framework; using Umbraco.Cms.Api.Management.Controllers.Security; using Umbraco.Cms.Api.Management.Routing; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Controllers; @@ -56,7 +58,9 @@ private void AssertMinimalBackOfficeRoutes(EndpointDataSource route) } private BackOfficeAreaRoutes GetBackOfficeAreaRoutes(RuntimeLevel level) - => new BackOfficeAreaRoutes(Mock.Of(x => x.Level == level)); + => new BackOfficeAreaRoutes( + Mock.Of(x => x.Level == level), + Options.Create(new SignalRSettings())); [IsBackOffice] private class Testing1Controller : UmbracoApiController diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Routing/PreviewRoutesTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Routing/PreviewRoutesTests.cs index c72c82d7926f..18bdd4fe3fdc 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Routing/PreviewRoutesTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Routing/PreviewRoutesTests.cs @@ -52,5 +52,7 @@ public void RuntimeState_All_Routes(RuntimeLevel level) } private PreviewRoutes GetRoutes(RuntimeLevel level) - => new PreviewRoutes(Mock.Of(x => x.Level == level)); + => new PreviewRoutes( + Mock.Of(x => x.Level == level), + Options.Create(new SignalRSettings())); } From f384f359c8a4ee752083a137c1fab735819bd920 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Mon, 4 May 2026 17:16:51 +0200 Subject: [PATCH 02/10] Cleanup and type generation --- .../Server/ConfigurationServerController.cs | 3 +- src/Umbraco.Cms.Api.Management/OpenApi.json | 55 ++++++++++++------- .../SignalRClientSettingsResponseModel.cs | 7 +++ .../Models/SignalRClientSettings.cs | 25 --------- .../Configuration/Models/SignalRSettings.cs | 16 ++++-- .../packages/core/backend-api/types.gen.ts | 22 +++++--- .../packages/core/server/server-connection.ts | 33 +++++++++-- .../global-context/server-event.context.ts | 17 +++--- .../preview/context/preview.context.ts | 17 ++++-- 9 files changed, 117 insertions(+), 78 deletions(-) delete mode 100644 src/Umbraco.Core/Configuration/Models/SignalRClientSettings.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Server/ConfigurationServerController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Server/ConfigurationServerController.cs index 0f78c5c255ad..4e0593773a7e 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Server/ConfigurationServerController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Server/ConfigurationServerController.cs @@ -106,7 +106,8 @@ public Task Configuration(CancellationToken cancellationToken) UmbracoCssPath = _hostingEnvironment.ToAbsolute(_globalSettings.UmbracoCssPath), SignalR = new SignalRClientSettingsResponseModel { - SkipNegotiation = _signalRSettings.Client.SkipNegotiation, + SkipNegotiation = _signalRSettings.ClientShouldSkipNegotiation, + Transports = _signalRSettings.Transports, }, }; diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 4e15b40802b3..c10491345788 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -36859,9 +36859,6 @@ "oneOf": [ { "$ref": "#/components/schemas/NoopSetupTwoFactorModel" - }, - { - "$ref": "#/components/schemas/TwoFactorAuthInfo" } ] } @@ -36956,9 +36953,6 @@ "oneOf": [ { "$ref": "#/components/schemas/NoopSetupTwoFactorModel" - }, - { - "$ref": "#/components/schemas/TwoFactorAuthInfo" } ] } @@ -50055,6 +50049,7 @@ "required": [ "allowLocalLogin", "allowPasswordReset", + "signalR", "umbracoCssPath", "versionCheckPeriod" ], @@ -50072,6 +50067,13 @@ }, "umbracoCssPath": { "type": "string" + }, + "signalR": { + "oneOf": [ + { + "$ref": "#/components/schemas/SignalRClientSettingsResponseModel" + } + ] } }, "additionalProperties": false @@ -50147,6 +50149,31 @@ }, "additionalProperties": false }, + "SignalRClientSettingsResponseModel": { + "required": [ + "skipNegotiation", + "transports" + ], + "type": "object", + "properties": { + "skipNegotiation": { + "type": "boolean" + }, + "transports": { + "$ref": "#/components/schemas/SignalRTransportTypeModel" + } + }, + "additionalProperties": false + }, + "SignalRTransportTypeModel": { + "enum": [ + "None", + "WebSockets", + "ServerSentEvents", + "LongPolling" + ], + "type": "string" + }, "SortingRequestModel": { "required": [ "sorting" @@ -51098,20 +51125,6 @@ ], "type": "string" }, - "TwoFactorAuthInfo": { - "type": "object", - "properties": { - "qrCodeSetupImageUrl": { - "type": "string", - "nullable": true - }, - "secret": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, "UnknownTypePermissionPresentationModel": { "required": [ "$type", @@ -53674,4 +53687,4 @@ "name": "Webhook" } ] -} +} \ No newline at end of file diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Server/SignalRClientSettingsResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Server/SignalRClientSettingsResponseModel.cs index cc4422f2d3a9..be4d74e64717 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Server/SignalRClientSettingsResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Server/SignalRClientSettingsResponseModel.cs @@ -1,3 +1,5 @@ +using Umbraco.Cms.Core.Configuration.Models; + namespace Umbraco.Cms.Api.Management.ViewModels.Server; /// @@ -7,4 +9,9 @@ public class SignalRClientSettingsResponseModel { /// Gets or sets a value indicating whether the client should skip the SignalR negotiate round-trip. public bool SkipNegotiation { get; set; } + + /// + /// Gets or sets the transport protocols the server will accept for SignalR connections. + /// + public SignalRTransportType? Transports { get; set; } } diff --git a/src/Umbraco.Core/Configuration/Models/SignalRClientSettings.cs b/src/Umbraco.Core/Configuration/Models/SignalRClientSettings.cs deleted file mode 100644 index a573239c0e86..000000000000 --- a/src/Umbraco.Core/Configuration/Models/SignalRClientSettings.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.ComponentModel; - -namespace Umbraco.Cms.Core.Configuration.Models; - -/// -/// Client-side SignalR settings that are forwarded to the backoffice frontend -/// via the server configuration endpoint. -/// -public class SignalRClientSettings -{ - internal const bool StaticSkipNegotiation = false; - - /// - /// Gets or sets a value indicating whether the client should skip the SignalR negotiate - /// round-trip and connect directly via WebSockets. - /// - /// - /// When set to true the client will set both skipNegotiation and - /// transport: WebSockets on the hub connection, which avoids the negotiate HTTP - /// request that causes failures in load-balanced deployments without sticky sessions. - /// This is safe for self-hosted SignalR but must not be used with Azure SignalR Service. - /// - [DefaultValue(StaticSkipNegotiation)] - public bool SkipNegotiation { get; set; } = StaticSkipNegotiation; -} diff --git a/src/Umbraco.Core/Configuration/Models/SignalRSettings.cs b/src/Umbraco.Core/Configuration/Models/SignalRSettings.cs index 4d70ede64805..f03d6be54387 100644 --- a/src/Umbraco.Core/Configuration/Models/SignalRSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/SignalRSettings.cs @@ -1,3 +1,5 @@ +using System.ComponentModel; + namespace Umbraco.Cms.Core.Configuration.Models; /// @@ -6,8 +8,8 @@ namespace Umbraco.Cms.Core.Configuration.Models; /// /// /// Server-side transport restrictions are applied to every Umbraco SignalR hub endpoint -/// via HttpConnectionDispatcherOptions.Transports. Client-side settings in -/// are forwarded to the backoffice frontend through the +/// via HttpConnectionDispatcherOptions.Transports. These settings are also forwarded to the +/// client via the server configuration endpoint, which is accessible via the /// /umbraco/management/api/v1/server/configuration endpoint. /// /// @@ -19,18 +21,22 @@ namespace Umbraco.Cms.Core.Configuration.Models; [UmbracoOptions(Constants.Configuration.ConfigSignalR)] public class SignalRSettings { + internal const bool StaticClientShouldSkipNegotiation = false; + /// /// Gets or sets the transport protocols the server will accept for SignalR connections. /// /// - /// When null (the default), all transports are accepted (framework default behaviour). + /// When null (the default), all transports are accepted (framework default behavior). /// Set to to restrict connections to WebSockets /// only, which is required for load-balanced deployments without sticky sessions. /// public SignalRTransportType? Transports { get; set; } /// - /// Gets or sets the client-side SignalR settings forwarded to the backoffice frontend. + /// Gets or sets a value indicating whether the client should skip the SignalR negotiate + /// round-trip and connect directly on one of the configured . /// - public SignalRClientSettings Client { get; set; } = new(); + [DefaultValue(StaticClientShouldSkipNegotiation)] + public bool ClientShouldSkipNegotiation { get; set; } = StaticClientShouldSkipNegotiation; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts b/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts index 1038c22e397f..5c87ae7b668c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts @@ -2456,6 +2456,7 @@ export type ServerConfigurationResponseModel = { versionCheckPeriod: number; allowLocalLogin: boolean; umbracoCssPath: string; + signalR: SignalRClientSettingsResponseModel; }; export type ServerInformationResponseModel = { @@ -2477,6 +2478,18 @@ export type SetAvatarRequestModel = { file: ReferenceByIdModel; }; +export type SignalRClientSettingsResponseModel = { + skipNegotiation: boolean; + transports: SignalRTransportTypeModel; +}; + +export enum SignalRTransportTypeModel { + NONE = 'None', + WEB_SOCKETS = 'WebSockets', + SERVER_SENT_EVENTS = 'ServerSentEvents', + LONG_POLLING = 'LongPolling' +} + export type SortingRequestModel = { parent?: ReferenceByIdModel | null; sorting: Array; @@ -2715,11 +2728,6 @@ export enum TreeItemKindModel { ALL = 'All' } -export type TwoFactorAuthInfo = { - qrCodeSetupImageUrl?: string | null; - secret?: string | null; -}; - export type UnknownTypePermissionPresentationModel = { $type: string; verbs: Array; @@ -17301,7 +17309,7 @@ export type GetUserCurrent2FaByProviderNameResponses = { /** * OK */ - 200: NoopSetupTwoFactorModel | TwoFactorAuthInfo; + 200: NoopSetupTwoFactorModel; }; export type GetUserCurrent2FaByProviderNameResponse = GetUserCurrent2FaByProviderNameResponses[keyof GetUserCurrent2FaByProviderNameResponses]; @@ -17336,7 +17344,7 @@ export type PostUserCurrent2FaByProviderNameResponses = { /** * OK */ - 200: NoopSetupTwoFactorModel | TwoFactorAuthInfo; + 200: NoopSetupTwoFactorModel; }; export type PostUserCurrent2FaByProviderNameResponse = PostUserCurrent2FaByProviderNameResponses[keyof PostUserCurrent2FaByProviderNameResponses]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts b/src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts index eebc5dd690c5..db7f7504d52a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts @@ -1,8 +1,9 @@ import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import { RuntimeLevelModel, ServerService } from '@umbraco-cms/backoffice/external/backend-api'; +import { RuntimeLevelModel, ServerService, SignalRTransportTypeModel } from '@umbraco-cms/backoffice/external/backend-api'; import { UmbBooleanState, UmbNumberState, UmbStringState } from '@umbraco-cms/backoffice/observable-api'; import { tryExecute } from '@umbraco-cms/backoffice/resources'; +import { HttpTransportType } from '@umbraco-cms/backoffice/external/signalr'; export class UmbServerConnection extends UmbControllerBase { #url: string; @@ -24,6 +25,7 @@ export class UmbServerConnection extends UmbControllerBase { umbracoCssPath = this.#umbracoCssPath.asObservable(); #signalRSkipNegotiation = false; + #signalRTransports: HttpTransportType | undefined; constructor(host: UmbControllerHost, serverUrl: string) { super(host); @@ -39,6 +41,15 @@ export class UmbServerConnection extends UmbControllerBase { return this.#signalRSkipNegotiation; } + /** + * Gets the SignalR transport types configured by the server, or undefined if all transports are allowed. + * @returns {HttpTransportType | undefined} + * @memberof UmbServerConnection + */ + getSignalRTransports() { + return this.#signalRTransports; + } + /** * Connects to the server. * @memberof UmbServerConnection @@ -101,8 +112,22 @@ export class UmbServerConnection extends UmbControllerBase { this.#allowLocalLogin.setValue(data?.allowLocalLogin ?? false); this.#allowPasswordReset.setValue(data?.allowPasswordReset ?? false); this.#umbracoCssPath.setValue(data?.umbracoCssPath); - // TODO: Remove the type assertion once the generated types include the signalR property. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - this.#signalRSkipNegotiation = (data as any)?.signalR?.skipNegotiation === true; + this.#signalRSkipNegotiation = data?.signalR?.skipNegotiation === true; + this.#signalRTransports = this.#mapTransportType(data?.signalR?.transports); + } + + #mapTransportType(transport?: SignalRTransportTypeModel): HttpTransportType | undefined { + switch (transport) { + case SignalRTransportTypeModel.WEB_SOCKETS: + return HttpTransportType.WebSockets; + case SignalRTransportTypeModel.SERVER_SENT_EVENTS: + return HttpTransportType.ServerSentEvents; + case SignalRTransportTypeModel.LONG_POLLING: + return HttpTransportType.LongPolling; + case SignalRTransportTypeModel.NONE: + return HttpTransportType.None; + default: + return undefined; + } } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/server-event.context.ts b/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/server-event.context.ts index 7295ac3b5ea1..335704164689 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/server-event.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/server-event.context.ts @@ -3,12 +3,7 @@ import type { UmbManagementApiServerEventModel } from './types.js'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UMB_AUTH_CONTEXT } from '@umbraco-cms/backoffice/auth'; -import { - HubConnectionBuilder, - HttpTransportType, - type HubConnection, - type IHttpConnectionOptions, -} from '@umbraco-cms/backoffice/external/signalr'; +import { HubConnectionBuilder, type HubConnection, type IHttpConnectionOptions } from '@umbraco-cms/backoffice/external/signalr'; import { UMB_SERVER_CONTEXT } from '@umbraco-cms/backoffice/server'; import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; import { filter, Subject } from '@umbraco-cms/backoffice/external/rxjs'; @@ -91,15 +86,19 @@ export class UmbManagementApiServerEventContext extends UmbContextBase { // TODO: get the url from a server config? const serverEventHubUrl = `${serverURL}/umbraco/serverEventHub`; - const skipNegotiation = this.#serverContext?.getServerConnection()?.getSignalRSkipNegotiation() ?? false; + const serverConnection = this.#serverContext?.getServerConnection(); const hubOptions: IHttpConnectionOptions = { accessTokenFactory: () => token, }; - if (skipNegotiation) { + const transports = serverConnection?.getSignalRTransports(); + if (transports !== undefined) { + hubOptions.transport = transports; + } + + if (serverConnection?.getSignalRSkipNegotiation()) { hubOptions.skipNegotiation = true; - hubOptions.transport = HttpTransportType.WebSockets; } this.#connection = new HubConnectionBuilder().withUrl(serverEventHubUrl, hubOptions).build(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/preview/context/preview.context.ts b/src/Umbraco.Web.UI.Client/src/packages/preview/context/preview.context.ts index 8c1cd7c3a542..02f5cb9b31da 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/preview/context/preview.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/preview/context/preview.context.ts @@ -1,6 +1,6 @@ import { UmbPreviewRepository } from '../repository/index.js'; import { UMB_PREVIEW_CONTEXT } from './preview.context-token.js'; -import { HubConnectionBuilder, HttpTransportType } from '@umbraco-cms/backoffice/external/signalr'; +import { HubConnectionBuilder } from '@umbraco-cms/backoffice/external/signalr'; import { UmbBooleanState, UmbStringState } from '@umbraco-cms/backoffice/observable-api'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; @@ -80,8 +80,7 @@ export class UmbPreviewContext extends UmbContextBase { this.#setPreviewUrl({ serverUrl }); - const skipNegotiation = serverContext?.getServerConnection()?.getSignalRSkipNegotiation() ?? false; - this.#initHubConnection(serverUrl, skipNegotiation); + this.#initHubConnection(serverUrl, serverContext); }); this.consumeContext(UMB_NOTIFICATION_CONTEXT, (notificationContext) => { @@ -102,7 +101,7 @@ export class UmbPreviewContext extends UmbContextBase { } } - async #initHubConnection(serverUrl: string, skipNegotiation: boolean = false) { + async #initHubConnection(serverUrl: string, serverContext?: typeof UMB_SERVER_CONTEXT.TYPE) { const previewHubUrl = `${serverUrl}/umbraco/PreviewHub`; // Make sure that no previous connection exists. @@ -111,11 +110,17 @@ export class UmbPreviewContext extends UmbContextBase { this.#connection = undefined; } + const serverConnection = serverContext?.getServerConnection(); + const hubOptions: IHttpConnectionOptions = {}; - if (skipNegotiation) { + const transports = serverConnection?.getSignalRTransports(); + if (transports !== undefined) { + hubOptions.transport = transports; + } + + if (serverConnection?.getSignalRSkipNegotiation()) { hubOptions.skipNegotiation = true; - hubOptions.transport = HttpTransportType.WebSockets; } this.#connection = new HubConnectionBuilder().withUrl(previewHubUrl, hubOptions).build(); From b61eb92a9c6d042eb059c940c9ccfc7b89c1349f Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Mon, 4 May 2026 22:02:00 +0200 Subject: [PATCH 03/10] Improve obsoletions --- .../Server/ConfigurationServerController.cs | 2 +- .../Routing/PreviewRoutes.cs | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Server/ConfigurationServerController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Server/ConfigurationServerController.cs index 4e0593773a7e..18622add3191 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Server/ConfigurationServerController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Server/ConfigurationServerController.cs @@ -60,7 +60,7 @@ public ConfigurationServerController( /// The options. /// The external login providers used for back office authentication. /// The hosting environment. - [Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 20.")] + [Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] public ConfigurationServerController(IOptions securitySettings, IOptions globalSettings, IBackOfficeExternalLoginProviders externalLoginProviders, IHostingEnvironment hostingEnvironment) : this( securitySettings, diff --git a/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs b/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs index ea605c448204..faaa132544c3 100644 --- a/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs +++ b/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs @@ -1,11 +1,13 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http.Connections; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Api.Management.Extensions; using Umbraco.Cms.Api.Management.Preview; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.Routing; @@ -19,6 +21,18 @@ public sealed class PreviewRoutes : IAreaRoutes private readonly IRuntimeState _runtimeState; private readonly SignalRSettings _signalRSettings; + /// + /// Initializes a new instance of the class, configuring preview routing based on the application's runtime state. + /// + /// An instance representing the current runtime state of the Umbraco application. + [Obsolete("Use the non obsoleted constructor instead. Scheduled for removal in v19")] + public PreviewRoutes(IRuntimeState runtimeState) + : this( + runtimeState, + StaticServiceProvider.Instance.GetRequiredService>()) + { + } + /// /// Initializes a new instance of the class, configuring preview routing based on the application's runtime state. /// @@ -58,3 +72,4 @@ private void ConfigureHubEndpoint(HttpConnectionDispatcherOptions options) /// public string GetPreviewHubRoute() => $"/{Constants.System.UmbracoPathSegment}/{nameof(PreviewHub)}"; } + From 095fd5cf4a9c7e6a6758af316e7ec5511d3ad4ce Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Mon, 4 May 2026 22:32:10 +0200 Subject: [PATCH 04/10] Fix removed constructor --- .../Routing/BackOfficeAreaRoutes.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs b/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs index 6e337810395a..a2a512d2a212 100644 --- a/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs +++ b/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs @@ -1,12 +1,14 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http.Connections; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Api.Management.Controllers.Security; using Umbraco.Cms.Api.Management.Extensions; using Umbraco.Cms.Api.Management.ServerEvents; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.Routing; using Umbraco.Extensions; @@ -21,6 +23,12 @@ public sealed class BackOfficeAreaRoutes : IAreaRoutes private readonly IRuntimeState _runtimeState; private readonly SignalRSettings _signalRSettings; + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Use the non obsoleted constructor instead. Scheduled for removal in v19")] + public BackOfficeAreaRoutes(IRuntimeState runtimeState): this(runtimeState, StaticServiceProvider.Instance.GetRequiredService>()){} + /// /// Initializes a new instance of the class. /// From 73345f0b9d2e99facaf50914249087968fce9835 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Mon, 4 May 2026 23:11:14 +0200 Subject: [PATCH 05/10] Simplify logic because of SignalR's JS limitations --- .../Server/ConfigurationServerController.cs | 1 - .../SignalRTransportTypeExtensions.cs | 39 ------------------- src/Umbraco.Cms.Api.Management/OpenApi.json | 15 +------ .../Routing/BackOfficeAreaRoutes.cs | 5 +-- .../Routing/PreviewRoutes.cs | 5 +-- .../SignalRClientSettingsResponseModel.cs | 7 ---- .../Configuration/Models/SignalRSettings.cs | 24 +++++------- .../Models/SignalRTransportType.cs | 35 ----------------- .../packages/core/backend-api/types.gen.ts | 8 ---- .../packages/core/server/server-connection.ts | 29 +------------- .../global-context/server-event.context.ts | 17 ++++---- .../preview/context/preview.context.ts | 12 ++---- 12 files changed, 29 insertions(+), 168 deletions(-) delete mode 100644 src/Umbraco.Cms.Api.Management/Extensions/SignalRTransportTypeExtensions.cs delete mode 100644 src/Umbraco.Core/Configuration/Models/SignalRTransportType.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Server/ConfigurationServerController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Server/ConfigurationServerController.cs index 18622add3191..40360dfda998 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Server/ConfigurationServerController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Server/ConfigurationServerController.cs @@ -107,7 +107,6 @@ public Task Configuration(CancellationToken cancellationToken) SignalR = new SignalRClientSettingsResponseModel { SkipNegotiation = _signalRSettings.ClientShouldSkipNegotiation, - Transports = _signalRSettings.Transports, }, }; diff --git a/src/Umbraco.Cms.Api.Management/Extensions/SignalRTransportTypeExtensions.cs b/src/Umbraco.Cms.Api.Management/Extensions/SignalRTransportTypeExtensions.cs deleted file mode 100644 index dabb33e15dae..000000000000 --- a/src/Umbraco.Cms.Api.Management/Extensions/SignalRTransportTypeExtensions.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Microsoft.AspNetCore.Http.Connections; -using Umbraco.Cms.Core.Configuration.Models; - -namespace Umbraco.Cms.Api.Management.Extensions; - -/// -/// Extension methods for converting -/// to . -/// -internal static class SignalRTransportTypeExtensions -{ - /// - /// Converts a flags value to the equivalent - /// flags value by mapping each flag individually. - /// - /// The Umbraco transport type flags to convert. - /// The equivalent flags value. - internal static HttpTransportType ToHttpTransportType(this SignalRTransportType transportType) - { - var result = HttpTransportType.None; - - if (transportType.HasFlag(SignalRTransportType.WebSockets)) - { - result |= HttpTransportType.WebSockets; - } - - if (transportType.HasFlag(SignalRTransportType.ServerSentEvents)) - { - result |= HttpTransportType.ServerSentEvents; - } - - if (transportType.HasFlag(SignalRTransportType.LongPolling)) - { - result |= HttpTransportType.LongPolling; - } - - return result; - } -} diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index c10491345788..54397e3677ce 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -50151,29 +50151,16 @@ }, "SignalRClientSettingsResponseModel": { "required": [ - "skipNegotiation", - "transports" + "skipNegotiation" ], "type": "object", "properties": { "skipNegotiation": { "type": "boolean" - }, - "transports": { - "$ref": "#/components/schemas/SignalRTransportTypeModel" } }, "additionalProperties": false }, - "SignalRTransportTypeModel": { - "enum": [ - "None", - "WebSockets", - "ServerSentEvents", - "LongPolling" - ], - "type": "string" - }, "SortingRequestModel": { "required": [ "sorting" diff --git a/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs b/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs index a2a512d2a212..c24b3d22f1af 100644 --- a/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs +++ b/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Api.Management.Controllers.Security; -using Umbraco.Cms.Api.Management.Extensions; using Umbraco.Cms.Api.Management.ServerEvents; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; @@ -53,9 +52,9 @@ public void CreateRoutes(IEndpointRouteBuilder endpoints) private void ConfigureHubEndpoint(HttpConnectionDispatcherOptions options) { - if (_signalRSettings.Transports.HasValue) + if (_signalRSettings.ClientShouldSkipNegotiation) { - options.Transports = _signalRSettings.Transports.Value.ToHttpTransportType(); + options.Transports = HttpTransportType.WebSockets; } } diff --git a/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs b/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs index faaa132544c3..d2bf8d693c5a 100644 --- a/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs +++ b/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Umbraco.Cms.Api.Management.Extensions; using Umbraco.Cms.Api.Management.Preview; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; @@ -58,9 +57,9 @@ public void CreateRoutes(IEndpointRouteBuilder endpoints) private void ConfigureHubEndpoint(HttpConnectionDispatcherOptions options) { - if (_signalRSettings.Transports.HasValue) + if (_signalRSettings.ClientShouldSkipNegotiation) { - options.Transports = _signalRSettings.Transports.Value.ToHttpTransportType(); + options.Transports = HttpTransportType.WebSockets; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Server/SignalRClientSettingsResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Server/SignalRClientSettingsResponseModel.cs index be4d74e64717..cc4422f2d3a9 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Server/SignalRClientSettingsResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Server/SignalRClientSettingsResponseModel.cs @@ -1,5 +1,3 @@ -using Umbraco.Cms.Core.Configuration.Models; - namespace Umbraco.Cms.Api.Management.ViewModels.Server; /// @@ -9,9 +7,4 @@ public class SignalRClientSettingsResponseModel { /// Gets or sets a value indicating whether the client should skip the SignalR negotiate round-trip. public bool SkipNegotiation { get; set; } - - /// - /// Gets or sets the transport protocols the server will accept for SignalR connections. - /// - public SignalRTransportType? Transports { get; set; } } diff --git a/src/Umbraco.Core/Configuration/Models/SignalRSettings.cs b/src/Umbraco.Core/Configuration/Models/SignalRSettings.cs index f03d6be54387..31895ba00807 100644 --- a/src/Umbraco.Core/Configuration/Models/SignalRSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/SignalRSettings.cs @@ -7,10 +7,9 @@ namespace Umbraco.Cms.Core.Configuration.Models; /// /// /// -/// Server-side transport restrictions are applied to every Umbraco SignalR hub endpoint -/// via HttpConnectionDispatcherOptions.Transports. These settings are also forwarded to the -/// client via the server configuration endpoint, which is accessible via the -/// /umbraco/management/api/v1/server/configuration endpoint. +/// When is enabled, all hub endpoints are restricted +/// to WebSocket transport and the client skips the negotiate round-trip. The setting is forwarded +/// to the client via the /umbraco/management/api/v1/server/configuration endpoint. /// /// /// Downstream packages (e.g. Umbraco Cloud) can configure these settings via @@ -24,19 +23,16 @@ public class SignalRSettings internal const bool StaticClientShouldSkipNegotiation = false; /// - /// Gets or sets the transport protocols the server will accept for SignalR connections. + /// Gets or sets a value indicating whether the client should skip the SignalR negotiate + /// round-trip and connect directly via WebSockets. /// /// - /// When null (the default), all transports are accepted (framework default behavior). - /// Set to to restrict connections to WebSockets - /// only, which is required for load-balanced deployments without sticky sessions. + /// When true, the server restricts all hub endpoints to the WebSocket transport only + /// (via HttpConnectionDispatcherOptions.Transports) and the client is instructed to + /// set skipNegotiation = true with transport = WebSockets. This eliminates the + /// negotiate HTTP request that causes failures in load-balanced deployments without sticky sessions. + /// This is safe for self-hosted SignalR but must not be used with Azure SignalR Service. /// - public SignalRTransportType? Transports { get; set; } - - /// - /// Gets or sets a value indicating whether the client should skip the SignalR negotiate - /// round-trip and connect directly on one of the configured . - /// [DefaultValue(StaticClientShouldSkipNegotiation)] public bool ClientShouldSkipNegotiation { get; set; } = StaticClientShouldSkipNegotiation; } diff --git a/src/Umbraco.Core/Configuration/Models/SignalRTransportType.cs b/src/Umbraco.Core/Configuration/Models/SignalRTransportType.cs deleted file mode 100644 index 425147ba023a..000000000000 --- a/src/Umbraco.Core/Configuration/Models/SignalRTransportType.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace Umbraco.Cms.Core.Configuration.Models; - -/// -/// Specifies the transport protocols available for SignalR connections. -/// -/// -/// This enum mirrors Microsoft.AspNetCore.Http.Connections.HttpTransportType so that -/// can live in Umbraco.Core, which does not reference the -/// ASP.NET Core shared framework. The integer values are kept identical to allow safe -/// flag-by-flag conversion via -/// SignalRTransportTypeExtensions.ToHttpTransportType in the web layer. -/// -[Flags] -public enum SignalRTransportType -{ - /// - /// No transport. - /// - None = 0, - - /// - /// The WebSockets transport. - /// - WebSockets = 1, - - /// - /// The Server-Sent Events transport. - /// - ServerSentEvents = 2, - - /// - /// The HTTP Long Polling transport. - /// - LongPolling = 4, -} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts b/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts index 5c87ae7b668c..9f2f8ed2b0c0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts @@ -2480,16 +2480,8 @@ export type SetAvatarRequestModel = { export type SignalRClientSettingsResponseModel = { skipNegotiation: boolean; - transports: SignalRTransportTypeModel; }; -export enum SignalRTransportTypeModel { - NONE = 'None', - WEB_SOCKETS = 'WebSockets', - SERVER_SENT_EVENTS = 'ServerSentEvents', - LONG_POLLING = 'LongPolling' -} - export type SortingRequestModel = { parent?: ReferenceByIdModel | null; sorting: Array; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts b/src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts index db7f7504d52a..74edd372d358 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts @@ -1,9 +1,8 @@ import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import { RuntimeLevelModel, ServerService, SignalRTransportTypeModel } from '@umbraco-cms/backoffice/external/backend-api'; +import { RuntimeLevelModel, ServerService } from '@umbraco-cms/backoffice/external/backend-api'; import { UmbBooleanState, UmbNumberState, UmbStringState } from '@umbraco-cms/backoffice/observable-api'; import { tryExecute } from '@umbraco-cms/backoffice/resources'; -import { HttpTransportType } from '@umbraco-cms/backoffice/external/signalr'; export class UmbServerConnection extends UmbControllerBase { #url: string; @@ -25,7 +24,6 @@ export class UmbServerConnection extends UmbControllerBase { umbracoCssPath = this.#umbracoCssPath.asObservable(); #signalRSkipNegotiation = false; - #signalRTransports: HttpTransportType | undefined; constructor(host: UmbControllerHost, serverUrl: string) { super(host); @@ -41,15 +39,6 @@ export class UmbServerConnection extends UmbControllerBase { return this.#signalRSkipNegotiation; } - /** - * Gets the SignalR transport types configured by the server, or undefined if all transports are allowed. - * @returns {HttpTransportType | undefined} - * @memberof UmbServerConnection - */ - getSignalRTransports() { - return this.#signalRTransports; - } - /** * Connects to the server. * @memberof UmbServerConnection @@ -113,21 +102,5 @@ export class UmbServerConnection extends UmbControllerBase { this.#allowPasswordReset.setValue(data?.allowPasswordReset ?? false); this.#umbracoCssPath.setValue(data?.umbracoCssPath); this.#signalRSkipNegotiation = data?.signalR?.skipNegotiation === true; - this.#signalRTransports = this.#mapTransportType(data?.signalR?.transports); - } - - #mapTransportType(transport?: SignalRTransportTypeModel): HttpTransportType | undefined { - switch (transport) { - case SignalRTransportTypeModel.WEB_SOCKETS: - return HttpTransportType.WebSockets; - case SignalRTransportTypeModel.SERVER_SENT_EVENTS: - return HttpTransportType.ServerSentEvents; - case SignalRTransportTypeModel.LONG_POLLING: - return HttpTransportType.LongPolling; - case SignalRTransportTypeModel.NONE: - return HttpTransportType.None; - default: - return undefined; - } } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/server-event.context.ts b/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/server-event.context.ts index 335704164689..7295ac3b5ea1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/server-event.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/server-event.context.ts @@ -3,7 +3,12 @@ import type { UmbManagementApiServerEventModel } from './types.js'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UMB_AUTH_CONTEXT } from '@umbraco-cms/backoffice/auth'; -import { HubConnectionBuilder, type HubConnection, type IHttpConnectionOptions } from '@umbraco-cms/backoffice/external/signalr'; +import { + HubConnectionBuilder, + HttpTransportType, + type HubConnection, + type IHttpConnectionOptions, +} from '@umbraco-cms/backoffice/external/signalr'; import { UMB_SERVER_CONTEXT } from '@umbraco-cms/backoffice/server'; import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; import { filter, Subject } from '@umbraco-cms/backoffice/external/rxjs'; @@ -86,19 +91,15 @@ export class UmbManagementApiServerEventContext extends UmbContextBase { // TODO: get the url from a server config? const serverEventHubUrl = `${serverURL}/umbraco/serverEventHub`; - const serverConnection = this.#serverContext?.getServerConnection(); + const skipNegotiation = this.#serverContext?.getServerConnection()?.getSignalRSkipNegotiation() ?? false; const hubOptions: IHttpConnectionOptions = { accessTokenFactory: () => token, }; - const transports = serverConnection?.getSignalRTransports(); - if (transports !== undefined) { - hubOptions.transport = transports; - } - - if (serverConnection?.getSignalRSkipNegotiation()) { + if (skipNegotiation) { hubOptions.skipNegotiation = true; + hubOptions.transport = HttpTransportType.WebSockets; } this.#connection = new HubConnectionBuilder().withUrl(serverEventHubUrl, hubOptions).build(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/preview/context/preview.context.ts b/src/Umbraco.Web.UI.Client/src/packages/preview/context/preview.context.ts index 02f5cb9b31da..e8e24d65f9e2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/preview/context/preview.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/preview/context/preview.context.ts @@ -1,6 +1,6 @@ import { UmbPreviewRepository } from '../repository/index.js'; import { UMB_PREVIEW_CONTEXT } from './preview.context-token.js'; -import { HubConnectionBuilder } from '@umbraco-cms/backoffice/external/signalr'; +import { HubConnectionBuilder, HttpTransportType } from '@umbraco-cms/backoffice/external/signalr'; import { UmbBooleanState, UmbStringState } from '@umbraco-cms/backoffice/observable-api'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; @@ -110,17 +110,13 @@ export class UmbPreviewContext extends UmbContextBase { this.#connection = undefined; } - const serverConnection = serverContext?.getServerConnection(); + const skipNegotiation = serverContext?.getServerConnection()?.getSignalRSkipNegotiation() ?? false; const hubOptions: IHttpConnectionOptions = {}; - const transports = serverConnection?.getSignalRTransports(); - if (transports !== undefined) { - hubOptions.transport = transports; - } - - if (serverConnection?.getSignalRSkipNegotiation()) { + if (skipNegotiation) { hubOptions.skipNegotiation = true; + hubOptions.transport = HttpTransportType.WebSockets; } this.#connection = new HubConnectionBuilder().withUrl(previewHubUrl, hubOptions).build(); From aa82d4343706869bd5361fcdc4f943d6c3d2d907 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 5 May 2026 13:25:43 +0200 Subject: [PATCH 06/10] Apply suggestions from code review Co-authored-by: Andy Butland --- .../Routing/BackOfficeAreaRoutes.cs | 9 +++++++-- src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs | 2 +- .../src/packages/core/server/server-connection.ts | 3 ++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs b/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs index c24b3d22f1af..371574cd5b16 100644 --- a/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs +++ b/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs @@ -25,8 +25,13 @@ public sealed class BackOfficeAreaRoutes : IAreaRoutes /// /// Initializes a new instance of the class. /// - [Obsolete("Use the non obsoleted constructor instead. Scheduled for removal in v19")] - public BackOfficeAreaRoutes(IRuntimeState runtimeState): this(runtimeState, StaticServiceProvider.Instance.GetRequiredService>()){} + [Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] + public BackOfficeAreaRoutes(IRuntimeState runtimeState) + : this( + runtimeState, + StaticServiceProvider.Instance.GetRequiredService>()) + { + } /// /// Initializes a new instance of the class. diff --git a/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs b/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs index d2bf8d693c5a..6d4a627af4ae 100644 --- a/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs +++ b/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs @@ -24,7 +24,7 @@ public sealed class PreviewRoutes : IAreaRoutes /// Initializes a new instance of the class, configuring preview routing based on the application's runtime state. /// /// An instance representing the current runtime state of the Umbraco application. - [Obsolete("Use the non obsoleted constructor instead. Scheduled for removal in v19")] + [Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] public PreviewRoutes(IRuntimeState runtimeState) : this( runtimeState, diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts b/src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts index 74edd372d358..bdad158ab8b5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts @@ -23,7 +23,8 @@ export class UmbServerConnection extends UmbControllerBase { #umbracoCssPath = new UmbStringState(undefined); umbracoCssPath = this.#umbracoCssPath.asObservable(); - #signalRSkipNegotiation = false; + #signalRSkipNegotiation = new UmbBooleanState(false); + signalRSkipNegotiation = this.#signalRSkipNegotiation.asObservable(); constructor(host: UmbControllerHost, serverUrl: string) { super(host); From 54909dd8a18e70c1ed5f44af7195651a64b7c594 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 5 May 2026 13:41:26 +0200 Subject: [PATCH 07/10] Add SignalRSettings to Schema --- tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs b/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs index 6a2f1cb38727..8780cf026f8e 100644 --- a/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs +++ b/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs @@ -79,6 +79,8 @@ public class UmbracoCmsDefinition public required DistributedJobSettings DistributedJobSettings { get; set; } public required WebsiteSettings Website { get; set; } + + public required SignalRSettings SignalR { get; set; } } public class InstallDefaultDataNamedOptions From 6adf47b87f747add163cb69e3fc4d559da18ffe6 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 5 May 2026 14:13:46 +0200 Subject: [PATCH 08/10] Abstrack SignalRRoutes class --- .../Routing/BackOfficeAreaRoutes.cs | 13 +------ .../Routing/PreviewRoutes.cs | 13 +------ .../Routing/SignalRRoutesBase.cs | 37 +++++++++++++++++++ 3 files changed, 41 insertions(+), 22 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Routing/SignalRRoutesBase.cs diff --git a/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs b/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs index 371574cd5b16..6db23e8be0eb 100644 --- a/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs +++ b/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs @@ -17,10 +17,9 @@ namespace Umbraco.Cms.Api.Management.Routing; /// /// Creates routes for the back office area. /// -public sealed class BackOfficeAreaRoutes : IAreaRoutes +public sealed class BackOfficeAreaRoutes : SignalRRoutesBase, IAreaRoutes { private readonly IRuntimeState _runtimeState; - private readonly SignalRSettings _signalRSettings; /// /// Initializes a new instance of the class. @@ -37,9 +36,9 @@ public BackOfficeAreaRoutes(IRuntimeState runtimeState) /// Initializes a new instance of the class. /// public BackOfficeAreaRoutes(IRuntimeState runtimeState, IOptions signalRSettings) + : base(signalRSettings) { _runtimeState = runtimeState; - _signalRSettings = signalRSettings.Value; } @@ -55,14 +54,6 @@ public void CreateRoutes(IEndpointRouteBuilder endpoints) } } - private void ConfigureHubEndpoint(HttpConnectionDispatcherOptions options) - { - if (_signalRSettings.ClientShouldSkipNegotiation) - { - options.Transports = HttpTransportType.WebSockets; - } - } - /// /// Map the minimal routes required to load the back office login and auth /// diff --git a/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs b/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs index 6d4a627af4ae..55867915a222 100644 --- a/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs +++ b/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs @@ -15,10 +15,9 @@ namespace Umbraco.Cms.Api.Management.Routing; /// /// Creates routes for the preview hub /// -public sealed class PreviewRoutes : IAreaRoutes +public sealed class PreviewRoutes : SignalRRoutesBase, IAreaRoutes { private readonly IRuntimeState _runtimeState; - private readonly SignalRSettings _signalRSettings; /// /// Initializes a new instance of the class, configuring preview routing based on the application's runtime state. @@ -38,9 +37,9 @@ public PreviewRoutes(IRuntimeState runtimeState) /// An instance representing the current runtime state of the Umbraco application. /// The SignalR settings options. public PreviewRoutes(IRuntimeState runtimeState, IOptions signalRSettings) + : base(signalRSettings) { _runtimeState = runtimeState; - _signalRSettings = signalRSettings.Value; } /// @@ -55,14 +54,6 @@ public void CreateRoutes(IEndpointRouteBuilder endpoints) } } - private void ConfigureHubEndpoint(HttpConnectionDispatcherOptions options) - { - if (_signalRSettings.ClientShouldSkipNegotiation) - { - options.Transports = HttpTransportType.WebSockets; - } - } - /// /// Gets the path to the SignalR hub used for preview. /// diff --git a/src/Umbraco.Cms.Api.Management/Routing/SignalRRoutesBase.cs b/src/Umbraco.Cms.Api.Management/Routing/SignalRRoutesBase.cs new file mode 100644 index 000000000000..8564a1e38ea9 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Routing/SignalRRoutesBase.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Http.Connections; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; + +namespace Umbraco.Cms.Api.Management.Routing; + +/// +/// Base class for route definitions that map SignalR hub endpoints, +/// applying shared transport configuration from . +/// +public class SignalRRoutesBase +{ + private readonly SignalRSettings _signalRSettings; + + /// + /// Initializes a new instance of the class. + /// + /// The SignalR settings options. + public SignalRRoutesBase(IOptions signalRSettings) + { + _signalRSettings = signalRSettings.Value; + } + + /// + /// Configures the transport options for a SignalR hub endpoint. + /// When is enabled, + /// restricts the endpoint to WebSocket transport only so clients can skip the negotiate round-trip. + /// + /// The hub endpoint dispatcher options to configure. + protected void ConfigureHubEndpoint(HttpConnectionDispatcherOptions options) + { + if (_signalRSettings.ClientShouldSkipNegotiation) + { + options.Transports = HttpTransportType.WebSockets; + } + } +} From dbf011df2485186e37751ecdb5c909d969b048a0 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 5 May 2026 14:24:56 +0200 Subject: [PATCH 09/10] Fix bool to observable --- .../src/packages/core/server/server-connection.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts b/src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts index bdad158ab8b5..e940827a3f14 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts @@ -37,7 +37,7 @@ export class UmbServerConnection extends UmbControllerBase { * @memberof UmbServerConnection */ getSignalRSkipNegotiation() { - return this.#signalRSkipNegotiation; + return this.#signalRSkipNegotiation.getValue(); } /** @@ -102,6 +102,6 @@ export class UmbServerConnection extends UmbControllerBase { this.#allowLocalLogin.setValue(data?.allowLocalLogin ?? false); this.#allowPasswordReset.setValue(data?.allowPasswordReset ?? false); this.#umbracoCssPath.setValue(data?.umbracoCssPath); - this.#signalRSkipNegotiation = data?.signalR?.skipNegotiation === true; + this.#signalRSkipNegotiation.setValue(data?.signalR?.skipNegotiation ?? false); } } From b5703859bc878da329db5487f72633e87120694a Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 5 May 2026 16:04:03 +0200 Subject: [PATCH 10/10] Refactor base class: pull down common service property, make abstract with protected constructor. --- .../Routing/BackOfficeAreaRoutes.cs | 9 ++------- .../Routing/PreviewRoutes.cs | 12 ++++-------- .../Routing/SignalRRoutesBase.cs | 14 +++++++++++--- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs b/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs index 6db23e8be0eb..72c6d24c45b6 100644 --- a/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs +++ b/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http.Connections; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -19,8 +18,6 @@ namespace Umbraco.Cms.Api.Management.Routing; /// public sealed class BackOfficeAreaRoutes : SignalRRoutesBase, IAreaRoutes { - private readonly IRuntimeState _runtimeState; - /// /// Initializes a new instance of the class. /// @@ -36,16 +33,14 @@ public BackOfficeAreaRoutes(IRuntimeState runtimeState) /// Initializes a new instance of the class. /// public BackOfficeAreaRoutes(IRuntimeState runtimeState, IOptions signalRSettings) - : base(signalRSettings) + : base(runtimeState, signalRSettings) { - _runtimeState = runtimeState; } - /// public void CreateRoutes(IEndpointRouteBuilder endpoints) { - if (_runtimeState.Level is RuntimeLevel.Install or RuntimeLevel.Upgrade or RuntimeLevel.Upgrading or RuntimeLevel.Run) + if (RuntimeState.Level is RuntimeLevel.Install or RuntimeLevel.Upgrade or RuntimeLevel.Upgrading or RuntimeLevel.Run) { MapMinimalBackOffice(endpoints); diff --git a/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs b/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs index 55867915a222..16786a2cd10c 100644 --- a/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs +++ b/src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http.Connections; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -17,10 +16,8 @@ namespace Umbraco.Cms.Api.Management.Routing; /// public sealed class PreviewRoutes : SignalRRoutesBase, IAreaRoutes { - private readonly IRuntimeState _runtimeState; - /// - /// Initializes a new instance of the class, configuring preview routing based on the application's runtime state. + /// Initializes a new instance of the class, configuring preview routing based on the application's runtime state. /// /// An instance representing the current runtime state of the Umbraco application. [Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] @@ -32,14 +29,13 @@ public PreviewRoutes(IRuntimeState runtimeState) } /// - /// Initializes a new instance of the class, configuring preview routing based on the application's runtime state. + /// Initializes a new instance of the class, configuring preview routing based on the application's runtime state. /// /// An instance representing the current runtime state of the Umbraco application. /// The SignalR settings options. public PreviewRoutes(IRuntimeState runtimeState, IOptions signalRSettings) - : base(signalRSettings) + : base(runtimeState, signalRSettings) { - _runtimeState = runtimeState; } /// @@ -48,7 +44,7 @@ public PreviewRoutes(IRuntimeState runtimeState, IOptions signa /// The endpoint route builder to add routes to. public void CreateRoutes(IEndpointRouteBuilder endpoints) { - if (_runtimeState.Level is RuntimeLevel.Install or RuntimeLevel.Upgrade or RuntimeLevel.Upgrading or RuntimeLevel.Run) + if (RuntimeState.Level is RuntimeLevel.Install or RuntimeLevel.Upgrade or RuntimeLevel.Upgrading or RuntimeLevel.Run) { endpoints.MapHub(GetPreviewHubRoute(), ConfigureHubEndpoint); } diff --git a/src/Umbraco.Cms.Api.Management/Routing/SignalRRoutesBase.cs b/src/Umbraco.Cms.Api.Management/Routing/SignalRRoutesBase.cs index 8564a1e38ea9..5fa96761e423 100644 --- a/src/Umbraco.Cms.Api.Management/Routing/SignalRRoutesBase.cs +++ b/src/Umbraco.Cms.Api.Management/Routing/SignalRRoutesBase.cs @@ -1,6 +1,7 @@ -using Microsoft.AspNetCore.Http.Connections; +using Microsoft.AspNetCore.Http.Connections; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Services; namespace Umbraco.Cms.Api.Management.Routing; @@ -8,19 +9,26 @@ namespace Umbraco.Cms.Api.Management.Routing; /// Base class for route definitions that map SignalR hub endpoints, /// applying shared transport configuration from . /// -public class SignalRRoutesBase +public abstract class SignalRRoutesBase { private readonly SignalRSettings _signalRSettings; /// /// Initializes a new instance of the class. /// + /// The current runtime state of the Umbraco application. /// The SignalR settings options. - public SignalRRoutesBase(IOptions signalRSettings) + protected SignalRRoutesBase(IRuntimeState runtimeState, IOptions signalRSettings) { + RuntimeState = runtimeState; _signalRSettings = signalRSettings.Value; } + /// + /// Gets the current runtime state of the Umbraco application. + /// + protected IRuntimeState RuntimeState { get; } + /// /// Configures the transport options for a SignalR hub endpoint. /// When is enabled,