Compare commits

...

7 commits

23 changed files with 1267 additions and 94 deletions

View file

@ -9,9 +9,10 @@ namespace Hamakaze.Headers {
public const string CLOSE = @"close"; public const string CLOSE = @"close";
public const string KEEP_ALIVE = @"keep-alive"; public const string KEEP_ALIVE = @"keep-alive";
public const string UPGRADE = @"upgrade";
public HttpConnectionHeader(string mode) { public HttpConnectionHeader(string mode) {
Value = mode ?? throw new ArgumentNullException(nameof(mode)); Value = (mode ?? throw new ArgumentNullException(nameof(mode))).ToLowerInvariant();
} }
} }
} }

View file

@ -1,14 +1,22 @@
using Hamakaze.Headers; using Hamakaze.Headers;
using Hamakaze.WebSocket;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
namespace Hamakaze { namespace Hamakaze {
public class HttpClient : IDisposable { public class HttpClient : IDisposable {
public const string PRODUCT_STRING = @"HMKZ"; public const string PRODUCT_STRING = @"HMKZ";
public const string VERSION_MAJOR = @"1"; public const string VERSION_MAJOR = @"1";
public const string VERSION_MINOR = @"0"; public const string VERSION_MINOR = @"1";
public const string USER_AGENT = PRODUCT_STRING + @"/" + VERSION_MAJOR + @"." + VERSION_MINOR; public const string USER_AGENT = PRODUCT_STRING + @"/" + VERSION_MAJOR + @"." + VERSION_MINOR;
private const string WS_GUID = @"258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
private const string WS_PROTO = @"websocket";
private const int WS_RNG = 16;
private static HttpClient InstanceValue { get; set; } private static HttpClient InstanceValue { get; set; }
public static HttpClient Instance { public static HttpClient Instance {
get { get {
@ -47,7 +55,8 @@ namespace Hamakaze {
request.UserAgent = DefaultUserAgent; request.UserAgent = DefaultUserAgent;
if(!request.HasHeader(HttpAcceptEncodingHeader.NAME)) if(!request.HasHeader(HttpAcceptEncodingHeader.NAME))
request.AcceptedEncodings = AcceptedEncodings; request.AcceptedEncodings = AcceptedEncodings;
request.Connection = ReuseConnections ? HttpConnectionHeader.KEEP_ALIVE : HttpConnectionHeader.CLOSE; if(!request.HasHeader(HttpConnectionHeader.NAME))
request.Connection = ReuseConnections ? HttpConnectionHeader.KEEP_ALIVE : HttpConnectionHeader.CLOSE;
HttpTask task = new(Connections, request, disposeRequest, disposeResponse); HttpTask task = new(Connections, request, disposeRequest, disposeResponse);
@ -85,6 +94,131 @@ namespace Hamakaze {
RunTask(CreateTask(request, onComplete, onError, onCancel, onDownloadProgress, onUploadProgress, onStateChange, disposeRequest, disposeResponse)); RunTask(CreateTask(request, onComplete, onError, onCancel, onDownloadProgress, onUploadProgress, onStateChange, disposeRequest, disposeResponse));
} }
public void CreateWsClient(
string url,
Action<WsClient> onOpen,
Action<WsMessage> onMessage,
Action<Exception> onError,
IEnumerable<string> protocols = null,
Action<HttpResponseMessage> onResponse = null,
bool disposeRequest = true,
bool disposeResponse = true
) => CreateWsConnection(
url,
conn => onOpen(new WsClient(conn, onMessage, onError)),
onError,
protocols,
onResponse,
disposeRequest,
disposeResponse
);
public void CreateWsClient(
HttpRequestMessage request,
Action<WsClient> onOpen,
Action<WsMessage> onMessage,
Action<Exception> onError,
IEnumerable<string> protocols = null,
Action<HttpResponseMessage> onResponse = null,
bool disposeRequest = true,
bool disposeResponse = true
) => CreateWsConnection(
request,
conn => onOpen(new WsClient(conn, onMessage, onError)),
onError,
protocols,
onResponse,
disposeRequest,
disposeResponse
);
public void CreateWsConnection(
string url,
Action<WsConnection> onOpen,
Action<Exception> onError,
IEnumerable<string> protocols = null,
Action<HttpResponseMessage> onResponse = null,
bool disposeRequest = true,
bool disposeResponse = true
) => CreateWsConnection(
new HttpRequestMessage(@"GET", url),
onOpen,
onError,
protocols,
onResponse,
disposeRequest,
disposeResponse
);
public void CreateWsConnection(
HttpRequestMessage request,
Action<WsConnection> onOpen,
Action<Exception> onError,
IEnumerable<string> protocols = null,
Action<HttpResponseMessage> onResponse = null,
bool disposeRequest = true,
bool disposeResponse = true
) {
string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(WS_RNG));
request.Connection = HttpConnectionHeader.UPGRADE;
request.SetHeader(@"Cache-Control", @"no-cache");
request.SetHeader(@"Upgrade", WS_PROTO);
request.SetHeader(@"Sec-WebSocket-Key", key);
request.SetHeader(@"Sec-WebSocket-Version", @"13");
if(protocols?.Any() == true)
request.SetHeader(@"Sec-WebSocket-Protocol", string.Join(@", ", protocols));
SendRequest(
request,
(t, res) => {
try {
onResponse?.Invoke(res);
if(res.ProtocolVersion.CompareTo(@"1.1") < 0)
throw new HttpUpgradeProtocolVersionException(@"1.1", res.ProtocolVersion);
if(res.StatusCode != 101)
throw new HttpUpgradeUnexpectedStatusException(res.StatusCode);
if(res.Connection != HttpConnectionHeader.UPGRADE)
throw new HttpUpgradeUnexpectedHeaderException(
@"Connection",
HttpConnectionHeader.UPGRADE,
res.Connection
);
string hUpgrade = res.GetHeaderLine(@"Upgrade");
if(hUpgrade != WS_PROTO)
throw new HttpUpgradeUnexpectedHeaderException(@"Upgrade", WS_PROTO, hUpgrade);
string serverHashStr = res.GetHeaderLine(@"Sec-WebSocket-Accept");
byte[] expectHash = SHA1.HashData(Encoding.ASCII.GetBytes(key + WS_GUID));
if(string.IsNullOrWhiteSpace(serverHashStr))
throw new HttpUpgradeUnexpectedHeaderException(
@"Sec-WebSocket-Accept",
Convert.ToBase64String(expectHash),
serverHashStr
);
byte[] givenHash = Convert.FromBase64String(serverHashStr.Trim());
if(!expectHash.SequenceEqual(givenHash))
throw new HttpUpgradeInvalidHashException(Convert.ToBase64String(expectHash), serverHashStr);
onOpen(t.Connection.ToWebSocket());
} catch(Exception ex) {
onError(ex);
}
},
(t, ex) => onError(ex),
disposeRequest: disposeRequest,
disposeResponse: disposeResponse
);
}
public static void Send( public static void Send(
HttpRequestMessage request, HttpRequestMessage request,
Action<HttpTask, HttpResponseMessage> onComplete = null, Action<HttpTask, HttpResponseMessage> onComplete = null,
@ -95,9 +229,57 @@ namespace Hamakaze {
Action<HttpTask, HttpTask.TaskState> onStateChange = null, Action<HttpTask, HttpTask.TaskState> onStateChange = null,
bool disposeRequest = true, bool disposeRequest = true,
bool disposeResponse = true bool disposeResponse = true
) { ) => Instance.SendRequest(
Instance.SendRequest(request, onComplete, onError, onCancel, onDownloadProgress, onUploadProgress, onStateChange, disposeRequest, disposeResponse); request,
} onComplete,
onError,
onCancel,
onDownloadProgress,
onUploadProgress,
onStateChange,
disposeRequest,
disposeResponse
);
public static void Connect(
string url,
Action<WsClient> onOpen,
Action<WsMessage> onMessage,
Action<Exception> onError,
IEnumerable<string> protocols = null,
Action<HttpResponseMessage> onResponse = null,
bool disposeRequest = true,
bool disposeResponse = true
) => Instance.CreateWsClient(
url,
onOpen,
onMessage,
onError,
protocols,
onResponse,
disposeRequest,
disposeResponse
);
public static void Connect(
HttpRequestMessage request,
Action<WsClient> onOpen,
Action<WsMessage> onMessage,
Action<Exception> onError,
IEnumerable<string> protocols = null,
Action<HttpResponseMessage> onResponse = null,
bool disposeRequest = true,
bool disposeResponse = true
) => Instance.CreateWsClient(
request,
onOpen,
onMessage,
onError,
protocols,
onResponse,
disposeRequest,
disposeResponse
);
private bool IsDisposed; private bool IsDisposed;
~HttpClient() ~HttpClient()

View file

@ -4,27 +4,29 @@ using System.Net;
using System.Net.Security; using System.Net.Security;
using System.Net.Sockets; using System.Net.Sockets;
using System.Security.Authentication; using System.Security.Authentication;
using System.Threading; using Hamakaze.WebSocket;
namespace Hamakaze { namespace Hamakaze {
public class HttpConnection : IDisposable { public class HttpConnection : IDisposable {
public IPEndPoint EndPoint { get; } public IPEndPoint EndPoint { get; }
public Stream Stream { get; } public Stream Stream { get; }
public Socket Socket { get; } private Socket Socket { get; }
public NetworkStream NetworkStream { get; }
public SslStream SslStream { get; } private NetworkStream NetworkStream { get; }
private SslStream SslStream { get; }
public string Host { get; } public string Host { get; }
public bool IsSecure { get; } public bool IsSecure { get; }
public bool HasTimedOut => MaxRequests == 0 || (DateTimeOffset.Now - LastOperation) > MaxIdle; public bool HasTimedOut => MaxRequests < 1 || (DateTimeOffset.Now - LastOperation) > MaxIdle;
public int MaxRequests { get; set; } = -1; public int? MaxRequests { get; set; } = null;
public TimeSpan MaxIdle { get; set; } = TimeSpan.MaxValue; public TimeSpan MaxIdle { get; set; } = TimeSpan.MaxValue;
public DateTimeOffset LastOperation { get; private set; } = DateTimeOffset.Now; public DateTimeOffset LastOperation { get; private set; } = DateTimeOffset.Now;
public bool InUse { get; private set; } public bool InUse { get; private set; }
public bool HasUpgraded { get; private set; }
public HttpConnection(string host, IPEndPoint endPoint, bool secure) { public HttpConnection(string host, IPEndPoint endPoint, bool secure) {
Host = host ?? throw new ArgumentNullException(nameof(host)); Host = host ?? throw new ArgumentNullException(nameof(host));
@ -45,25 +47,41 @@ namespace Hamakaze {
if(IsSecure) { if(IsSecure) {
SslStream = new SslStream(NetworkStream, false, (s, ce, ch, e) => e == SslPolicyErrors.None, null); SslStream = new SslStream(NetworkStream, false, (s, ce, ch, e) => e == SslPolicyErrors.None, null);
Stream = SslStream; Stream = SslStream;
SslStream.AuthenticateAsClient(Host, null, SslProtocols.Tls11 | SslProtocols.Tls12 | SslProtocols.Tls13, true); SslStream.AuthenticateAsClient(
Host,
null,
SslProtocols.Tls11 | SslProtocols.Tls12 | SslProtocols.Tls13,
true
);
} else } else
Stream = NetworkStream; Stream = NetworkStream;
} }
public void MarkUsed() { public void MarkUsed() {
LastOperation = DateTimeOffset.Now; LastOperation = DateTimeOffset.Now;
if(MaxRequests > 0) if(MaxRequests != null)
--MaxRequests; --MaxRequests;
} }
public bool Acquire() { public bool Acquire() {
return !InUse && (InUse = true); return !HasUpgraded && !InUse && (InUse = true);
} }
public void Release() { public void Release() {
InUse = false; InUse = false;
} }
public WsConnection ToWebSocket() {
if(HasUpgraded)
throw new HttpConnectionAlreadyUpgradedException();
HasUpgraded = true;
NetworkStream.ReadTimeout = -1;
SslStream.ReadTimeout = -1;
return new WsConnection(Stream);
}
private bool IsDisposed; private bool IsDisposed;
~HttpConnection() ~HttpConnection()
=> DoDispose(); => DoDispose();
@ -75,7 +93,9 @@ namespace Hamakaze {
if(IsDisposed) if(IsDisposed)
return; return;
IsDisposed = true; IsDisposed = true;
Stream.Dispose();
if(!HasUpgraded)
Stream.Dispose();
} }
} }
} }

View file

@ -5,6 +5,32 @@ namespace Hamakaze {
public HttpException(string message) : base(message) { } public HttpException(string message) : base(message) { }
} }
public class HttpUpgradeException : HttpException {
public HttpUpgradeException(string message) : base(message) { }
}
public class HttpUpgradeProtocolVersionException : HttpUpgradeException {
public HttpUpgradeProtocolVersionException(string expectedVersion, string givenVersion)
: base($@"Server HTTP version ({givenVersion}) is lower than what is expected {expectedVersion}.") { }
}
public class HttpUpgradeUnexpectedStatusException : HttpUpgradeException {
public HttpUpgradeUnexpectedStatusException(int statusCode) : base($@"Expected HTTP status code 101, got {statusCode}.") { }
}
public class HttpUpgradeUnexpectedHeaderException : HttpUpgradeException {
public HttpUpgradeUnexpectedHeaderException(string header, string expected, string given)
: base($@"Unexpected {header} header value ""{given}"", expected ""{expected}"".") { }
}
public class HttpUpgradeInvalidHashException : HttpUpgradeException {
public HttpUpgradeInvalidHashException(string expected, string given)
: base($@"Server sent invalid hash ""{given}"", expected ""{expected}"".") { }
}
public class HttpConnectionException : HttpException {
public HttpConnectionException(string message) : base(message) { }
}
public class HttpConnectionAlreadyUpgradedException : HttpConnectionException {
public HttpConnectionAlreadyUpgradedException() : base(@"This connection has already been upgraded.") { }
}
public class HttpConnectionManagerException : HttpException { public class HttpConnectionManagerException : HttpException {
public HttpConnectionManagerException(string message) : base(message) { } public HttpConnectionManagerException(string message) : base(message) { }
} }
@ -12,6 +38,13 @@ namespace Hamakaze {
public HttpConnectionManagerLockException() : base(@"Failed to lock the connection manager in time.") { } public HttpConnectionManagerLockException() : base(@"Failed to lock the connection manager in time.") { }
} }
public class HttpRequestMessageException : HttpException {
public HttpRequestMessageException(string message) : base(message) { }
}
public class HttpRequestMessageStreamException : HttpRequestMessageException {
public HttpRequestMessageStreamException() : base(@"Provided Stream is not writable.") { }
}
public class HttpTaskException : HttpException { public class HttpTaskException : HttpException {
public HttpTaskException(string message) : base(message) { } public HttpTaskException(string message) : base(message) { }
} }

View file

@ -92,7 +92,8 @@ namespace Hamakaze {
public HttpRequestMessage(string method, Uri uri) { public HttpRequestMessage(string method, Uri uri) {
Method = method ?? throw new ArgumentNullException(nameof(method)); Method = method ?? throw new ArgumentNullException(nameof(method));
RequestTarget = uri.PathAndQuery; RequestTarget = uri.PathAndQuery;
IsSecure = uri.Scheme.Equals(@"https", StringComparison.InvariantCultureIgnoreCase); IsSecure = uri.Scheme.Equals(@"https", StringComparison.InvariantCultureIgnoreCase)
|| uri.Scheme.Equals(@"wss", StringComparison.InvariantCultureIgnoreCase);
Host = uri.Host; Host = uri.Host;
ushort defaultPort = (IsSecure ? HTTPS : HTTP); ushort defaultPort = (IsSecure ? HTTPS : HTTP);
Port = uri.Port == -1 ? defaultPort : (ushort)uri.Port; Port = uri.Port == -1 ? defaultPort : (ushort)uri.Port;
@ -157,6 +158,9 @@ namespace Hamakaze {
} }
public void WriteTo(Stream stream, Action<long, long> onProgress = null) { public void WriteTo(Stream stream, Action<long, long> onProgress = null) {
if(!stream.CanWrite)
throw new HttpRequestMessageStreamException();
using(StreamWriter sw = new(stream, new ASCIIEncoding(), leaveOpen: true)) { using(StreamWriter sw = new(stream, new ASCIIEncoding(), leaveOpen: true)) {
sw.NewLine = "\r\n"; sw.NewLine = "\r\n";
sw.Write(Method); sw.Write(Method);

View file

@ -124,10 +124,10 @@ namespace Hamakaze {
using MemoryStream ms = new(); using MemoryStream ms = new();
int byt; ushort lastTwo = 0; int byt; ushort lastTwo = 0;
for(; ; ) { for(;;) {
byt = stream.ReadByte(); byt = stream.ReadByte();
if(byt == -1 && ms.Length == 0) if(byt == -1 && ms.Length == 0)
return null; throw new IOException(@"readLine: There is no data.");
ms.WriteByte((byte)byt); ms.WriteByte((byte)byt);
@ -151,7 +151,7 @@ namespace Hamakaze {
if(line == null) if(line == null)
throw new IOException(@"Failed to read initial HTTP header."); throw new IOException(@"Failed to read initial HTTP header.");
if(!line.StartsWith(@"HTTP/")) if(!line.StartsWith(@"HTTP/"))
throw new IOException(@"Response is not a valid HTTP message."); throw new IOException($@"Response is not a valid HTTP message: {line}.");
string[] parts = line[5..].Split(' ', 3); string[] parts = line[5..].Split(' ', 3);
if(!int.TryParse(parts.ElementAtOrDefault(1), out int statusCode)) if(!int.TryParse(parts.ElementAtOrDefault(1), out int statusCode))
throw new IOException(@"Invalid HTTP status code format."); throw new IOException(@"Invalid HTTP status code format.");
@ -238,11 +238,11 @@ namespace Hamakaze {
readBuffer(chunkLength); readBuffer(chunkLength);
readLine(); readLine();
} }
readLine(); readLine();
} else if(contentLength != 0) { } else if(contentLength != 0) {
body = new MemoryStream(); body = new MemoryStream();
readBuffer(contentLength); readBuffer(contentLength);
readLine();
} }
if(body != null) if(body != null)

View file

@ -1,7 +1,6 @@
using Hamakaze.Headers; using Hamakaze.Headers;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
@ -25,7 +24,7 @@ namespace Hamakaze {
private HttpConnectionManager Connections { get; } private HttpConnectionManager Connections { get; }
private IEnumerable<IPAddress> Addresses { get; set; } private IEnumerable<IPAddress> Addresses { get; set; }
private HttpConnection Connection { get; set; } public HttpConnection Connection { get; private set; }
public bool DisposeRequest { get; set; } public bool DisposeRequest { get; set; }
public bool DisposeResponse { get; set; } public bool DisposeResponse { get; set; }
@ -70,102 +69,90 @@ namespace Hamakaze {
if(IsCancelled) if(IsCancelled)
return false; return false;
switch(State) { try {
case TaskState.Initial: switch(State) {
State = TaskState.Lookup; case TaskState.Initial:
OnStateChange?.Invoke(this, State); State = TaskState.Lookup;
DoLookup(); OnStateChange?.Invoke(this, State);
break; DoLookup();
case TaskState.Lookup: break;
State = TaskState.Request; case TaskState.Lookup:
OnStateChange?.Invoke(this, State); State = TaskState.Request;
DoRequest(); OnStateChange?.Invoke(this, State);
break; DoRequest();
case TaskState.Request: break;
State = TaskState.Response; case TaskState.Request:
OnStateChange?.Invoke(this, State); State = TaskState.Response;
DoResponse(); OnStateChange?.Invoke(this, State);
break; DoResponse();
case TaskState.Response: break;
State = TaskState.Finished; case TaskState.Response:
OnStateChange?.Invoke(this, State); State = TaskState.Finished;
OnComplete?.Invoke(this, Response); OnStateChange?.Invoke(this, State);
if(DisposeResponse) OnComplete?.Invoke(this, Response);
Response?.Dispose(); if(DisposeResponse)
if(DisposeRequest) Response?.Dispose();
Request?.Dispose(); if(DisposeRequest)
return false; Request?.Dispose();
default: return false;
Error(new HttpTaskInvalidStateException()); default:
return false; throw new HttpTaskInvalidStateException();
}
} catch(Exception ex) {
Error(ex);
return false;
} }
return true; return true;
} }
private void DoLookup() { private void DoLookup() {
try { Addresses = Dns.GetHostAddresses(Request.Host);
Addresses = Dns.GetHostAddresses(Request.Host);
} catch(Exception ex) {
Error(ex);
return;
}
if(!Addresses.Any()) if(!Addresses.Any())
Error(new HttpTaskNoAddressesException()); throw new HttpTaskNoAddressesException();
} }
private void DoRequest() { private void DoRequest() {
Exception exception = null; Queue<IPAddress> addresses = new(Addresses);
try { while(addresses.TryDequeue(out IPAddress addr)) {
foreach(IPAddress addr in Addresses) { int tries = 0;
int tries = 0; IPEndPoint endPoint = new(addr, Request.Port);
IPEndPoint endPoint = new(addr, Request.Port);
exception = null; Connection = Connections.GetConnection(Request.Host, endPoint, Request.IsSecure);
retry:
++tries;
try {
Request.WriteTo(Connection.Stream, (p, t) => OnUploadProgress?.Invoke(this, p, t));
break;
} catch(HttpRequestMessageStreamException) {
Connection.Dispose();
Connection = Connections.GetConnection(Request.Host, endPoint, Request.IsSecure); Connection = Connections.GetConnection(Request.Host, endPoint, Request.IsSecure);
retry: if(tries < 2)
++tries; goto retry;
try {
Request.WriteTo(Connection.Stream, (p, t) => OnUploadProgress?.Invoke(this, p, t));
break;
} catch(IOException ex) {
Connection.Dispose();
Connection = Connections.GetConnection(Request.Host, endPoint, Request.IsSecure);
if(tries < 2) if(!addresses.Any())
goto retry; throw;
} finally {
exception = ex; Connection.MarkUsed();
continue;
} finally {
Connection.MarkUsed();
}
} }
} catch(Exception ex) {
Error(ex);
} }
if(exception != null) if(Connection == null)
Error(exception); throw new HttpTaskNoConnectionException();
else if(Connection == null)
Error(new HttpTaskNoConnectionException());
} }
private void DoResponse() { private void DoResponse() {
try { Response = HttpResponseMessage.ReadFrom(Connection.Stream, (p, t) => OnDownloadProgress?.Invoke(this, p, t));
Response = HttpResponseMessage.ReadFrom(Connection.Stream, (p, t) => OnDownloadProgress?.Invoke(this, p, t));
} catch(Exception ex) {
Error(ex);
return;
}
if(Response.Connection == HttpConnectionHeader.CLOSE) if(Response.Connection == HttpConnectionHeader.CLOSE
|| Response.ProtocolVersion.CompareTo(@"1.1") < 0)
Connection.Dispose(); Connection.Dispose();
if(Response == null) if(Response == null)
Error(new HttpTaskRequestFailedException()); throw new HttpTaskRequestFailedException();
HttpKeepAliveHeader hkah = Response.Headers.Where(x => x.Name == HttpKeepAliveHeader.NAME).Cast<HttpKeepAliveHeader>().FirstOrDefault(); HttpKeepAliveHeader hkah = Response.Headers.Where(x => x.Name == HttpKeepAliveHeader.NAME).Cast<HttpKeepAliveHeader>().FirstOrDefault();
if(hkah != null) { if(hkah != null) {

View file

@ -0,0 +1,5 @@
namespace Hamakaze.WebSocket {
public interface IHasBinaryData {
byte[] Data { get; }
}
}

View file

@ -0,0 +1,11 @@
using System;
namespace Hamakaze.WebSocket {
public class WsBinaryMessage : WsMessage, IHasBinaryData {
public byte[] Data { get; }
public WsBinaryMessage(byte[] data) {
Data = data ?? Array.Empty<byte>();
}
}
}

View file

@ -0,0 +1,36 @@
using System;
namespace Hamakaze.WebSocket {
public class WsBufferedSend : IDisposable {
private WsConnection Connection { get; }
internal WsBufferedSend(WsConnection conn) {
Connection = conn ?? throw new ArgumentNullException(nameof(conn));
}
public void SendPart(ReadOnlySpan<byte> data)
=> Connection.WriteFrame(WsOpcode.DataBinary, data, false);
public void SendFinalPart(ReadOnlySpan<byte> data)
=> Connection.WriteFrame(WsOpcode.DataBinary, data, true);
private bool IsDisposed;
~WsBufferedSend() {
DoDispose();
}
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
private void DoDispose() {
if(IsDisposed)
return;
IsDisposed = true;
Connection.EndBufferedSend();
}
}
}

View file

@ -0,0 +1,306 @@
using System;
using System.Threading;
// todo: sending errors as fake close messages
namespace Hamakaze.WebSocket {
public class WsClient : IDisposable {
public WsConnection Connection { get; }
public bool IsRunning { get; private set; } = true;
private Thread ReadThread { get; }
private Action<WsMessage> MessageHandler { get; }
private Action<Exception> ExceptionHandler { get; }
private Mutex SendLock { get; }
private const int TIMEOUT = 60000;
public WsClient(
WsConnection connection,
Action<WsMessage> messageHandler,
Action<Exception> exceptionHandler
) {
Connection = connection ?? throw new ArgumentNullException(nameof(connection));
MessageHandler = messageHandler ?? throw new ArgumentNullException(nameof(messageHandler));
ExceptionHandler = exceptionHandler ?? throw new ArgumentNullException(nameof(exceptionHandler));
SendLock = new();
ReadThread = new(ReadThreadBody) { IsBackground = true };
ReadThread.Start();
}
private void ReadThreadBody() {
try {
while(IsRunning)
MessageHandler(Connection.Receive());
} catch(Exception ex) {
IsRunning = false;
ExceptionHandler(ex);
}
}
public void Send(string text) {
try {
if(!SendLock.WaitOne(TIMEOUT))
throw new WsClientMutexFailedException();
Connection.Send(text);
} finally {
SendLock.ReleaseMutex();
}
}
public void Send(object obj) {
if(obj == null)
throw new ArgumentNullException(nameof(obj));
try {
if(!SendLock.WaitOne(TIMEOUT))
throw new WsClientMutexFailedException();
Connection.Send(obj.ToString());
} finally {
SendLock.ReleaseMutex();
}
}
public void Send(ReadOnlySpan<byte> data) {
if(data == null)
throw new ArgumentNullException(nameof(data));
try {
if(!SendLock.WaitOne(TIMEOUT))
throw new WsClientMutexFailedException();
Connection.Send(data);
} finally {
SendLock.ReleaseMutex();
}
}
public void Send(byte[] buffer, int offset, int count) {
if(buffer == null)
throw new ArgumentNullException(nameof(buffer));
try {
if(!SendLock.WaitOne(TIMEOUT))
throw new WsClientMutexFailedException();
Connection.Send(buffer.AsSpan(offset, count));
} finally {
SendLock.ReleaseMutex();
}
}
public void Send(Action<WsBufferedSend> handler) {
if(handler == null)
throw new ArgumentNullException(nameof(handler));
try {
if(!SendLock.WaitOne(TIMEOUT))
throw new WsClientMutexFailedException();
using(WsBufferedSend bs = Connection.BeginBufferedSend())
handler(bs);
} finally {
SendLock.ReleaseMutex();
}
}
public void Ping() {
try {
if(!SendLock.WaitOne(TIMEOUT))
throw new WsClientMutexFailedException();
Connection.Ping();
} finally {
SendLock.ReleaseMutex();
}
}
public void Ping(ReadOnlySpan<byte> data) {
if(data == null)
throw new ArgumentNullException(nameof(data));
try {
if(!SendLock.WaitOne(TIMEOUT))
throw new WsClientMutexFailedException();
Connection.Ping(data);
} finally {
SendLock.ReleaseMutex();
}
}
public void Ping(byte[] buffer, int offset, int length) {
if(buffer == null)
throw new ArgumentNullException(nameof(buffer));
try {
if(!SendLock.WaitOne(TIMEOUT))
throw new WsClientMutexFailedException();
Connection.Ping(buffer.AsSpan(offset, length));
} finally {
SendLock.ReleaseMutex();
}
}
public void Pong() {
try {
if(!SendLock.WaitOne(TIMEOUT))
throw new WsClientMutexFailedException();
Connection.Pong();
} finally {
SendLock.ReleaseMutex();
}
}
public void Pong(ReadOnlySpan<byte> data) {
if(data == null)
throw new ArgumentNullException(nameof(data));
try {
if(!SendLock.WaitOne(TIMEOUT))
throw new WsClientMutexFailedException();
Connection.Pong(data);
} finally {
SendLock.ReleaseMutex();
}
}
public void Pong(byte[] buffer, int offset, int length) {
if(buffer == null)
throw new ArgumentNullException(nameof(buffer));
try {
if(!SendLock.WaitOne(TIMEOUT))
throw new WsClientMutexFailedException();
Connection.Pong(buffer.AsSpan(offset, length));
} finally {
SendLock.ReleaseMutex();
}
}
public void Close() {
try {
if(!SendLock.WaitOne(TIMEOUT))
throw new WsClientMutexFailedException();
Connection.Close(WsCloseReason.NormalClosure);
} finally {
SendLock.ReleaseMutex();
}
}
public void CloseEmpty() {
try {
if(!SendLock.WaitOne(TIMEOUT))
throw new WsClientMutexFailedException();
Connection.CloseEmpty();
} finally {
SendLock.ReleaseMutex();
}
}
public void Close(WsCloseReason opcode) {
try {
if(!SendLock.WaitOne(TIMEOUT))
throw new WsClientMutexFailedException();
Connection.Close(opcode);
} finally {
SendLock.ReleaseMutex();
}
}
public void Close(string reason) {
if(reason == null)
throw new ArgumentNullException(nameof(reason));
try {
if(!SendLock.WaitOne(TIMEOUT))
throw new WsClientMutexFailedException();
Connection.Close(WsCloseReason.NormalClosure, reason);
} finally {
SendLock.ReleaseMutex();
}
}
public void Close(WsCloseReason opcode, string reason) {
if(reason == null)
throw new ArgumentNullException(nameof(reason));
try {
if(!SendLock.WaitOne(TIMEOUT))
throw new WsClientMutexFailedException();
Connection.Close(opcode, reason);
} finally {
SendLock.ReleaseMutex();
}
}
public void Close(ReadOnlySpan<byte> data) {
if(data == null)
throw new ArgumentNullException(nameof(data));
try {
if(!SendLock.WaitOne(TIMEOUT))
throw new WsClientMutexFailedException();
Connection.Close(data);
} finally {
SendLock.ReleaseMutex();
}
}
public void Close(byte[] buffer, int offset, int length) {
if(buffer == null)
throw new ArgumentNullException(nameof(buffer));
try {
if(!SendLock.WaitOne(TIMEOUT))
throw new WsClientMutexFailedException();
Connection.Close(buffer.AsSpan(offset, length));
} finally {
SendLock.ReleaseMutex();
}
}
public void Close(WsCloseReason opcode, ReadOnlySpan<byte> data) {
if(data == null)
throw new ArgumentNullException(nameof(data));
try {
if(!SendLock.WaitOne(TIMEOUT))
throw new WsClientMutexFailedException();
Connection.Close(opcode, data);
} finally {
SendLock.ReleaseMutex();
}
}
public void Close(WsCloseReason code, byte[] buffer, int offset, int length) {
if(buffer == null)
throw new ArgumentNullException(nameof(buffer));
try {
if(!SendLock.WaitOne(TIMEOUT))
throw new WsClientMutexFailedException();
Connection.Close(code, buffer.AsSpan(offset, length));
} finally {
SendLock.ReleaseMutex();
}
}
private bool IsDisposed;
~WsClient() {
DoDispose();
}
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
private void DoDispose() {
if(IsDisposed)
return;
IsDisposed = true;
SendLock.Dispose();
Connection.Dispose();
}
}
}

View file

@ -0,0 +1,36 @@
using System;
using System.Text;
namespace Hamakaze.WebSocket {
public class WsCloseMessage : WsMessage, IHasBinaryData {
public WsCloseReason Reason { get; }
public string ReasonPhrase { get; }
public byte[] Data { get; }
public WsCloseMessage(WsCloseReason reason) {
Reason = reason;
ReasonPhrase = string.Empty;
Data = Array.Empty<byte>();
}
public WsCloseMessage(byte[] data) {
if(data == null) {
Reason = WsCloseReason.NoStatus;
ReasonPhrase = string.Empty;
Data = Array.Empty<byte>();
} else {
Reason = (WsCloseReason)WsUtils.ToU16(data);
Data = data;
if(data.Length > 2)
try {
ReasonPhrase = Encoding.UTF8.GetString(data, 2, data.Length - 2);
} catch {
ReasonPhrase = string.Empty;
}
else
ReasonPhrase = string.Empty;
}
}
}
}

View file

@ -0,0 +1,16 @@
namespace Hamakaze.WebSocket {
public enum WsCloseReason : ushort {
NormalClosure = 1000,
GoingAway = 1001,
ProtocolError = 1002,
InvalidData = 1003,
NoStatus = 1005, // virtual -> no data in close frame
AbnormalClosure = 1006, // virtual -> connection dropped
MalformedData = 1007,
PolicyViolation = 1008,
FrameTooLarge = 1009,
MissingExtension = 1010,
UnexpectedCondition = 1011,
TlsHandshakeFailed = 1015, // virtual -> obvious
}
}

View file

@ -0,0 +1,395 @@
using System;
using System.IO;
using System.Net.Security;
using System.Security.Cryptography;
using System.Text;
namespace Hamakaze.WebSocket {
public class WsConnection : IDisposable {
public Stream Stream { get; }
public bool IsSecure { get; }
public bool IsClosed { get; private set; }
private const byte MASK_FLAG = 0x80;
private const int MASK_SIZE = 4;
private WsOpcode FragmentType = 0;
private MemoryStream FragmentStream;
private WsBufferedSend BufferedSend;
public WsConnection(Stream stream) {
Stream = stream ?? throw new ArgumentNullException(nameof(stream));
IsSecure = stream is SslStream;
}
private static byte[] GenerateMask() {
return RandomNumberGenerator.GetBytes(MASK_SIZE);
}
private void StrictRead(byte[] buffer, int offset, int length) {
int read = Stream.Read(buffer, offset, length);
if(read < length)
throw new Exception(@"Was unable to read the requested amount of data.");
}
private (WsOpcode opcode, int length, bool isFinal, byte[] mask) ReadFrameHeader() {
byte[] buffer = new byte[8];
StrictRead(buffer, 0, 2);
WsOpcode opcode = (WsOpcode)(buffer[0] & 0x0F);
bool isFinal = (buffer[0] & (byte)WsOpcode.FlagFinal) > 0;
if(opcode >= WsOpcode.CtrlClose && !isFinal)
throw new WsInvalidOpcodeException((WsOpcode)buffer[0]);
bool isControl = (opcode & WsOpcode.CtrlClose) > 0;
if(isControl && !isFinal)
throw new WsInvalidControlFrameException(@"fragmented");
bool isMasked = (buffer[1] & MASK_FLAG) > 0;
// this may look stupid and you'd be correct but it's better than the stack of casts
// i'd otherwise have to do otherwise because c# converts everything back to int32
buffer[1] &= 0x7F;
long length = buffer[1];
if(length == 126) {
StrictRead(buffer, 0, 2);
length = WsUtils.ToU16(buffer);
} else if(length == 127) {
StrictRead(buffer, 0, 8);
length = WsUtils.ToI64(buffer);
}
if(isControl && length > 125)
throw new WsInvalidControlFrameException(@"too large");
// should there be a sanity check on the length of frames?
// i seriously don't understand the rationale behind both
// having a framing system but then also supporting frame lengths
// of 2^63, feels like 2^16 per frame would be a fine max.
// UPDATE: decided to put the max at 2^32-1
// it's still more than you should ever need for a single frame
// and it makes working with the number within a .NET context
// less of a bother.
if(length < 0 || length > int.MaxValue)
throw new WsInvalidFrameSizeException(length);
byte[] mask = null;
if(isMasked) {
StrictRead(buffer, 0, MASK_SIZE);
mask = buffer;
}
return (opcode, (int)length, isFinal, mask);
}
private int ReadFrameBody(byte[] target, int length, byte[] mask, int offset = 0) {
if(target == null)
throw new ArgumentNullException(nameof(target));
bool isMasked = mask != null;
int read;
const int bufferSize = 0x1000;
int take = length > bufferSize ? bufferSize : (int)length;
while(length > 0) {
read = Stream.Read(target, offset, take);
if(isMasked)
for(int i = 0; i < read; ++i) {
int o = offset + i;
target[o] ^= mask[o % MASK_SIZE];
}
length -= read;
offset += read;
if(take > length)
take = (int)length;
}
return offset;
}
private WsMessage ReadFrame() {
(WsOpcode opcode, int length, bool isFinal, byte[] mask) = ReadFrameHeader();
if(opcode is not WsOpcode.DataContinue
and not WsOpcode.DataBinary
and not WsOpcode.DataText
and not WsOpcode.CtrlClose
and not WsOpcode.CtrlPing
and not WsOpcode.CtrlPong)
throw new WsUnsupportedOpcodeException(opcode);
bool hasBody = length > 0;
bool isContinue = opcode == WsOpcode.DataContinue;
bool canFragment = (opcode & WsOpcode.CtrlClose) == 0;
byte[] body = length < 1 ? null : new byte[length];
if(hasBody) {
ReadFrameBody(body, length, mask);
if(canFragment) {
if(isContinue) {
if(FragmentType == 0)
throw new WsUnexpectedContinueException();
opcode = FragmentType;
FragmentStream ??= new();
FragmentStream.Write(body, 0, length);
} else {
if(FragmentType != 0)
throw new WsUnexpectedDataException();
if(!isFinal) {
FragmentType = opcode;
FragmentStream = new();
FragmentStream.Write(body, 0, length);
}
}
}
}
WsMessage msg;
if(isFinal) {
if(canFragment && isContinue) {
FragmentType = 0;
body = FragmentStream.ToArray();
FragmentStream.Dispose();
FragmentStream = null;
}
msg = opcode switch {
WsOpcode.DataText => new WsTextMessage(body),
WsOpcode.DataBinary => new WsBinaryMessage(body),
WsOpcode.CtrlClose => new WsCloseMessage(body),
WsOpcode.CtrlPing => new WsPingMessage(body),
WsOpcode.CtrlPong => new WsPongMessage(body),
// fallback, if we end up here something is very fucked
_ => throw new WsUnsupportedOpcodeException(opcode),
};
} else msg = null;
return msg;
}
public WsMessage Receive() {
WsMessage msg;
while((msg = ReadFrame()) == null);
return msg;
}
private void WriteFrameHeader(WsOpcode opcode, int length, bool isFinal, byte[] mask = null) {
if(length < 0 || length > int.MaxValue)
throw new WsInvalidFrameSizeException(length);
bool shouldMask = mask != null;
if(isFinal)
opcode |= WsOpcode.FlagFinal;
Stream.WriteByte((byte)opcode);
byte bLen1 = 0;
if(shouldMask)
bLen1 |= MASK_FLAG;
byte[] bLenBuff = WsUtils.FromI64(length);
if(length < 126) {
Stream.WriteByte((byte)(bLen1 | bLenBuff[7]));
} else if(length <= ushort.MaxValue) {
Stream.WriteByte((byte)(bLen1 | 126));
Stream.Write(bLenBuff, 6, 2);
} else {
Stream.WriteByte((byte)(bLen1 | 127));
Stream.Write(bLenBuff, 0, 8);
}
if(shouldMask)
Stream.Write(mask, 0, MASK_SIZE);
Stream.Flush();
}
private int WriteFrameBody(ReadOnlySpan<byte> body, byte[] mask = null, int offset = 0) {
if(body == null)
throw new ArgumentNullException(nameof(body));
if(mask != null) {
byte[] masked = new byte[body.Length];
for(int i = 0; i < body.Length; ++i)
masked[i] = (byte)(body[i] ^ mask[offset++ % MASK_SIZE]);
body = masked;
}
Stream.Write(body);
Stream.Flush();
return offset;
}
internal void WriteFrame(WsOpcode opcode, ReadOnlySpan<byte> body, bool isFinal) {
if(body == null)
throw new ArgumentNullException(nameof(body));
byte[] mask = GenerateMask();
WriteFrameHeader(opcode, body.Length, isFinal, mask);
if(body.Length > 0)
WriteFrameBody(body, mask);
}
private void WriteData(WsOpcode opcode, ReadOnlySpan<byte> body) {
if(body == null)
throw new ArgumentNullException(nameof(body));
if(BufferedSend != null)
throw new WsBufferedSendInSessionException();
if(body.Length > ushort.MaxValue) {
WriteFrame(opcode, body.Slice(0, ushort.MaxValue), false);
body = body.Slice(ushort.MaxValue);
while(body.Length > ushort.MaxValue) {
WriteFrame(WsOpcode.DataContinue, body.Slice(0, ushort.MaxValue), false);
body = body.Slice(ushort.MaxValue);
}
WriteFrame(WsOpcode.DataContinue, body, true);
} else
WriteFrame(opcode, body, true);
}
public void Send(string text)
=> WriteData(WsOpcode.DataText, Encoding.UTF8.GetBytes(text));
public void Send(ReadOnlySpan<byte> buffer)
=> WriteData(WsOpcode.DataBinary, buffer);
public WsBufferedSend BeginBufferedSend() {
if(BufferedSend != null)
throw new WsBufferedSendAlreadyActiveException();
return BufferedSend = new(this);
}
// this method should only be called from within WsBufferedSend.Dispose
internal void EndBufferedSend() {
BufferedSend = null;
}
private void WriteControl(WsOpcode opcode)
=> WriteFrameHeader(opcode, 0, true, GenerateMask());
private void WriteControl(WsOpcode opcode, ReadOnlySpan<byte> buffer) {
if(buffer == null)
throw new ArgumentNullException(nameof(buffer));
if(buffer.Length > 125)
throw new ArgumentException(@"Data may not be more than 125 bytes.", nameof(buffer));
byte[] mask = GenerateMask();
WriteFrameHeader(opcode, buffer.Length, true, mask);
WriteFrameBody(buffer, mask);
}
public void Ping()
=> WriteControl(WsOpcode.CtrlPing);
public void Ping(ReadOnlySpan<byte> buffer)
=> WriteControl(WsOpcode.CtrlPing, buffer);
public void Pong()
=> WriteControl(WsOpcode.CtrlPong);
public void Pong(ReadOnlySpan<byte> buffer)
=> WriteControl(WsOpcode.CtrlPong, buffer);
public void CloseEmpty() {
if(IsClosed)
return;
IsClosed = true;
WriteControl(WsOpcode.CtrlClose);
}
public void Close(ReadOnlySpan<byte> buffer) {
if(buffer == null)
throw new ArgumentNullException(nameof(buffer));
if(IsClosed)
return;
IsClosed = true;
WriteControl(WsOpcode.CtrlClose, buffer);
}
public void Close(WsCloseReason code)
=> Close(WsUtils.FromU16((ushort)code));
public void Close(WsCloseReason code, ReadOnlySpan<byte> reason) {
if(reason == null)
throw new ArgumentNullException(nameof(reason));
if(reason.Length > 123)
throw new ArgumentException(@"Reason may not be more than 123 bytes.", nameof(reason));
if(IsClosed)
return;
IsClosed = true;
byte[] mask = GenerateMask();
WriteFrameHeader(WsOpcode.CtrlClose, 2 + reason.Length, true, mask);
WriteFrameBody(WsUtils.FromU16((ushort)code), mask);
WriteFrameBody(reason, mask, 2);
}
public void Close(WsCloseReason code, string reason) {
if(reason == null)
throw new ArgumentNullException(nameof(reason));
int length = Encoding.UTF8.GetByteCount(reason);
if(length > 123)
throw new ArgumentException(@"Reason string may not exceed 123 bytes in length.", nameof(reason));
if(IsClosed)
return;
IsClosed = true;
byte[] mask = GenerateMask();
WriteFrameHeader(WsOpcode.CtrlClose, 2 + reason.Length, true, mask);
WriteFrameBody(WsUtils.FromU16((ushort)code), mask);
WriteFrameBody(Encoding.UTF8.GetBytes(reason), mask, 2);
}
private bool IsDisposed;
~WsConnection() {
DoDispose();
}
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
private void DoDispose() {
if(IsDisposed)
return;
IsDisposed = true;
BufferedSend?.Dispose();
FragmentStream?.Dispose();
Stream.Dispose();
}
}
}

View file

@ -0,0 +1,41 @@
namespace Hamakaze.WebSocket {
public class WsException : HttpException {
public WsException(string message) : base(message) { }
}
public class WsInvalidOpcodeException : WsException {
public WsInvalidOpcodeException(WsOpcode opcode) : base($@"An invalid WebSocket opcode was encountered: {opcode}.") { }
}
public class WsUnsupportedOpcodeException : WsException {
public WsUnsupportedOpcodeException(WsOpcode opcode) : base($@"An unsupported WebSocket opcode was encountered: {opcode}.") { }
}
public class WsInvalidFrameSizeException : WsException {
public WsInvalidFrameSizeException(long size) : base($@"WebSocket frame size is too large: {size} bytes.") { }
}
public class WsUnexpectedContinueException : WsException {
public WsUnexpectedContinueException() : base(@"A WebSocket continue frame was issued but there is nothing to continue.") { }
}
public class WsUnexpectedDataException : WsException {
public WsUnexpectedDataException() : base(@"A WebSocket data frame was issued while a fragmented frame is being constructed.") { }
}
public class WsInvalidControlFrameException : WsException {
public WsInvalidControlFrameException(string variant) : base($@"An invalid WebSocket control frame was encountered: {variant}") { }
}
public class WsClientMutexFailedException : WsException {
public WsClientMutexFailedException() : base(@"Failed to acquire send mutex.") { }
}
public class WsBufferedSendAlreadyActiveException : WsException {
public WsBufferedSendAlreadyActiveException() : base(@"A buffered websocket send is already in session.") { }
}
public class WsBufferedSendInSessionException : WsException {
public WsBufferedSendInSessionException() : base(@"Cannot send data while a buffered send is in session.") { }
}
}

View file

@ -0,0 +1,5 @@
namespace Hamakaze.WebSocket {
public abstract class WsMessage {
// nothing, lol
}
}

View file

@ -0,0 +1,13 @@
namespace Hamakaze.WebSocket {
public enum WsOpcode : byte {
DataContinue = 0x00,
DataText = 0x01,
DataBinary = 0x02,
CtrlClose = 0x08,
CtrlPing = 0x09,
CtrlPong = 0x0A,
FlagFinal = 0x80,
}
}

View file

@ -0,0 +1,11 @@
using System;
namespace Hamakaze.WebSocket {
public class WsPingMessage : WsMessage, IHasBinaryData {
public byte[] Data { get; }
public WsPingMessage(byte[] data) {
Data = data ?? Array.Empty<byte>();
}
}
}

View file

@ -0,0 +1,11 @@
using System;
namespace Hamakaze.WebSocket {
public class WsPongMessage : WsMessage, IHasBinaryData {
public byte[] Data { get; }
public WsPongMessage(byte[] data) {
Data = data ?? Array.Empty<byte>();
}
}
}

View file

@ -0,0 +1,20 @@
using System.Text;
namespace Hamakaze.WebSocket {
public class WsTextMessage : WsMessage {
public string Text { get; }
public WsTextMessage(byte[] data) {
if(data?.Length > 0)
Text = Encoding.UTF8.GetString(data);
else
Text = string.Empty;
}
public static implicit operator string(WsTextMessage msg) => msg.Text;
public override string ToString() {
return Text;
}
}
}

View file

@ -0,0 +1,38 @@
using System;
namespace Hamakaze.WebSocket {
internal static class WsUtils {
public static byte[] FromU16(ushort num) {
byte[] buff = BitConverter.GetBytes(num);
if(BitConverter.IsLittleEndian)
Array.Reverse(buff);
return buff;
}
public static ushort ToU16(ReadOnlySpan<byte> buffer) {
if(BitConverter.IsLittleEndian)
buffer = new byte[2] {
buffer[1], buffer[0],
};
return BitConverter.ToUInt16(buffer);
}
public static byte[] FromI64(long num) {
byte[] buff = BitConverter.GetBytes(num);
if(BitConverter.IsLittleEndian)
Array.Reverse(buff);
return buff;
}
public static long ToI64(ReadOnlySpan<byte> buffer) {
if(BitConverter.IsLittleEndian)
buffer = new byte[8] {
buffer[7], buffer[6], buffer[5], buffer[4],
buffer[3], buffer[2], buffer[1], buffer[0],
};
return BitConverter.ToInt64(buffer);
}
}
}

View file

@ -5,3 +5,5 @@ Shoddy custom HTTP client, currently targeting C#.
System.Http.HttpClient is annoying cause it forces async on you and they've conveniently deprecated everything else. System.Http.HttpClient is annoying cause it forces async on you and they've conveniently deprecated everything else.
Not really intended for use by anyone other than myself. Not really intended for use by anyone other than myself.
![](logo.png)

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB