Skip to content

SignalR: Add configurable transport settings for load-balanced deployments without sticky sessions#22700

Merged
AndyButland merged 10 commits into
mainfrom
v17/bugfix/signalRStickySessions
May 5, 2026
Merged

SignalR: Add configurable transport settings for load-balanced deployments without sticky sessions#22700
AndyButland merged 10 commits into
mainfrom
v17/bugfix/signalRStickySessions

Conversation

@Migaroez

@Migaroez Migaroez commented May 4, 2026

Copy link
Copy Markdown
Contributor

Description

Adds a new Umbraco:CMS:SignalR configuration section that allows restricting SignalR transport protocols and skipping the negotiate round-trip. This is required for load-balanced deployments without sticky sessions, where the negotiate request and subsequent WebSocket connection may land on different servers, causing connection failures.

Problem

When Umbraco is deployed behind a load balancer without sticky sessions, SignalR's default behavior performs a negotiate HTTP request before upgrading to WebSockets. If the negotiate request hits server A but the WebSocket connection is routed to server B, the connection fails because the connection token is not shared between servers.

Solution

Backend:

  • New SignalRSettings configuration model (Umbraco.Core) with:
    • ClientShouldSkipNegotiation — tells the client to skip the negotiate round-trip
  • BackOfficeAreaRoutes and PreviewRoutes apply transport restrictions to all SignalR hub endpoints via HttpConnectionDispatcherOptions
  • ConfigurationServerController exposes the SignalR settings to the client via a new SignalRClientSettingsResponseModel
  • Proper constructor obsoletions with StaticServiceProvider fallback (scheduled for removal in v19)
    Frontend:

UmbServerConnection reads SignalR settings from the server configuration endpoint
UmbManagementApiServerEventContext (server events hub) and UmbPreviewContext (preview hub) apply transport and skipNegotiation options when building hub connections
Generated API types updated
Configuration example (appsettings.json):

{
  "Umbraco": {
    "CMS": {
      "SignalR": {
        "ClientShouldSkipNegotiation": true
      }
    }
  }
}

Downstream packages (e.g., Umbraco Cloud) can also configure these settings via IConfigureOptions.

How to test

  1. Default behavior (no config): Start Umbraco without any SignalR config — all hubs should connect as before using negotiate + transport fallback
  2. WebSockets-only: Set Transports to WebSockets and ClientShouldSkipNegotiation to true — verify the backoffice hub and preview hub connect directly via WebSockets without a negotiate request (observable in browser DevTools Network tab)
  3. Verify API response: GET /umbraco/management/api/v1/server/configuration should include the signalR object with skipNegotiation fields

@claude

claude Bot commented May 4, 2026

Copy link
Copy Markdown

Claude finished @Migaroez's task in 5m 19s —— View job


PR Review

Target: origin/main · Based on commit: b61eb92a · Skipped: 1 noise file (types.gen.ts) out of 18 total

Adds a Umbraco:CMS:SignalR configuration section that restricts server-side transports and optionally tells the backoffice client to skip the SignalR negotiate round-trip, solving connection failures in load-balanced deployments without sticky sessions.

  • Modified public API: BackOfficeAreaRoutes constructor signature; PreviewRoutes constructor signature; ConfigurationServerController constructor signature; ServerConfigurationResponseModel (new SignalR property)
  • Affected implementations (outside this PR): Any external code that instantiates BackOfficeAreaRoutes directly
  • Other changes: New Umbraco:CMS:SignalR config section; new signalR.skipNegotiation / signalR.transports fields on the /umbraco/management/api/v1/server/configuration endpoint

Critical

  • src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs:27: The old single-parameter constructor BackOfficeAreaRoutes(IRuntimeState) was completely replaced — no obsolete constructor preserved. This is a binary breaking change. PreviewRoutes handles this correctly with the obsolete pattern, but BackOfficeAreaRoutes does not. The old constructor must be retained as obsolete per Pattern 1 (CLAUDE.md §5.1), delegating to the new one via StaticServiceProvider. Fix this →

