Skip to content

Commit c58d750

Browse files
committed
Improve SignalR plugin error handling and URL resolution
Refactor plugin JS to wrap hub connection logic in try/catch, add _resolveHubUrl helper for absolute URL resolution, and display inline connection errors in the UI with new CSS styling. Add integration tests to verify presence of the new helper and error style in embedded resources.
1 parent 079cf14 commit c58d750

3 files changed

Lines changed: 182 additions & 68 deletions

File tree

src/SignalR.OpenApi.SwaggerUi/Resources/signalr-openapi-plugin.js

Lines changed: 134 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -308,78 +308,85 @@ var SignalROpenApiPlugin = function (system) {
308308
return Promise.resolve(_hubs[hubPath]);
309309
}
310310

311-
var token = _getAccessToken();
312-
var configs = system.getConfigs ? system.getConfigs() : {};
313-
var options = {};
314-
if (token) {
315-
options.accessTokenFactory = function () { return token; };
316-
}
317-
318-
// Apply custom headers from configuration (e.g. "X-Custom-Header")
319-
var configuredHeaders = configs.signalRHeaders;
320-
if (configuredHeaders && typeof configuredHeaders === "object") {
321-
options.headers = {};
322-
Object.keys(configuredHeaders).forEach(function (key) {
323-
options.headers[key] = configuredHeaders[key];
324-
});
325-
}
311+
// Wrap in try/catch so synchronous errors (e.g. URL resolution
312+
// failures in Electron/Node-like environments) are surfaced as
313+
// rejected promises instead of uncaught exceptions.
314+
try {
315+
var token = _getAccessToken();
316+
var configs = system.getConfigs ? system.getConfigs() : {};
317+
var options = {};
318+
if (token) {
319+
options.accessTokenFactory = function () { return token; };
320+
}
326321

327-
// Merge apiKey headers entered via the SwaggerUI Authorize dialog
328-
var apiKeyHeaders = _getApiKeyHeaders();
329-
if (apiKeyHeaders) {
330-
if (!options.headers) {
322+
// Apply custom headers from configuration (e.g. "X-Custom-Header")
323+
var configuredHeaders = configs.signalRHeaders;
324+
if (configuredHeaders && typeof configuredHeaders === "object") {
331325
options.headers = {};
326+
Object.keys(configuredHeaders).forEach(function (key) {
327+
options.headers[key] = configuredHeaders[key];
328+
});
332329
}
333330

334-
Object.keys(apiKeyHeaders).forEach(function (key) {
335-
options.headers[key] = apiKeyHeaders[key];
336-
});
337-
}
331+
// Merge apiKey headers entered via the SwaggerUI Authorize dialog
332+
var apiKeyHeaders = _getApiKeyHeaders();
333+
if (apiKeyHeaders) {
334+
if (!options.headers) {
335+
options.headers = {};
336+
}
338337

339-
// Browsers cannot send custom HTTP headers on WebSocket or
340-
// Server-Sent Events connections. When custom headers are
341-
// configured, fall back to Long Polling which includes headers
342-
// with every HTTP request so the server can read them on each
343-
// hub invocation.
344-
if (options.headers && Object.keys(options.headers).length > 0) {
345-
options.transport = signalR.HttpTransportType.LongPolling;
346-
}
338+
Object.keys(apiKeyHeaders).forEach(function (key) {
339+
options.headers[key] = apiKeyHeaders[key];
340+
});
341+
}
347342

348-
var connection = new signalR.HubConnectionBuilder()
349-
.withUrl(hubPath, options)
350-
.withAutomaticReconnect()
351-
.build();
343+
// Browsers cannot send custom HTTP headers on WebSocket or
344+
// Server-Sent Events connections. When custom headers are
345+
// configured, fall back to Long Polling which includes headers
346+
// with every HTTP request so the server can read them on each
347+
// hub invocation.
348+
if (options.headers && Object.keys(options.headers).length > 0) {
349+
options.transport = signalR.HttpTransportType.LongPolling;
350+
}
352351

353-
_hubs[hubPath] = connection;
352+
var connection = new signalR.HubConnectionBuilder()
353+
.withUrl(_resolveHubUrl(hubPath), options)
354+
.withAutomaticReconnect()
355+
.build();
354356

355-
// Store the auth fingerprint so we can detect changes later
356-
_hubAuthFingerprints[hubPath] = currentFingerprint;
357+
_hubs[hubPath] = connection;
357358

358-
// Track connection state changes for UI updates
359-
connection.onreconnecting(function () {
360-
if (_eventListeners[hubPath]) {
361-
_eventListeners[hubPath].forEach(function (fn) { fn(); });
362-
}
363-
});
359+
// Store the auth fingerprint so we can detect changes later
360+
_hubAuthFingerprints[hubPath] = currentFingerprint;
364361

365-
connection.onreconnected(function () {
366-
if (_eventListeners[hubPath]) {
367-
_eventListeners[hubPath].forEach(function (fn) { fn(); });
368-
}
369-
});
362+
// Track connection state changes for UI updates
363+
connection.onreconnecting(function () {
364+
if (_eventListeners[hubPath]) {
365+
_eventListeners[hubPath].forEach(function (fn) { fn(); });
366+
}
367+
});
370368

371-
connection.onclose(function () {
372-
if (_eventListeners[hubPath]) {
373-
_eventListeners[hubPath].forEach(function (fn) { fn(); });
374-
}
375-
});
369+
connection.onreconnected(function () {
370+
if (_eventListeners[hubPath]) {
371+
_eventListeners[hubPath].forEach(function (fn) { fn(); });
372+
}
373+
});
374+
375+
connection.onclose(function () {
376+
if (_eventListeners[hubPath]) {
377+
_eventListeners[hubPath].forEach(function (fn) { fn(); });
378+
}
379+
});
376380

377-
// Subscribe to client events once connected
378-
_subscribeClientEvents(hubPath, connection);
381+
// Subscribe to client events once connected
382+
_subscribeClientEvents(hubPath, connection);
379383

380-
return connection.start().then(function () {
381-
return connection;
382-
});
384+
return connection.start().then(function () {
385+
return connection;
386+
});
387+
} catch (err) {
388+
return Promise.reject(err);
389+
}
383390
};
384391

