-
Notifications
You must be signed in to change notification settings - Fork 394
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
websocket server #4130
Open
austinmilt
wants to merge
7
commits into
TASEmulators:master
Choose a base branch
from
austinmilt:amilt/websocket-server
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
websocket server #4130
Changes from 4 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
1fb1be1
rename WebSocketServer to WebSocketClient
austinmilt 278116f
add minimal websocket server with cli configs
austinmilt 2d02269
websocket message types with topics echo, error, registration
austinmilt 7050cb5
null enable flags
austinmilt f8d7ed3
fix serialization of error enum
austinmilt a04072f
revert formatting changes
austinmilt c3b1cce
revert formatting changes
austinmilt File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
#nullable enable | ||
|
||
namespace BizHawk.Client.Common | ||
{ | ||
public sealed class WebSocketClient | ||
{ | ||
public ClientWebSocketWrapper Open(Uri uri) => new(uri); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,14 +1,215 @@ | ||
#nullable enable | ||
|
||
using System.Net; | ||
using System.Threading.Tasks; | ||
using System.Net.WebSockets; | ||
using System.Threading; | ||
using System.Text; | ||
using System.Collections.Generic; | ||
using BizHawk.Client.Common.Websocket.Messages; | ||
using Newtonsoft.Json; | ||
using BizHawk.Common.CollectionExtensions; | ||
using Newtonsoft.Json.Serialization; | ||
|
||
namespace BizHawk.Client.Common | ||
{ | ||
public sealed class WebSocketServer | ||
{ | ||
public ClientWebSocketWrapper Open( | ||
Uri uri, | ||
CancellationToken cancellationToken = default/* == CancellationToken.None */) | ||
=> new(uri, cancellationToken); | ||
private static readonly HashSet<Topic> forcedRegistrationTopics = [Topic.Error, Topic.Registration]; | ||
private readonly HttpListener clientRegistrationListener; | ||
private CancellationToken _cancellationToken = default; | ||
private bool _running = false; | ||
private readonly Dictionary<string, WebSocket> clients = []; | ||
private readonly Dictionary<Topic, HashSet<string>> topicRegistrations = []; | ||
|
||
/// <param name="host"> | ||
/// host address to register for listening to connections, defaults to <see cref="IPAddress.Loopback"/>> | ||
/// </param> | ||
/// <param name="port">port to register for listening to connections</param> | ||
public WebSocketServer(IPAddress? host = null, int port = 3333) | ||
{ | ||
clientRegistrationListener = new(); | ||
clientRegistrationListener.Prefixes.Add($"http://{host}:{port}/"); | ||
} | ||
|
||
/// <summary> | ||
/// Stops the server. Alternatively, use the cancellation token passed into <see cref="Start"/>. | ||
/// The server can be restarted by calling <see cref="Start"/> again. | ||
/// </summary> | ||
public void Stop() | ||
{ | ||
var cancellationTokenSource = new CancellationTokenSource(); | ||
_cancellationToken = cancellationTokenSource.Token; | ||
cancellationTokenSource.Cancel(); | ||
_running = false; | ||
} | ||
|
||
/// <summary> | ||
/// Starts the websocket server at the configured address and registers clients. | ||
/// </summary> | ||
/// <param name="cancellationToken">optional cancellation token to stop the server</param> | ||
/// <returns>async task for the server loop</returns> | ||
public async Task Start(CancellationToken cancellationToken = default) | ||
{ | ||
if (_running) | ||
{ | ||
throw new InvalidOperationException("Server has already been started"); | ||
} | ||
_cancellationToken = cancellationToken; | ||
_running = true; | ||
|
||
clientRegistrationListener.Start(); | ||
await ListenForAndRegisterClients(); | ||
} | ||
|
||
private async Task ListenForAndRegisterClients() | ||
{ | ||
while (_running && !_cancellationToken.IsCancellationRequested) | ||
{ | ||
var context = await clientRegistrationListener.GetContextAsync(); | ||
if (context is null) return; | ||
|
||
if (!context.Request.IsWebSocketRequest) | ||
{ | ||
context.Response.Abort(); | ||
return; | ||
} | ||
|
||
var webSocketContext = await context.AcceptWebSocketAsync(subProtocol: null); | ||
if (webSocketContext is null) return; | ||
RegisterClient(webSocketContext.WebSocket); | ||
} | ||
} | ||
|
||
private void RegisterClient(WebSocket newClient) | ||
{ | ||
string clientId = Guid.NewGuid().ToString(); | ||
clients.Add(clientId, newClient); | ||
_ = Task.Run(() => ClientMessageReceiveLoop(clientId), _cancellationToken); | ||
} | ||
|
||
private async Task ClientMessageReceiveLoop(string clientId) | ||
{ | ||
byte[] buffer = new byte[1024]; | ||
var messageStringBuilder = new StringBuilder(2048); | ||
var client = clients[clientId]; | ||
while (client.State == WebSocketState.Open && !_cancellationToken.IsCancellationRequested) | ||
{ | ||
ArraySegment<byte> messageBuffer = new(buffer); | ||
var receiveResult = await client.ReceiveAsync(messageBuffer, _cancellationToken); | ||
if (receiveResult.Count == 0) | ||
return; | ||
|
||
messageStringBuilder.Append(Encoding.ASCII.GetString(buffer, 0, receiveResult.Count)); | ||
if (receiveResult.EndOfMessage) | ||
{ | ||
string messageString = messageStringBuilder.ToString(); | ||
messageStringBuilder = new StringBuilder(2048); | ||
|
||
try | ||
{ | ||
var request = JsonSerde.Deserialize<RequestMessageWrapper>(messageString); | ||
await HandleRequest(clientId, request); | ||
} | ||
catch (Exception e) | ||
{ | ||
// TODO proper logging | ||
Console.WriteLine("Error deserializing message {0}", e); | ||
await SendClientGenericError(clientId); | ||
} | ||
} | ||
} | ||
} | ||
|
||
private async Task HandleRequest(string clientId, RequestMessageWrapper request) | ||
{ | ||
try | ||
{ | ||
switch (request.Topic) | ||
{ | ||
case Topic.Error: | ||
// clients arent allowed to publish to this topic | ||
await SendClientGenericError(clientId); | ||
break; | ||
|
||
case Topic.Registration: | ||
await HandleRegistrationRequest(clientId, request.Registration!.Value); | ||
break; | ||
|
||
case Topic.Echo: | ||
await HandleEchoRequest(clientId, request.Echo!.Value); | ||
break; | ||
} | ||
|
||
} | ||
catch (Exception e) | ||
{ | ||
// this could happen if, for instance, the client sent a registration request to the echo topic, such | ||
// that we tried to access the wrong field of the wrapper | ||
// TODO proper logging | ||
Console.WriteLine("Error handling message {0}", e); | ||
await SendClientGenericError(clientId); | ||
} | ||
} | ||
|
||
private async Task HandleRegistrationRequest(string clientId, RegistrationRequestMessage request) | ||
{ | ||
foreach (Topic topic in Enum.GetValues(typeof(Topic))) | ||
{ | ||
if (forcedRegistrationTopics.Contains(topic)) | ||
{ | ||
// we dont need to keep track of topics that clients must be registered for. | ||
continue; | ||
} | ||
else if (request.Topics.Contains(topic)) | ||
{ | ||
_ = topicRegistrations.GetValueOrPut(topic, (_) => []).Add(clientId); | ||
} | ||
else | ||
{ | ||
_ = topicRegistrations.GetValueOrDefault(topic, [])?.Remove(clientId); | ||
} | ||
} | ||
|
||
var registeredTopics = request.Topics; | ||
registeredTopics.AddRange(forcedRegistrationTopics); | ||
var responseMessage = new ResponseMessageWrapper(new RegistrationResponseMessage(registeredTopics)); | ||
await SendClientMessage(clientId, responseMessage); | ||
} | ||
|
||
private async Task HandleEchoRequest(string clientId, EchoRequestMessage request) | ||
{ | ||
if (topicRegistrations.GetValueOrDefault(Topic.Echo, [])?.Contains(clientId) ?? false) | ||
{ | ||
await SendClientMessage(clientId, new ResponseMessageWrapper(new EchoResponseMessage(request.Message))); | ||
} | ||
} | ||
|
||
// clients always get error topics | ||
private async Task SendClientGenericError(string clientId) => await SendClientMessage(clientId, new ResponseMessageWrapper(new ErrorMessage(ErrorType.UnknownRequest))); | ||
|
||
private async Task SendClientMessage(string clientId, object message) | ||
{ | ||
await clients[clientId].SendAsync( | ||
JsonSerde.Serialize(message), | ||
WebSocketMessageType.Text, | ||
endOfMessage: true, | ||
_cancellationToken | ||
); | ||
} | ||
|
||
private static class JsonSerde | ||
{ | ||
|
||
private static readonly JsonSerializerSettings serializerSettings = new() | ||
{ | ||
NullValueHandling = NullValueHandling.Ignore, | ||
ContractResolver = new CamelCasePropertyNamesContractResolver(), | ||
}; | ||
|
||
public static ArraySegment<byte> Serialize(object message) => new(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(message, serializerSettings))); | ||
|
||
public static T? Deserialize<T>(string message) => JsonConvert.DeserializeObject<T>(message, serializerSettings); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would appreciate advice on how to properly do logging (both debug and otherwise)