Important

  • src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts:128 (and server-event.context.ts:101, preview.context.ts:120): skipNegotiation = true is set unconditionally whenever the server flag is on, regardless of which transport is configured. SignalR's JS client throws "Negotiation can only be skipped when using the WebSocket transport directly." if skipNegotiation is true and the transport is not WebSockets. A misconfiguration of ClientShouldSkipNegotiation: true with default (null) or non-WebSocket transports will silently break all hub connections at runtime. Guard the flag: only set skipNegotiation: true when hubOptions.transport === HttpTransportType.WebSockets.

  • src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts:119: #mapTransportType uses a switch that only handles individual enum members. SignalRTransportType is a [Flags] enum — a value like WebSockets | LongPolling would be serialized by JsonStringEnumConverter as "WebSockets, LongPolling", which falls through to default: return undefined, silently discarding the restriction. Use bitwise mapping matching the backend's ToHttpTransportType pattern, or restrict the config/schema to single-transport values only.

  • src/Umbraco.Cms.Api.Management/ViewModels/Server/SignalRClientSettingsResponseModel.cs:17: Transports is SignalRTransportType? (nullable), but the generated OpenAPI schema marks it as required with a non-nullable $ref to SignalRTransportTypeModel. When not configured, the server serializes "transports": null, which violates the schema contract and makes the generated TypeScript type (transports: SignalRTransportTypeModel — non-optional) incorrect. Either mark transports as nullable in the schema (add [JsonIgnore(Condition = WhenWritingDefault)] + schema annotation), or change the C# property to always emit a non-null sentinel value. Fix this →

  • src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs:28: Obsolete message reads "Scheduled for removal in v19" — should be "Scheduled for removal in Umbraco 19." to match the required format (Scheduled for removal in Umbraco {current_major+2}. — CLAUDE.md §5.4). Fix this →

  • Missing tests: SignalRTransportTypeExtensions.ToHttpTransportType (including the None edge case) and the new signalR field on the ConfigurationServerController response have no tests. The coding preferences require blackbox tests for all new code.


Suggestions

  • src/Umbraco.Core/Configuration/Models/SignalRSettings.cs:30: Consider adding a [System.ComponentModel.DataAnnotations.CustomValidation] or IValidateOptions guard that enforces ClientShouldSkipNegotiation = true requires Transports = WebSockets. Fail-fast at startup beats a confusing runtime SignalR error.

  • src/Umbraco.Cms.Api.Management/Extensions/SignalRTransportTypeExtensions.cs:19: If SignalRTransportType.None is explicitly configured, the conversion yields HttpTransportType.None, which effectively disables all SignalR connections with no helpful error message. A small guard or a log warning when Transports = None would make misconfiguration easier to diagnose.


Request Changes

The missing obsolete constructor on BackOfficeAreaRoutes is a binary breaking change that must be addressed. The skipNegotiation/transport mismatch will cause silent connection failures on certain valid configurations. The schema mismatch on transports produces an incorrect client type. Please address the Critical and Important items before merging.

@AndyButland AndyButland left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good @Migaroez. Have left a few suggestions for consideration. Once you've decided and/or acted on any or all of these, please let me know and I'll take it for local testing.

Comment thread src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs Outdated
Comment thread src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs Outdated
Comment thread src/Umbraco.Core/Configuration/Models/SignalRSettings.cs
Comment thread src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs Outdated
Comment thread src/Umbraco.Cms.Api.Management/Routing/PreviewRoutes.cs Outdated
Comment thread src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs Outdated
Comment thread src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts Outdated
@AndyButland AndyButland added the status/needs-docs Requires new or updated documentation label May 5, 2026

@AndyButland AndyButland left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All looks good to me now @Migaroez and tests out as expected. I'm seeing the call to the /umbraco/serverEventHub/negotiate?negotiateVersion=1 endpoint only with the default configuration and not when it's configured to skip. In both cases a web socket connection is established.

Please can you create a docs PR to document this new configuration setting? It needs to be indexed from here and added to the SUMMARY.md file (given it's a new configuration section). Should be marked as pending to go live with the RC of 17.5, which is due 11th June.

@AndyButland AndyButland enabled auto-merge (squash) May 5, 2026 14:14
@AndyButland AndyButland merged commit b3a9f86 into main May 5, 2026
29 of 30 checks passed
@AndyButland AndyButland deleted the v17/bugfix/signalRStickySessions branch May 5, 2026 14:51
AndyButland pushed a commit that referenced this pull request May 14, 2026
…sponse (#22849)

Mocks: Add missing signalR property to mock server configuration response

The GetServerConfigurationResponse type was updated in #22700 to require
a signalR.skipNegotiation property, but the MSW mock handler was not
updated to match, causing a tsc compilation error.
AndyButland pushed a commit that referenced this pull request May 14, 2026
…sponse (#22849)

Mocks: Add missing signalR property to mock server configuration response

The GetServerConfigurationResponse type was updated in #22700 to require
a signalR.skipNegotiation property, but the MSW mock handler was not
updated to match, causing a tsc compilation error.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants