Skip to content

Commit 0ef7f46

Browse files
authored
Add KeepAliveMode and SupportedWebSocketSubProtocols options (#1154)
1 parent 22601b4 commit 0ef7f46

15 files changed

+501
-23
lines changed

README.md

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -730,10 +730,12 @@ methods allowing for different options for each configured endpoint.
730730
| Property | Description | Default value |
731731
|-----------------------------|----------------------|---------------|
732732
| `ConnectionInitWaitTimeout` | The amount of time to wait for a GraphQL initialization packet before the connection is closed. | 10 seconds |
733-
| `KeepAliveTimeout` | The amount of time to wait between sending keep-alive packets. | disabled |
734733
| `DisconnectionTimeout` | The amount of time to wait to attempt a graceful teardown of the WebSockets protocol. | 10 seconds |
735734
| `DisconnectAfterErrorEvent` | Disconnects a subscription from the client if the subscription source dispatches an `OnError` event. | True |
736735
| `DisconnectAfterAnyError` | Disconnects a subscription from the client if there are any GraphQL errors during a subscription. | False |
736+
| `KeepAliveMode` | The mode to use for sending keep-alive packets. | protocol-dependent |
737+
| `KeepAliveTimeout` | The amount of time to wait between sending keep-alive packets. | disabled |
738+
| `SupportedWebSocketSubProtocols` | A list of supported WebSocket sub-protocols. | `graphql-ws`, `graphql-transport-ws` |
737739

738740
### Multi-schema configuration
739741

@@ -800,6 +802,59 @@ public class MySchema : Schema
800802
}
801803
```
802804

805+
### Keep-alive configuration
806+
807+
By default, the middleware will not send keep-alive packets to the client. As the underlying
808+
operating system may not detect a disconnected client until a message is sent, you may wish to
809+
enable keep-alive packets to be sent periodically. The default mode for keep-alive packets
810+
differs depending on whether the client connected with the `graphql-ws` or `graphql-transport-ws`
811+
sub-protocol. The `graphql-ws` sub-protocol will send a unidirectional keep-alive packet to the
812+
client on a fixed schedule, while the `graphql-transport-ws` sub-protocol will only send
813+
unidirectional keep-alive packets when the client has not sent a message within a certain time.
814+
The differing behavior is due to the default implementation of the `graphql-ws` sub-protocol
815+
client, which after receiving a single keep-alive packet, expects additional keep-alive packets
816+
to be sent sooner than every 20 seconds, regardless of the client's activity.
817+
818+
To configure keep-alive packets, set the `KeepAliveMode` and `KeepAliveTimeout` properties
819+
within the `GraphQLWebSocketOptions` object. Set the `KeepAliveTimeout` property to
820+
enable keep-alive packets, or use `TimeSpan.Zero` or `Timeout.InfiniteTimeSpan` to disable it.
821+
822+
The `KeepAliveMode` property is only applicable to the `graphql-transport-ws` sub-protocol and
823+
can be set to the options listed below:
824+
825+
| Keep-alive mode | Description |
826+
|-----------------|-------------|
827+
| `Default` | Same as `Timeout`. |
828+
| `Timeout` | Sends a unidirectional keep-alive message when no message has been received within the specified timeout period. |
829+
| `Interval` | Sends a unidirectional keep-alive message at a fixed interval, regardless of message activity. |
830+
| `TimeoutWithPayload` | Sends a bidirectional keep-alive message with a payload on a fixed interval, and validates the payload matches in the response. |
831+
832+
The `TimeoutWithPayload` model is particularly useful when the server may send messages to the
833+
client at a faster pace than the client can process them. In this case queued messages will be
834+
limited to double the timeout period, as the keep-alive message is queued along with other
835+
packets sent from the server to the client. The client will need to respond to process queued
836+
messages and respond to the keep-alive message within the timeout period or the server will
837+
disconnect the client. When the server forcibly disconnects the client, no graceful teardown
838+
of the WebSocket protocol occurs, and any queued messages are discarded.
839+
840+
When using the `TimeoutWithPayload` keep-alive mode, you may wish to enforce that the
841+
`graphql-transport-ws` sub-protocol is in use by the client, as the `graphql-ws` sub-protocol
842+
does not support bidirectional keep-alive packets. This can be done by setting the
843+
`SupportedWebSocketSubProtocols` property to only include the `graphql-transport-ws` sub-protocol.
844+
845+
```csharp
846+
app.UseGraphQL("/graphql", options =>
847+
{
848+
// configure keep-alive packets
849+
options.WebSockets.KeepAliveTimeout = TimeSpan.FromSeconds(10);
850+
options.WebSockets.KeepAliveMode = KeepAliveMode.TimeoutWithPayload;
851+
// set the supported sub-protocols to only include the graphql-transport-ws sub-protocol
852+
options.WebSockets.SupportedWebSocketSubProtocols = [GraphQLWs.SubscriptionServer.SubProtocol];
853+
});
854+
```
855+
856+
Please note that the included UI packages are configured to use the `graphql-ws` sub-protocol.
857+
803858
### Customizing middleware behavior
804859

805860
GET/POST requests are handled directly by the `GraphQLHttpMiddleware`.

src/Transports.AspNetCore/GraphQLHttpMiddleware.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1007,7 +1007,7 @@ protected virtual Task WriteJsonResponseAsync<TResult>(HttpContext context, Http
10071007
/// <summary>
10081008
/// Gets a list of WebSocket sub-protocols supported.
10091009
/// </summary>
1010-
protected virtual IEnumerable<string> SupportedWebSocketSubProtocols => _supportedSubProtocols;
1010+
protected virtual IEnumerable<string> SupportedWebSocketSubProtocols => _options.WebSockets.SupportedWebSocketSubProtocols;
10111011

10121012
/// <summary>
10131013
/// Creates an <see cref="IWebSocketConnection"/>, a WebSocket message pump.

src/Transports.AspNetCore/WebSockets/BaseSubscriptionServer.cs

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -259,10 +259,33 @@ protected virtual Task OnNotAuthorizedPolicyAsync(OperationMessage message, Auth
259259
/// <br/><br/>
260260
/// Otherwise, the connection is acknowledged via <see cref="OnConnectionAcknowledgeAsync(OperationMessage)"/>,
261261
/// <see cref="TryInitialize"/> is called to indicate that this WebSocket connection is ready to accept requests,
262-
/// and keep-alive messages are sent via <see cref="OnSendKeepAliveAsync"/> if configured to do so.
263-
/// Keep-alive messages are only sent if no messages have been sent over the WebSockets connection for the
264-
/// length of time configured in <see cref="GraphQLWebSocketOptions.KeepAliveTimeout"/>.
262+
/// and <see cref="OnSendKeepAliveAsync"/> is called to start sending keep-alive messages if configured to do so.
265263
/// </summary>
264+
protected virtual async Task OnConnectionInitAsync(OperationMessage message)
265+
{
266+
if (!await AuthorizeAsync(message))
267+
{
268+
return;
269+
}
270+
await OnConnectionAcknowledgeAsync(message);
271+
if (!TryInitialize())
272+
return;
273+
274+
_ = OnKeepAliveLoopAsync();
275+
}
276+
277+
/// <summary>
278+
/// Executes when the client is attempting to initialize the connection.
279+
/// <br/><br/>
280+
/// By default, this first checks <see cref="AuthorizeAsync(OperationMessage)"/> to validate that the
281+
/// request has passed authentication. If validation fails, the connection is closed with an Access
282+
/// Denied message.
283+
/// <br/><br/>
284+
/// Otherwise, the connection is acknowledged via <see cref="OnConnectionAcknowledgeAsync(OperationMessage)"/>,
285+
/// <see cref="TryInitialize"/> is called to indicate that this WebSocket connection is ready to accept requests,
286+
/// and <see cref="OnSendKeepAliveAsync"/> is called to start sending keep-alive messages if configured to do so.
287+
/// </summary>
288+
[Obsolete($"Please use the {nameof(OnConnectionInitAsync)}(message) and {nameof(OnKeepAliveLoopAsync)} methods instead. This method will be removed in a future version of this library.")]
266289
protected virtual async Task OnConnectionInitAsync(OperationMessage message, bool smartKeepAlive)
267290
{
268291
if (!await AuthorizeAsync(message))
@@ -277,12 +300,49 @@ protected virtual async Task OnConnectionInitAsync(OperationMessage message, boo
277300
if (keepAliveTimeout > TimeSpan.Zero)
278301
{
279302
if (smartKeepAlive)
280-
_ = StartSmartKeepAliveLoopAsync();
303+
_ = OnKeepAliveLoopAsync(keepAliveTimeout, KeepAliveMode.Timeout);
281304
else
282-
_ = StartKeepAliveLoopAsync();
305+
_ = OnKeepAliveLoopAsync(keepAliveTimeout, KeepAliveMode.Interval);
306+
}
307+
}
308+
309+
/// <summary>
310+
/// Starts sending keep-alive messages if configured to do so. Inspects the configured
311+
/// <see cref="GraphQLWebSocketOptions"/> and passes control to <see cref="OnKeepAliveLoopAsync(TimeSpan, KeepAliveMode)"/>
312+
/// if keep-alive messages are enabled.
313+
/// </summary>
314+
protected virtual Task OnKeepAliveLoopAsync()
315+
{
316+
return OnKeepAliveLoopAsync(
317+
_options.KeepAliveTimeout ?? DefaultKeepAliveTimeout,
318+
_options.KeepAliveMode);
319+
}
320+
321+
/// <summary>
322+
/// Sends keep-alive messages according to the specified timeout period and method.
323+
/// See <see cref="KeepAliveMode"/> for implementation details for each supported mode.
324+
/// </summary>
325+
protected virtual async Task OnKeepAliveLoopAsync(TimeSpan keepAliveTimeout, KeepAliveMode keepAliveMode)
326+
{
327+
if (keepAliveTimeout <= TimeSpan.Zero)
328+
return;
329+
330+
switch (keepAliveMode)
331+
{
332+
case KeepAliveMode.Default:
333+
case KeepAliveMode.Timeout:
334+
await StartSmartKeepAliveLoopAsync();
335+
break;
336+
case KeepAliveMode.Interval:
337+
await StartDumbKeepAliveLoopAsync();
338+
break;
339+
case KeepAliveMode.TimeoutWithPayload:
340+
throw new NotImplementedException($"{nameof(KeepAliveMode.TimeoutWithPayload)} is not implemented within the {nameof(BaseSubscriptionServer)} class.");
341+
default:
342+
throw new ArgumentOutOfRangeException(nameof(keepAliveMode));
283343
}
284344

285-
async Task StartKeepAliveLoopAsync()
345+
async Task StartDumbKeepAliveLoopAsync()
286346
{
287347
while (!CancellationToken.IsCancellationRequested)
288348
{

src/Transports.AspNetCore/WebSockets/GraphQLWebSocketOptions.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ public class GraphQLWebSocketOptions
2525
/// </summary>
2626
public TimeSpan? KeepAliveTimeout { get; set; }
2727

28+
/// <summary>
29+
/// Gets or sets the keep-alive mode used for websocket subscriptions.
30+
/// This property is only applicable when using the GraphQLWs protocol.
31+
/// </summary>
32+
public KeepAliveMode KeepAliveMode { get; set; } = KeepAliveMode.Default;
33+
2834
/// <summary>
2935
/// The amount of time to wait to attempt a graceful teardown of the WebSockets protocol.
3036
/// A value of <see langword="null"/> indicates the default value defined by the implementation.
@@ -42,4 +48,17 @@ public class GraphQLWebSocketOptions
4248
/// Disconnects a subscription from the client in the event of any GraphQL errors during a subscription. The default value is <see langword="false"/>.
4349
/// </summary>
4450
public bool DisconnectAfterAnyError { get; set; }
51+
52+
/// <summary>
53+
/// The list of supported WebSocket sub-protocols.
54+
/// Defaults to <see cref="GraphQLWs.SubscriptionServer.SubProtocol"/> and <see cref="SubscriptionsTransportWs.SubscriptionServer.SubProtocol"/>.
55+
/// Adding other sub-protocols require the <see cref="GraphQLHttpMiddleware.CreateMessageProcessor(IWebSocketConnection, string)"/> method
56+
/// to be overridden to handle the new sub-protocol.
57+
/// </summary>
58+
/// <remarks>
59+
/// When the <see cref="KeepAliveMode"/> is set to <see cref="KeepAliveMode.TimeoutWithPayload"/>, you may wish to remove
60+
/// <see cref="SubscriptionsTransportWs.SubscriptionServer.SubProtocol"/> from this list to prevent clients from using
61+
/// protocols which do not support the <see cref="KeepAliveMode.TimeoutWithPayload"/> keep-alive mode.
62+
/// </remarks>
63+
public List<string> SupportedWebSocketSubProtocols { get; set; } = [GraphQLWs.SubscriptionServer.SubProtocol, SubscriptionsTransportWs.SubscriptionServer.SubProtocol];
4564
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace GraphQL.Server.Transports.AspNetCore.WebSockets.GraphQLWs;
2+
3+
/// <summary>
4+
/// The payload of the ping message.
5+
/// </summary>
6+
public class PingPayload
7+
{
8+
/// <summary>
9+
/// The unique identifier of the ping message.
10+
/// </summary>
11+
public string? id { get; set; }
12+
}

src/Transports.AspNetCore/WebSockets/GraphQLWs/SubscriptionServer.cs

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ namespace GraphQL.Server.Transports.AspNetCore.WebSockets.GraphQLWs;
44
public class SubscriptionServer : BaseSubscriptionServer
55
{
66
private readonly IWebSocketAuthenticationService? _authenticationService;
7+
private readonly IGraphQLSerializer _serializer;
8+
private readonly GraphQLWebSocketOptions _options;
9+
private DateTime _lastPongReceivedUtc;
10+
private string? _lastPingId;
11+
private readonly object _lastPingLock = new();
712

813
/// <summary>
914
/// The WebSocket sub-protocol used for this protocol.
@@ -67,6 +72,8 @@ public SubscriptionServer(
6772
UserContextBuilder = userContextBuilder ?? throw new ArgumentNullException(nameof(userContextBuilder));
6873
Serializer = serializer ?? throw new ArgumentNullException(nameof(serializer));
6974
_authenticationService = authenticationService;
75+
_serializer = serializer;
76+
_options = options;
7077
}
7178

7279
/// <inheritdoc/>
@@ -90,7 +97,9 @@ public override async Task OnMessageReceivedAsync(OperationMessage message)
9097
}
9198
else
9299
{
100+
#pragma warning disable CS0618 // Type or member is obsolete
93101
await OnConnectionInitAsync(message, true);
102+
#pragma warning restore CS0618 // Type or member is obsolete
94103
}
95104
return;
96105
}
@@ -113,6 +122,69 @@ public override async Task OnMessageReceivedAsync(OperationMessage message)
113122
}
114123
}
115124

125+
/// <inheritdoc/>
126+
[Obsolete($"Please use the {nameof(OnConnectionInitAsync)} and {nameof(OnKeepAliveLoopAsync)} methods instead. This method will be removed in a future version of this library.")]
127+
protected override Task OnConnectionInitAsync(OperationMessage message, bool smartKeepAlive)
128+
{
129+
if (smartKeepAlive)
130+
return OnConnectionInitAsync(message);
131+
else
132+
return base.OnConnectionInitAsync(message, smartKeepAlive);
133+
}
134+
135+
/// <inheritdoc/>
136+
protected override Task OnKeepAliveLoopAsync(TimeSpan keepAliveTimeout, KeepAliveMode keepAliveMode)
137+
{
138+
if (keepAliveMode == KeepAliveMode.TimeoutWithPayload)
139+
{
140+
if (keepAliveTimeout <= TimeSpan.Zero)
141+
return Task.CompletedTask;
142+
return SecureKeepAliveLoopAsync(keepAliveTimeout, keepAliveTimeout);
143+
}
144+
return base.OnKeepAliveLoopAsync(keepAliveTimeout, keepAliveMode);
145+
146+
// pingInterval is the time since the last pong was received before sending a new ping
147+
// pongInterval is the time to wait for a pong after a ping was sent before forcibly closing the connection
148+
async Task SecureKeepAliveLoopAsync(TimeSpan pingInterval, TimeSpan pongInterval)
149+
{
150+
lock (_lastPingLock)
151+
_lastPongReceivedUtc = DateTime.UtcNow;
152+
while (!CancellationToken.IsCancellationRequested)
153+
{
154+
// Wait for the next ping interval
155+
TimeSpan interval;
156+
var now = DateTime.UtcNow;
157+
DateTime lastPongReceivedUtc;
158+
lock (_lastPingLock)
159+
{
160+
lastPongReceivedUtc = _lastPongReceivedUtc;
161+
}
162+
var nextPing = lastPongReceivedUtc.Add(pingInterval);
163+
interval = nextPing.Subtract(now);
164+
if (interval > TimeSpan.Zero) // could easily be zero or less, if pongInterval is equal or greater than pingInterval
165+
await Task.Delay(interval, CancellationToken);
166+
167+
// Send a new ping message
168+
await OnSendKeepAliveAsync();
169+
170+
// Wait for the pong response
171+
await Task.Delay(pongInterval, CancellationToken);
172+
bool abort;
173+
lock (_lastPingLock)
174+
{
175+
abort = _lastPongReceivedUtc == lastPongReceivedUtc;
176+
}
177+
if (abort)
178+
{
179+
// Forcibly close the connection if the client has not responded to the keep-alive message.
180+
// Do not send a close message to the client or wait for a response.
181+
Connection.HttpContext.Abort();
182+
return;
183+
}
184+
}
185+
}
186+
}
187+
116188
/// <summary>
117189
/// Pong is a required response to a ping, and also a unidirectional keep-alive packet,
118190
/// whereas ping is a bidirectional keep-alive packet.
@@ -131,11 +203,46 @@ protected virtual Task OnPingAsync(OperationMessage message)
131203
/// Executes when a pong message is received.
132204
/// </summary>
133205
protected virtual Task OnPongAsync(OperationMessage message)
134-
=> Task.CompletedTask;
206+
{
207+
if (_options.KeepAliveMode == KeepAliveMode.TimeoutWithPayload)
208+
{
209+
try
210+
{
211+
var pingId = _serializer.ReadNode<PingPayload>(message.Payload)?.id;
212+
lock (_lastPingLock)
213+
{
214+
if (_lastPingId == pingId)
215+
_lastPongReceivedUtc = DateTime.UtcNow;
216+
}
217+
}
218+
catch { } // ignore deserialization errors in case the pong message does not match the expected format
219+
}
220+
return Task.CompletedTask;
221+
}
135222

136223
/// <inheritdoc/>
137224
protected override Task OnSendKeepAliveAsync()
138-
=> Connection.SendMessageAsync(_pongMessage);
225+
{
226+
if (_options.KeepAliveMode == KeepAliveMode.TimeoutWithPayload)
227+
{
228+
var lastPingId = Guid.NewGuid().ToString("N");
229+
lock (_lastPingLock)
230+
{
231+
_lastPingId = lastPingId;
232+
}
233+
return Connection.SendMessageAsync(
234+
new()
235+
{
236+
Type = MessageType.Ping,
237+
Payload = new PingPayload { id = lastPingId }
238+
}
239+
);
240+
}
241+
else
242+
{
243+
return Connection.SendMessageAsync(_pongMessage);
244+
}
245+
}
139246

140247
private static readonly OperationMessage _connectionAckMessage = new() { Type = MessageType.ConnectionAck };
141248
/// <inheritdoc/>

0 commit comments

Comments
 (0)