385392
// Parse the x-signalr extension from an operation
@@ -406,6 +413,39 @@ var SignalROpenApiPlugin = function (system) {
406413
return signalrExt.hubPath || ("/" + signalrExt.hub.toLowerCase());
407414
};
408415

416+
// Resolve a relative hub path to an absolute URL so the SignalR client
417+
// does not need to use its platform-specific URL resolution logic.
418+
// In Electron / Electron.NET renderers the SignalR isBrowser check
419+
// returns false (Node.js process global is present), causing
420+
// "Cannot resolve '/...'" errors for relative paths. Absolute URLs
421+
// starting with http:// or https:// bypass that check entirely.
422+
var _resolveHubUrl = function (hubPath) {
423+
if (hubPath.indexOf("http://") === 0 || hubPath.indexOf("https://") === 0) {
424+
return hubPath;
425+
}
426+
427+
// Prefer the first server URL from the OpenAPI spec (matches what
428+
// SwaggerUI shows the user and handles reverse-proxy scenarios).
429+
var specIm = system.specSelectors.specJson();
430+
var serversIm = specIm && specIm.get("servers");
431+
if (serversIm && serversIm.size > 0) {
432+
var serverUrl = serversIm.getIn([0, "url"]);
433+
if (serverUrl && typeof serverUrl === "string"
434+
&& (serverUrl.indexOf("http://") === 0 || serverUrl.indexOf("https://") === 0)) {
435+
// Strip trailing slash from server URL before appending path
436+
return serverUrl.replace(/\/+$/, "") + hubPath;
437+
}
438+
}
439+
440+
// Fall back to the current page origin
441+
if (typeof window !== "undefined" && window.location && window.location.origin) {
442+
return window.location.origin + hubPath;
443+
}
444+
445+
// Last resort: return as-is (standard browser environments resolve it fine)
446+
return hubPath;
447+
};
448+
409449
// Discover all unique hub paths from the spec's x-signalr extensions.
410450
// Returns an array of { hubPath, hubName } objects.
411451
var _getHubPaths = function () {
@@ -663,6 +703,10 @@ var SignalROpenApiPlugin = function (system) {
663703
var isManualConnecting = connectingHook[0];
664704
var setManualConnecting = connectingHook[1];
665705

706+
var errorHook = React.useState(null);
707+
var connectionError = errorHook[0];
708+
var setConnectionError = errorHook[1];
709+
666710
React.useEffect(function () {
667711
var listener = function () { forceUpdate(function (n) { return n + 1; }); };
668712

@@ -682,19 +726,23 @@ var SignalROpenApiPlugin = function (system) {
682726

683727
var handleConnect = function () {
684728
setManualConnecting(true);
729+
setConnectionError(null);
685730
_getOrCreateHub(hubPath)
686731
.then(function () {
687732
setManualConnecting(false);
688733
forceUpdate(function (n) { return n + 1; });
689734
})
690735
.catch(function (err) {
691736
setManualConnecting(false);
737+
var message = err && (err.message || err.toString()) || "Unknown error";
692738
console.error("[SignalR OpenAPI] Manual connect failed:", err);
739+
setConnectionError(message);
693740
forceUpdate(function (n) { return n + 1; });
694741
});
695742
};
696743

697744
var handleDisconnect = function () {
745+
setConnectionError(null);
698746
_disconnectHub(hubPath).then(function () {
699747
forceUpdate(function (n) { return n + 1; });
700748
});
@@ -716,16 +764,29 @@ var SignalROpenApiPlugin = function (system) {
716764
? handleConnect
717765
: undefined;
718766

719-
return React.createElement("div", {
720-
className: "signalr-hub-connection-bar",
721-
onClick: function (e) { e.stopPropagation(); },
722-
},
767+
var children = [
723768
React.createElement("button", {
769+
key: "btn",
724770
className: buttonClass,
725771
onClick: handleClick,
726772
disabled: showConnecting,
727-
}, buttonText)
728-
);
773+
}, buttonText),
774+
];
775+
776+
if (connectionError) {
777+
children.push(
778+
React.createElement("span", {
779+
key: "err",
780+
className: "signalr-connection-error",
781+
title: connectionError,
782+
}, connectionError)
783+
);
784+
}
785+
786+
return React.createElement("div", {
787+
className: "signalr-hub-connection-bar",
788+
onClick: function (e) { e.stopPropagation(); },
789+
}, children);
729790
}
730791

731792
// React component for client event log panel
@@ -744,9 +805,14 @@ var SignalROpenApiPlugin = function (system) {
744805
var isConnected = _hubs[hubPath] && _hubs[hubPath].state === signalR.HubConnectionState.Connected;
745806

746807
var connectAndListen = function () {
747-
_getOrCreateHub(hubPath).then(function () {
748-
forceUpdate(function (n) { return n + 1; });
749-
});
808+
_getOrCreateHub(hubPath)
809+
.then(function () {
810+
forceUpdate(function (n) { return n + 1; });
811+
})
812+
.catch(function (err) {
813+
console.error("[SignalR OpenAPI] Connect & Listen failed:", err);
814+
forceUpdate(function (n) { return n + 1; });
815+
});
750816
};
751817

752818
var clearLog = function () {

src/SignalR.OpenApi.SwaggerUi/Resources/signalr-openapi.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,14 @@
106106
font-size: 13px;
107107
gap: 8px;
108108
}
109+
110+
/* Inline error message shown after a failed connect attempt */
111+
.signalr-connection-error {
112+
color: #f93e3e;
113+
font-size: 12px;
114+
font-family: monospace;
115+
overflow: hidden;
116+
text-overflow: ellipsis;
117+
white-space: nowrap;
118+
max-width: 600px;
119+
}

test/SignalR.OpenApi.Tests/SwaggerUiIntegrationTests.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,43 @@ public async Task EmbeddedResources_ServesPluginJs()
208208
Assert.IsTrue(content.Contains("SignalROpenApiPlugin"));
209209
}
210210

211+
/// <summary>
212+
/// Verifies that the plugin JS contains the _resolveHubUrl helper
213+
/// that converts relative hub paths to absolute URLs for environments
214+
/// where the SignalR client cannot resolve relative URLs (e.g. Electron).
215+
/// </summary>
216+
/// <returns>A <see cref="Task"/> representing the asynchronous test.</returns>
217+
[TestMethod]
218+
public async Task EmbeddedResources_PluginJs_ContainsResolveHubUrl()
219+
{
220+
using var host = await CreateTestHost();
221+
using var client = host.GetTestClient();
222+
223+
using var response = await client.GetAsync("/signalr-swagger/_resources/signalr-openapi-plugin.js");
224+
225+
Assert.AreEqual(System.Net.HttpStatusCode.OK, response.StatusCode);
226+
var content = await response.Content.ReadAsStringAsync();
227+
Assert.IsTrue(content.Contains("_resolveHubUrl"), "Plugin JS should contain _resolveHubUrl helper for absolute URL resolution");
228+
}
229+
230+
/// <summary>
231+
/// Verifies that the plugin CSS contains the connection error style
232+
/// for displaying inline error messages on failed connect attempts.
233+
/// </summary>
234+
/// <returns>A <see cref="Task"/> representing the asynchronous test.</returns>
235+
[TestMethod]
236+
public async Task EmbeddedResources_Css_ContainsConnectionErrorStyle()
237+
{
238+
using var host = await CreateTestHost();
239+
using var client = host.GetTestClient();
240+
241+
using var response = await client.GetAsync("/signalr-swagger/_resources/signalr-openapi.css");
242+
243+
Assert.AreEqual(System.Net.HttpStatusCode.OK, response.StatusCode);
244+
var content = await response.Content.ReadAsStringAsync();
245+
Assert.IsTrue(content.Contains("signalr-connection-error"), "CSS should contain signalr-connection-error class for inline error display");
246+
}
247+
211248
/// <summary>
212249
/// Verifies embedded CSS resource is served.
213250
/// </summary>

0 commit comments

Comments
 (0)