commit a2f2f85df819e66696338448c306800a7ae21939 Author: Timur Kozanov Date: Fri Jul 3 05:03:02 2026 +0300 - Initial commit diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a4bc792 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +## 3.0.0 (2023-01-01) + +__Additions and improvements__ + +- New namespace hierarchy, instead of `BestHTTP.WebSocket` the WebSocket class is in the `Best.Websockets` namespace. +- The `OnBinary` event now receives a `BufferSegment` and the memory will be reused after the event. +- The `OnClosed` event now receives an [WebSocketStatusCodes](https://bestdocshub.pages.dev/WebSockets/api-reference/WebSockets/WebSocketStatusCodes.md) enum as its status code. + +__Removals__ + +- Removed the `OnBinaryNoAlloc` event. + +__Fixes__ + +- Fixed confusing naming by renaming `StartPingThread` to `SendPings` +- Fixed confusing behavior because of the two closure events `OnError` and `OnClosed` by mergind the two into one `OnClosed` event. The behavior of `OnClosed` is now matching what browsers have. \ No newline at end of file diff --git a/CHANGELOG.md.meta b/CHANGELOG.md.meta new file mode 100644 index 0000000..bb99bb4 --- /dev/null +++ b/CHANGELOG.md.meta @@ -0,0 +1,14 @@ +fileFormatVersion: 2 +guid: 78a59743cf639004bb78494b2a199075 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 268757 + packageName: Best WebSockets + packageVersion: 3.0.7 + assetPath: Packages/com.tivadar.best.websockets/CHANGELOG.md + uploadId: 737284 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..58d2a04 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1 @@ +The plugin's license falls under the Unity Asset Store EULA that can be found here under Appendix 1: https://unity3d.com/legal/as_terms \ No newline at end of file diff --git a/LICENSE.md.meta b/LICENSE.md.meta new file mode 100644 index 0000000..859428f --- /dev/null +++ b/LICENSE.md.meta @@ -0,0 +1,14 @@ +fileFormatVersion: 2 +guid: 59407affe84cf3e468fa506e7411ea8b +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 268757 + packageName: Best WebSockets + packageVersion: 3.0.7 + assetPath: Packages/com.tivadar.best.websockets/LICENSE.md + uploadId: 737284 diff --git a/README.md b/README.md new file mode 100644 index 0000000..04e57ca --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +Best WebSockets is a premier networking library for Unity, tailored specifically for seamless WebSocket integration. +It's perfect for applications that require real-time, bi-directional communication such as chat applications, multiplayer games, and live interactive systems. + +Warning! **Dependency Alert** + + Best WebSockets relies on the Best HTTP package! + Ensure you have it installed and set up in your Unity project before diving into Best WebSockets. Learn more about the [installation of Best HTTP](https://bestdocshub.pages.dev/HTTP//HTTP/installation.md). + +## Overview +In the fast-paced digital landscape, real-time communication is crucial for a multitude of applications. +Whether it's sending instantaneous game state updates, chat messages, or receiving live feeds, WebSockets provide an edge in facilitating these real-time interactions. +Best WebSockets is crafted to effortlessly integrate this technology into your Unity projects, making bi-directional communication straightforward and efficient. + +## Key Features +- **Supported Unity Versions:** Best WebSockets is compatible with Unity versions starting from :fontawesome-brands-unity: **2021.1 onwards**. +- **Cross-Platform:** Best WebSockets seamlessly operates across a wide variety of Unity platforms, ensuring its applicability for diverse development projects. Specifically, it supports: + + - :fontawesome-solid-desktop: **Desktop:** Windows, Linux, MacOS + - :fontawesome-solid-mobile: **Mobile:** iOS, Android + - :material-microsoft-windows: **Universal Windows Platform (UWP)** + - :material-web: **Web Browsers:** WebGL + + This vast platform compatibility assures that Best WebSockets is an excellent choice for any project, regardless of your target platform or audience. + +- **Persistent Connections:** Unlike traditional request-response communication, WebSockets offer a persistent, low-latency connection that's perfect for applications that need instant communication. +- **Binary and Text Data:** Whether you're sending textual messages or binary data like images and files, Best WebSockets is equipped to handle both with ease. +- **Secure Communication:** With support for WSS:// (WebSocket over TLS), your application's data remains secure and encrypted. +- **Built-In Profiler Support:** To ensure peak performance and help debug potential issues, Best WebSockets integrates with the base [Best HTTP profiler](https://bestdocshub.pages.dev/Shared/profiler/index.md): + - **Memory Profiler:** Examine the library's internal memory usage, helping identify potential bottlenecks or memory leaks. + - **Network Profiler:** Delve deep into your network operations, monitoring data transfers, open and closed connections, and much more. +- **Custom Protocols:** Easily extend and adapt your WebSocket communication with custom protocols, allowing for versatile application-specific communication styles. +- **Compression:** Data compression capabilities ensure efficient bandwidth usage, leading to faster data transfers and reduced latency. + +## Documentation Sections +Delve into the details and start using Best WebSockets in your projects: + +- [Installation Guide:](https://bestdocshub.pages.dev/WebSockets/installation.md) Kick off with Best WebSockets by setting up the package and configuring your Unity project. +- [Upgrade Guide:](https://bestdocshub.pages.dev/WebSockets/upgrade-guide.md) Transitioning from an earlier version? Find out the latest improvements and how to smoothly upgrade to the most recent release. +- [Getting Started:](https://bestdocshub.pages.dev/WebSockets/getting-started/index.md) Embark on your WebSocket journey, understand the basics, and configure Best WebSockets tailored to your application's needs. +- [Advanced Topics:](https://bestdocshub.pages.dev/WebSockets/intermediate-topics/index.md) Enhance your knowledge with deeper insights into WebSocket topics, like custom protocols, security, and more. + +This documentation is designed for developers of all backgrounds and expertise. +Whether you're new to Unity or a seasoned professional, these guides will assist you in maximizing the capabilities of Best WebSockets. + +Dive in now and elevate your Unity projects with superior real-time communication features using Best WebSockets! + +## Installation Guide + +!!! Warning "Dependency Alert" + Before installing Best WebSockets, ensure you have the [Best HTTP package](../HTTP/index.md) installed and set up in your Unity project. If you haven't done so yet, refer to the [Best HTTP Installation Guide](../HTTP/installation.md). + +Getting started with Best WebSockets requires a prior installation of the Best HTTP package. Once Best HTTP is set up, integrating Best WebSockets into your Unity project is a breeze. + +### Installing from the Unity Asset Store using the Package Manager Window + +1. **Purchase:** If you haven't previously purchased the package, proceed to do so. Once purchased, Unity will recognize your purchase, and you can install the package directly from within the Unity Editor. If you already own the package, you can skip these steps. + 1. **Visit the Unity Asset Store:** Navigate to the [Unity Asset Store](https://assetstore.unity.com/publishers/4137?aid=1101lfX8E) using your web browser. + 2. **Search for Best WebSockets:** Locate and choose the official Best WebSockets package. + 3. **Buy Best WebSockets:** By clicking on the `Buy Now` button go though the purchase process. +2. **Open Unity & Access the Package Manager:** Start Unity and select your project. Head to [Window > Package Manager](https://docs.unity3d.com/Manual/upm-ui.html). +3. **Select 'My Assets':** In the Package Manager, switch to the [My Assets](https://docs.unity3d.com/Manual/upm-ui-import.html) tab to view all accessible assets. +4. **Find Best WebSockets and Download:** Scroll to find "Best WebSockets". Click to view its details. If it isn't downloaded, you'll notice a Download button. Click and wait. After downloading, this button will change to Import. +5. **Import the Package:** Once downloaded, click the Import button. Unity will display all Best WebSockets' assets. Ensure all are selected and click Import. +6. **Confirmation:** After the import, Best WebSockets will integrate into your project, signaling a successful installation. + +### Installing from a .unitypackage file + +If you have a .unitypackage file for Best WebSockets, follow these steps: + +1. **Download the .unitypackage:** Make sure the Best WebSockets.unitypackage file is saved on your device. +2. **Import into Unity:** Open Unity and your project. Go to Assets > Import Package > Custom Package. +3. **Locate and Select the .unitypackage:** Find where you saved the Best WebSockets.unitypackage file, select it, and click Open. +4. **Review and Import:** Unity will show a list of all the package's assets. Ensure all assets are selected and click Import. +5. **Confirmation:** Post import, you'll see all the Best WebSockets assets in your project's Asset folder, indicating a successful setup. + +!!! Note + Best WebSockets also supports other installation techniques as documented in Unity's manual for packages. For more advanced installation methods, please see the Unity Manual on [Sharing Packages](https://docs.unity3d.com/Manual/cus-share.html). + +### Assembly Definitions and Runtime References +For developers familiar with Unity's development patterns, it's essential to understand how Best WebSockets incorporates Unity's systems: + +- **Assembly Definition Files:** Best WebSockets incorporates [Unity's Assembly Definition files](https://docs.unity3d.com/Manual/ScriptCompilationAssemblyDefinitionFiles.html). It aids in organizing and managing the codebase efficiently. +- **Auto-Referencing of Runtime DLLs:** The runtime DLLs produced by Best WebSockets are [Auto Referenced](https://docs.unity3d.com/Manual/class-AssemblyDefinitionImporter.html), allowing Unity to automatically recognize and utilize them without manual intervention. +- **Manual Package Referencing:** Should you need to reference Best WebSockets manually in your project (for advanced setups or specific use cases), you can do so. Simply [reference the package](https://docs.unity3d.com/Manual/ScriptCompilationAssemblyDefinitionFiles.html#reference-another-assembly) by searching for `com.Tivadar.Best.WebSockets`. + +Congratulations! You've successfully integrated Best WebSockets into your Unity project. Begin your WebSocket adventure with the [Getting Started guide](getting-started/index.md). + +For any issues or additional assistance, please consult the [Community and Support page](../Shared/support.md). \ No newline at end of file diff --git a/README.md.meta b/README.md.meta new file mode 100644 index 0000000..a59ed15 --- /dev/null +++ b/README.md.meta @@ -0,0 +1,14 @@ +fileFormatVersion: 2 +guid: 25179c491c36d554e89facefd79d266a +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 268757 + packageName: Best WebSockets + packageVersion: 3.0.7 + assetPath: Packages/com.tivadar.best.websockets/README.md + uploadId: 737284 diff --git a/Runtime.meta b/Runtime.meta new file mode 100644 index 0000000..ab31357 --- /dev/null +++ b/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 79e1b9f2f75dbae4bb800e8b2bf4db50 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/AssemblyInfo.cs b/Runtime/AssemblyInfo.cs new file mode 100644 index 0000000..eaa3486 --- /dev/null +++ b/Runtime/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Reflection; + +[assembly: AssemblyVersion("3.0.7")] +[assembly: AssemblyCompany("Tivadar György Nagy")] +[assembly: AssemblyCopyright("Copyright © 2024 Tivadar György Nagy")] +[assembly: AssemblyDescription("Best WebSockets is a premier networking library for Unity, tailored specifically for seamless WebSocket integration. It's perfect for applications that require real-time, bi-directional communication such as chat applications, multiplayer games, and live interactive systems.")] + +#if WITH_BURST +[assembly: Unity.Burst.BurstCompile(CompileSynchronously = true, OptimizeFor = Unity.Burst.OptimizeFor.Performance)] +#endif diff --git a/Runtime/AssemblyInfo.cs.meta b/Runtime/AssemblyInfo.cs.meta new file mode 100644 index 0000000..16d0044 --- /dev/null +++ b/Runtime/AssemblyInfo.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 0088801e96f54ee4c947f9d8ad1ff8f6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 268757 + packageName: Best WebSockets + packageVersion: 3.0.7 + assetPath: Packages/com.tivadar.best.websockets/Runtime/AssemblyInfo.cs + uploadId: 737284 diff --git a/Runtime/Extensions.meta b/Runtime/Extensions.meta new file mode 100644 index 0000000..037c7fe --- /dev/null +++ b/Runtime/Extensions.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 34bb8567fd5feb142affea2532722c3e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Extensions/IExtension.cs b/Runtime/Extensions/IExtension.cs new file mode 100644 index 0000000..50b19f6 --- /dev/null +++ b/Runtime/Extensions/IExtension.cs @@ -0,0 +1,48 @@ +#if !UNITY_WEBGL || UNITY_EDITOR + +using System; + +using Best.HTTP; +using Best.HTTP.Shared.PlatformSupport.Memory; +using Best.WebSockets.Implementations.Frames; + +namespace Best.WebSockets.Extensions +{ + /// + /// Interface for websocket-extension implementations. + /// + public interface IExtension : IDisposable + { + /// + /// This is the first pass: here we can add headers to the request to initiate an extension negotiation. + /// + /// + void AddNegotiation(HTTPRequest request); + + /// + /// If the websocket upgrade succeded it will call this function to be able to parse the server's negotiation + /// response. Inside this function the IsEnabled should be set. + /// + bool ParseNegotiation(HTTPResponse resp); + + /// + /// This function should return a new header flag based on the inFlag parameter. The extension should set only the + /// Rsv1-3 bits in the header. + /// + byte GetFrameHeader(WebSocketFrame writer, byte inFlag); + + /// + /// This function will be called to be able to transform the data that will be sent to the server. + /// + /// + /// + BufferSegment Encode(WebSocketFrame writer); + + /// + /// This function can be used the decode the server-sent data. + /// + BufferSegment Decode(byte header, BufferSegment data); + } +} + +#endif diff --git a/Runtime/Extensions/IExtension.cs.meta b/Runtime/Extensions/IExtension.cs.meta new file mode 100644 index 0000000..1150ce8 --- /dev/null +++ b/Runtime/Extensions/IExtension.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 9b8334cd17e5fcb49a30d5cec459d376 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 268757 + packageName: Best WebSockets + packageVersion: 3.0.7 + assetPath: Packages/com.tivadar.best.websockets/Runtime/Extensions/IExtension.cs + uploadId: 737284 diff --git a/Runtime/Extensions/PerMessageCompression.cs b/Runtime/Extensions/PerMessageCompression.cs new file mode 100644 index 0000000..3a7de83 --- /dev/null +++ b/Runtime/Extensions/PerMessageCompression.cs @@ -0,0 +1,368 @@ +#if !UNITY_WEBGL || UNITY_EDITOR + +using System; + +using Best.HTTP.Shared; +using Best.HTTP.Shared.Compression.Zlib; +using Best.HTTP.Shared.Extensions; +using Best.HTTP.Shared.PlatformSupport.Memory; +using Best.HTTP.Shared.Streams; +using Best.WebSockets.Implementations.Frames; + +namespace Best.WebSockets.Extensions +{ + /// + /// Compression Extensions for WebSocket implementation. + /// http://tools.ietf.org/html/rfc7692 + /// + public sealed class PerMessageCompression : IExtension + { + public const int MinDataLengthToCompressDefault = 256; + + private static readonly byte[] Trailer = new byte[] { 0x00, 0x00, 0xFF, 0xFF }; + + #region Public Properties + + /// + /// By including this extension parameter in an extension negotiation offer, a client informs the peer server + /// of a hint that even if the server doesn't include the "client_no_context_takeover" extension parameter in + /// the corresponding extension negotiation response to the offer, the client is not going to use context takeover. + /// + public bool ClientNoContextTakeover { get; private set; } + + /// + /// By including this extension parameter in an extension negotiation offer, a client prevents the peer server from using context takeover. + /// + public bool ServerNoContextTakeover { get; private set; } + + /// + /// This parameter indicates the base-2 logarithm of the LZ77 sliding window size of the client context. + /// + public int ClientMaxWindowBits { get; private set; } + + /// + /// This parameter indicates the base-2 logarithm of the LZ77 sliding window size of the server context. + /// + public int ServerMaxWindowBits { get; private set; } + + /// + /// The compression level that the client will use to compress the frames. + /// + public CompressionLevel Level { get; private set; } + + /// + /// What minimum data length will trigger the compression. + /// + public int MinimumDataLegthToCompress { get; set; } + + #endregion + + #region Private fields + + /// + /// Cached object to support context takeover. + /// + private BufferPoolMemoryStream compressorOutputStream; + private DeflateStream compressorDeflateStream; + + /// + /// Cached object to support context takeover. + /// + private BufferPoolMemoryStream decompressorInputStream; + private BufferPoolMemoryStream decompressorOutputStream; + private DeflateStream decompressorDeflateStream; + + #endregion + + public PerMessageCompression() + :this(CompressionLevel.Default, false, false, ZlibConstants.WindowBitsMax, ZlibConstants.WindowBitsMax, MinDataLengthToCompressDefault) + { } + + public PerMessageCompression(CompressionLevel level, + bool clientNoContextTakeover, + bool serverNoContextTakeover, + int desiredClientMaxWindowBits, + int desiredServerMaxWindowBits, + int minDatalengthToCompress) + { + this.Level = level; + this.ClientNoContextTakeover = clientNoContextTakeover; + this.ServerNoContextTakeover = serverNoContextTakeover; + this.ClientMaxWindowBits = desiredClientMaxWindowBits; + this.ServerMaxWindowBits = desiredServerMaxWindowBits; + this.MinimumDataLegthToCompress = minDatalengthToCompress; + } + + #region IExtension Implementation + + /// + /// This will start the permessage-deflate negotiation process. + /// + /// + public void AddNegotiation(HTTP.HTTPRequest request) + { + // The default header value that we will send out minimum. + string headerValue = "permessage-deflate"; + + + // http://tools.ietf.org/html/rfc7692#section-7.1.1.1 + // A client MAY include the "server_no_context_takeover" extension parameter in an extension negotiation offer. This extension parameter has no value. + // By including this extension parameter in an extension negotiation offer, a client prevents the peer server from using context takeover. + // If the peer server doesn't use context takeover, the client doesn't need to reserve memory to retain the LZ77 sliding window between messages. + if (this.ServerNoContextTakeover) + headerValue += "; server_no_context_takeover"; + + + // http://tools.ietf.org/html/rfc7692#section-7.1.1.2 + // A client MAY include the "client_no_context_takeover" extension parameter in an extension negotiation offer. + // This extension parameter has no value. By including this extension parameter in an extension negotiation offer, + // a client informs the peer server of a hint that even if the server doesn't include the "client_no_context_takeover" + // extension parameter in the corresponding extension negotiation response to the offer, the client is not going to use context takeover. + if (this.ClientNoContextTakeover) + headerValue += "; client_no_context_takeover"; + + // http://tools.ietf.org/html/rfc7692#section-7.1.2.1 + // By including this parameter in an extension negotiation offer, a client limits the LZ77 sliding window size that the server + // will use to compress messages.If the peer server uses a small LZ77 sliding window to compress messages, the client can reduce the memory needed for the LZ77 sliding window. + if (this.ServerMaxWindowBits != ZlibConstants.WindowBitsMax) + headerValue += "; server_max_window_bits=" + this.ServerMaxWindowBits.ToString(); + else + // Absence of this parameter in an extension negotiation offer indicates that the client can receive messages compressed using an LZ77 sliding window of up to 32,768 bytes. + this.ServerMaxWindowBits = ZlibConstants.WindowBitsMax; + + // http://tools.ietf.org/html/rfc7692#section-7.1.2.2 + // By including this parameter in an offer, a client informs the peer server that the client supports the "client_max_window_bits" + // extension parameter in an extension negotiation response and, optionally, a hint by attaching a value to the parameter. + if (this.ClientMaxWindowBits != ZlibConstants.WindowBitsMax) + headerValue += "; client_max_window_bits=" + this.ClientMaxWindowBits.ToString(); + else + { + headerValue += "; client_max_window_bits"; + + // If the "client_max_window_bits" extension parameter in an extension negotiation offer has a value, the parameter also informs the + // peer server of a hint that even if the server doesn't include the "client_max_window_bits" extension parameter in the corresponding + // extension negotiation response with a value greater than the one in the extension negotiation offer or if the server doesn't include + // the extension parameter at all, the client is not going to use an LZ77 sliding window size greater than the size specified + // by the value in the extension negotiation offer to compress messages. + this.ClientMaxWindowBits = ZlibConstants.WindowBitsMax; + } + + // Add the new header to the request. + request.AddHeader("Sec-WebSocket-Extensions", headerValue); + } + + public bool ParseNegotiation(HTTP.HTTPResponse resp) + { + // Search for any returned neogitation offer + var headerValues = resp.GetHeaderValues("Sec-WebSocket-Extensions"); + if (headerValues == null) + return false; + + for (int i = 0; i < headerValues.Count; ++i) + { + // If found, tokenize it + HeaderParser parser = new HeaderParser(headerValues[i]); + + for (int cv = 0; cv < parser.Values.Count; ++cv) + { + HeaderValue value = parser.Values[i]; + + if (!string.IsNullOrEmpty(value.Key) && value.Key.StartsWith("permessage-deflate", StringComparison.OrdinalIgnoreCase)) + { + HTTPManager.Logger.Information("PerMessageCompression", "Enabled with header: " + headerValues[i]); + + HeaderValue option; + if (value.TryGetOption("client_no_context_takeover", out option)) + this.ClientNoContextTakeover = true; + + if (value.TryGetOption("server_no_context_takeover", out option)) + this.ServerNoContextTakeover = true; + + if (value.TryGetOption("client_max_window_bits", out option)) + if (option.HasValue) + { + int windowBits; + if (int.TryParse(option.Value, out windowBits)) + this.ClientMaxWindowBits = windowBits; + } + + if (value.TryGetOption("server_max_window_bits", out option)) + if (option.HasValue) + { + int windowBits; + if (int.TryParse(option.Value, out windowBits)) + this.ServerMaxWindowBits = windowBits; + } + + return true; + } + } + } + + return false; + } + + /// + /// IExtension implementation to set the Rsv1 flag in the header if we are we will want to compress the data + /// in the writer. + /// + public byte GetFrameHeader(WebSocketFrame writer, byte inFlag) + { + // http://tools.ietf.org/html/rfc7692#section-7.2.3.1 + // the RSV1 bit is set only on the first frame. + if ((writer.Type == WebSocketFrameTypes.Binary || writer.Type == WebSocketFrameTypes.Text) && + writer.Data != null && writer.Data.Count >= this.MinimumDataLegthToCompress) + return (byte)(inFlag | 0x40); + else + return inFlag; + } + + /// + /// IExtension implementation to be able to compress the data hold in the writer. + /// + public BufferSegment Encode(WebSocketFrame writer) + { + if (writer.Data == null) + return BufferSegment.Empty; + + // Is compressing enabled for this frame? If so, compress it. + if ((writer.Header & 0x40) != 0) + return Compress(writer.Data); + else + return writer.Data; + } + + /// + /// IExtension implementation to possible decompress the data. + /// + public BufferSegment Decode(byte header, BufferSegment data) + { + // Is the server compressed the data? If so, decompress it. + if ((header & 0x40) != 0) + return Decompress(data); + else + return data; + } + + #endregion + + #region Private Helper Functions + + /// + /// A function to compress and return the data parameter with possible context takeover support (reusing the DeflateStream). + /// + private BufferSegment Compress(BufferSegment data) + { + CheckDisposed(); + + if (compressorOutputStream == null) + compressorOutputStream = new BufferPoolMemoryStream(); + compressorOutputStream.SetLength(0); + + if (compressorDeflateStream == null) + { + compressorDeflateStream = new DeflateStream(compressorOutputStream, CompressionMode.Compress, this.Level, false, this.ClientMaxWindowBits); + compressorDeflateStream.FlushMode = FlushType.Sync; + } + + BufferSegment result = BufferSegment.Empty; + try + { + compressorDeflateStream.Write(data.Data, data.Offset, data.Count); + compressorDeflateStream.Flush(); + + compressorOutputStream.Position = 0; + + // http://tools.ietf.org/html/rfc7692#section-7.2.1 + // Remove 4 octets (that are 0x00 0x00 0xff 0xff) from the tail end. + // After this step, the last octet of the compressed data contains (possibly part of) + // the DEFLATE header bits with the "BTYPE" bits set to 00. + compressorOutputStream.SetLength(compressorOutputStream.Length - 4); + + result = compressorOutputStream.ToBufferSegment(); + } + finally + { + if (this.ClientNoContextTakeover) + { + compressorDeflateStream.Dispose(); + compressorDeflateStream = null; + compressorOutputStream = null; + } + } + + return result; + } + + /// + /// A function to decompress and return the data parameter with possible context takeover support (reusing the DeflateStream). + /// + private BufferSegment Decompress(BufferSegment data) + { + CheckDisposed(); + + if (decompressorInputStream == null) + decompressorInputStream = new BufferPoolMemoryStream(data.Count + 4); + + decompressorInputStream.Write(data.Data, data.Offset, data.Count); + + // http://tools.ietf.org/html/rfc7692#section-7.2.2 + // Append 4 octets of 0x00 0x00 0xff 0xff to the tail end of the payload of the message. + decompressorInputStream.Write(PerMessageCompression.Trailer, 0, PerMessageCompression.Trailer.Length); + + decompressorInputStream.Position = 0; + + if (decompressorDeflateStream == null) + { + decompressorDeflateStream = new DeflateStream(decompressorInputStream, CompressionMode.Decompress, CompressionLevel.Default, false, this.ServerMaxWindowBits); + decompressorDeflateStream.FlushMode = FlushType.Sync; + } + + if (decompressorOutputStream == null) + decompressorOutputStream = new BufferPoolMemoryStream(); + decompressorOutputStream.SetLength(0); + + byte[] copyBuffer = BufferPool.Get(1024, true); + int readCount; + while ((readCount = decompressorDeflateStream.Read(copyBuffer, 0, copyBuffer.Length)) != 0) + decompressorOutputStream.Write(copyBuffer, 0, readCount); + + BufferPool.Release(copyBuffer); + + decompressorDeflateStream.SetLength(0); + + var result = decompressorOutputStream.ToBufferSegment(); + + if (this.ServerNoContextTakeover) + { + decompressorDeflateStream.Dispose(); + decompressorDeflateStream = null; + decompressorInputStream = null; + } + + return result; + } + + #endregion + + bool _disposed; + void CheckDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(PerMessageCompression)); + } + void IDisposable.Dispose() + { + _disposed = true; + + compressorDeflateStream?.Dispose(); + compressorOutputStream?.Dispose(); + + decompressorDeflateStream?.Dispose(); + decompressorInputStream?.Dispose(); + decompressorOutputStream?.Dispose(); + } + } +} + +#endif diff --git a/Runtime/Extensions/PerMessageCompression.cs.meta b/Runtime/Extensions/PerMessageCompression.cs.meta new file mode 100644 index 0000000..8b1d62a --- /dev/null +++ b/Runtime/Extensions/PerMessageCompression.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: d3455ed9a28ed30498a715264c917d68 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 268757 + packageName: Best WebSockets + packageVersion: 3.0.7 + assetPath: Packages/com.tivadar.best.websockets/Runtime/Extensions/PerMessageCompression.cs + uploadId: 737284 diff --git a/Runtime/Implementations.meta b/Runtime/Implementations.meta new file mode 100644 index 0000000..4a91372 --- /dev/null +++ b/Runtime/Implementations.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5e315fcb38c58764097b10921fc465a9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Implementations/Frames.meta b/Runtime/Implementations/Frames.meta new file mode 100644 index 0000000..b1c690c --- /dev/null +++ b/Runtime/Implementations/Frames.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 66d7911cf4ea4b442a861292e904cc95 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Implementations/Frames/WebSocketFrame.cs b/Runtime/Implementations/Frames/WebSocketFrame.cs new file mode 100644 index 0000000..1544466 --- /dev/null +++ b/Runtime/Implementations/Frames/WebSocketFrame.cs @@ -0,0 +1,348 @@ +#if !UNITY_WEBGL || UNITY_EDITOR + +using System; +using System.Runtime.CompilerServices; + +using Best.HTTP.Shared; +using Best.HTTP.Shared.Extensions; +using Best.HTTP.Shared.Logger; +using Best.HTTP.Shared.PlatformSupport.IL2CPP; +using Best.HTTP.Shared.PlatformSupport.Memory; + +#if WITH_BURST +using Unity.Burst; +using Unity.Burst.Intrinsics; +using static Unity.Burst.Intrinsics.X86.Avx2; +using static Unity.Burst.Intrinsics.X86.Sse2; +using static Unity.Burst.Intrinsics.Arm.Neon; +#endif + +namespace Best.WebSockets.Implementations.Frames +{ + /// + /// Denotes a binary frame. The "Payload data" is arbitrary binary data whose interpretation is solely up to the application layer. + /// This is the base class of all other frame writers, as all frame can be represented as a byte array. + /// +#if WITH_BURST + [BurstCompile] +#endif + [Il2CppEagerStaticClassConstruction] + public struct WebSocketFrame + { + public WebSocketFrameTypes Type { get; private set; } + + public BufferSegment Data { get; private set; } + + public WebSocket Websocket { get; private set; } + + public byte Header; + + public WebSocketFrame(WebSocket webSocket, WebSocketFrameTypes type, BufferSegment data) + :this(webSocket, type, data, copyData: true) + { + + } + + public WebSocketFrame(WebSocket webSocket, WebSocketFrameTypes type, BufferSegment data, bool copyData) + { + this.Type = type; + this.Websocket = webSocket; + + this.Data = data; + + if (this.Data.Data != null) + { + if (copyData) + { + var from = this.Data; + + var buffer = BufferPool.Get(this.Data.Count, true); + this.Data = new BufferSegment(buffer, 0, this.Data.Count); + + Array.Copy(from.Data, (int)from.Offset, this.Data.Data, this.Data.Offset, this.Data.Count); + } + } + else + this.Data = BufferSegment.Empty; + + // We use the header only for storing extension flags only + this.Header = 0x00; + } + + public override string ToString() + { + return $"[WebSocketFrame Type: {this.Type}, Header: {this.Header:X2}, Data: {this.Data}]"; + } + + public void WriteTo(Action callback, uint maxFragmentSize, bool mask, LoggingContext context) + { + if (HTTPManager.Logger.IsDiagnostic) + HTTPManager.Logger.Verbose("WebSocketFrame", "WriteTo - Frame: " + ToString(), context); + + if ((this.Type == WebSocketFrameTypes.Binary || this.Type == WebSocketFrameTypes.Text)) + { + DoExtensions(); + if (this.Data.Count > maxFragmentSize) + FragmentAndSend(callback, maxFragmentSize, mask, context); + else + WriteFragment(callback, (byte)(0x80 | this.Header | (byte)this.Type), this.Data, mask, context); + } + else + { + WriteFragment(callback, (byte)(0x80 | this.Header | (byte)this.Type), this.Data, mask, context); + } + } + + private void DoExtensions() + { + if (this.Websocket != null && this.Websocket.Extensions != null) + { + for (int i = 0; i < this.Websocket.Extensions.Length; ++i) + { + var ext = this.Websocket.Extensions[i]; + if (ext != null) + { + this.Header |= ext.GetFrameHeader(this, this.Header); + BufferSegment newData = ext.Encode(this); + + if (newData != this.Data) + { + BufferPool.Release(this.Data); + + this.Data = newData; + } + } + } + } + } + + private void FragmentAndSend(Action callback, uint maxFragmentSize, bool mask, LoggingContext context) + { + int pos = this.Data.Offset; + int endPos = this.Data.Offset + this.Data.Count; + + byte header = (byte)(0x00 | this.Header | (byte)this.Type); + + while (pos < endPos) + { + int chunkLength = Math.Min((int)maxFragmentSize, endPos - pos); + + WriteFragment(callback: callback, + Header: header, + Data: this.Data.Slice((int)pos, (int)chunkLength), + mask: mask, + context: context); + + pos += chunkLength; + + // set only the IsFinal flag, every other flags are zero + header = (byte)(pos + chunkLength >= this.Data.Count ? 0x80 : 0x00); + } + } + + private static unsafe void WriteFragment(Action callback, byte Header, BufferSegment Data, bool mask, LoggingContext context) + { + // For the complete documentation for this section see: + // http://tools.ietf.org/html/rfc6455#section-5.2 + + // Header(1) + Len(8) + Mask (4) + byte[] wsHeader = BufferPool.Get(13, true); + int pos = 0; + + // Write the header + wsHeader[pos++] = Header; + + // The length of the "Payload data", in bytes: if 0-125, that is the payload length. If 126, the following 2 bytes interpreted as a + // 16-bit unsigned integer are the payload length. If 127, the following 8 bytes interpreted as a 64-bit unsigned integer (the + // most significant bit MUST be 0) are the payload length. Multibyte length quantities are expressed in network byte order. + if (Data.Count < 126) + { + wsHeader[pos++] = (byte)(0x80 | (byte)Data.Count); + } + else if (Data.Count < UInt16.MaxValue) + { + wsHeader[pos++] = (byte)(0x80 | 126); + var count = (UInt16)Data.Count; + wsHeader[pos++] = (byte)(count >> 8); + wsHeader[pos++] = (byte)(count); + } + else + { + wsHeader[pos++] = (byte)(0x80 | 127); + + var count = (UInt64)Data.Count; + wsHeader[pos++] = (byte)(count >> 56); + wsHeader[pos++] = (byte)(count >> 48); + wsHeader[pos++] = (byte)(count >> 40); + wsHeader[pos++] = (byte)(count >> 32); + wsHeader[pos++] = (byte)(count >> 24); + wsHeader[pos++] = (byte)(count >> 16); + wsHeader[pos++] = (byte)(count >> 8); + wsHeader[pos++] = (byte)(count); + } + + if (Data != BufferSegment.Empty) + { + // All frames sent from the client to the server are masked by a 32-bit value that is contained within the frame. This field is + // present if the mask bit is set to 1 and is absent if the mask bit is set to 0. + // If the data is being sent by the client, the frame(s) MUST be masked. + + uint hash = mask ? (uint)wsHeader.GetHashCode() : 0; + + wsHeader[pos++] = (byte)(hash >> 24); + wsHeader[pos++] = (byte)(hash >> 16); + wsHeader[pos++] = (byte)(hash >> 8); + wsHeader[pos++] = (byte)(hash); + + // Do the masking. + if (mask) + { + fixed (byte* pData = Data.Data/*, pmask = &wsHeader[pos - 4]*/) + { + byte* alignedMask = stackalloc byte[4]; + alignedMask[0] = wsHeader[pos - 4]; + alignedMask[1] = wsHeader[pos - 3]; + alignedMask[2] = wsHeader[pos - 2]; + alignedMask[3] = wsHeader[pos - 1]; + + ApplyMask(pData, Data.Offset, Data.Count, alignedMask); + } + } + } + else + { + wsHeader[pos++] = 0; + wsHeader[pos++] = 0; + wsHeader[pos++] = 0; + wsHeader[pos++] = 0; + } + + var header = wsHeader.AsBuffer(pos); + + if (HTTPManager.Logger.IsDiagnostic) + HTTPManager.Logger.Verbose("WebSocketFrame", string.Format("WriteFragment - Header: {0}, data chunk: {1}", header.ToString(), Data.ToString()), context); + + callback(header, Data); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] +#if WITH_BURST + [BurstCompile(CompileSynchronously = true)] +#endif + public unsafe static void ApplyMask( +#if WITH_BURST + [NoAlias] +#endif + byte* pData, + int DataOffset, + int DataCount, +#if WITH_BURST + [NoAlias] +#endif + byte* pmask + ) + { + int targetOffset = DataOffset + DataCount; + uint umask = *(uint*)pmask; + +#if WITH_BURST + if (targetOffset - DataOffset >= 32) + { + if (IsAvx2Supported) + { + v256 mask = new v256(umask); + v256 ldstrMask = new v256((byte)0xFF); + + while (targetOffset - DataOffset >= 32) + { + // load data + v256 data = mm256_maskload_epi32(pData + DataOffset, ldstrMask); + + // xor + v256 result = mm256_xor_si256(data, mask); + + // store + mm256_maskstore_epi32(pData + DataOffset, ldstrMask, result); + + // advance + DataOffset += 32; + } + } + } + + if (targetOffset - DataOffset >= 16) + { + v128 mask = new v128(umask); + +#if !UNITY_ANDROID && !UNITY_IOS + if (IsSse2Supported) + { + while (targetOffset - DataOffset >= 16) + { + // load data + v128 data = loadu_si128(pData + DataOffset); + + // xor + var result = xor_si128(data, mask); + + // store + storeu_si128(pData + DataOffset, result); + + // advance + DataOffset += 16; + } + } + else +#endif + if (IsNeonSupported) + { + while (targetOffset - DataOffset >= 16) + { + // load data + v128 data = vld1q_u8(pData + DataOffset); + + // xor + v128 result = veorq_u8(data, mask); + + // store + vst1q_u8(pData + DataOffset, result); + + // advance + DataOffset += 16; + } + } + } +#endif + + // fallback to calculate by reinterpret-casting to ulong + if (targetOffset - DataOffset >= 8) + { + ulong* ulpData = (ulong*)(pData + DataOffset); + +#if UNITY_ANDROID && !UNITY_EDITOR + if ((long)ulpData % sizeof(ulong) == 0) + { +#endif + // duplicate the mask to fill up a whole ulong. + ulong ulmask = (((ulong)umask << 32) | umask); + + while (targetOffset - DataOffset >= 8) + { + *ulpData = *ulpData ^ ulmask; + + ulpData++; + DataOffset += 8; + } +#if UNITY_ANDROID && !UNITY_EDITOR + } +#endif + } + + // process remaining bytes (0..7) + for (int i = DataOffset; i < targetOffset; ++i) + pData[i] = (byte)(pData[i] ^ pmask[(i - DataOffset) % 4]); + } + } +} + +#endif diff --git a/Runtime/Implementations/Frames/WebSocketFrame.cs.meta b/Runtime/Implementations/Frames/WebSocketFrame.cs.meta new file mode 100644 index 0000000..3f820c7 --- /dev/null +++ b/Runtime/Implementations/Frames/WebSocketFrame.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 326466ccfd440a94693ff2379b4d5461 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 268757 + packageName: Best WebSockets + packageVersion: 3.0.7 + assetPath: Packages/com.tivadar.best.websockets/Runtime/Implementations/Frames/WebSocketFrame.cs + uploadId: 737284 diff --git a/Runtime/Implementations/Frames/WebSocketFrameReader.cs b/Runtime/Implementations/Frames/WebSocketFrameReader.cs new file mode 100644 index 0000000..4aab27d --- /dev/null +++ b/Runtime/Implementations/Frames/WebSocketFrameReader.cs @@ -0,0 +1,253 @@ +#if !UNITY_WEBGL || UNITY_EDITOR + +using System; +using System.Collections.Generic; +using System.IO; + +using Best.HTTP.Shared; +using Best.HTTP.Shared.Extensions; +using Best.HTTP.Shared.PlatformSupport.Memory; +using Best.HTTP.Shared.Streams; + +namespace Best.WebSockets.Implementations.Frames +{ + /// + /// Represents an incoming WebSocket Frame. + /// + public struct WebSocketFrameReader + { +#region Properties + + public byte Header { get; private set; } + + /// + /// True if it's a final Frame in a sequence, or the only one. + /// + public bool IsFinal { get; private set; } + + /// + /// The type of the Frame. + /// + public WebSocketFrameTypes Type { get; private set; } + + /// + /// The decoded array of bytes. + /// + public BufferSegment Data { get; private set; } + + /// + /// Textual representation of the received Data. + /// + public string DataAsText { get; private set; } + + #endregion + + #region Internal & Private Functions + + private static bool HasEnoughBytesForAFrame(PeekableIncomingSegmentStream peekable) + { + // https://www.rfc-editor.org/rfc/rfc6455#section-5.2 + // Minimum frame length is 2 bytes: header + zero length + if (peekable.Length < 2) + return false; + + peekable.BeginPeek(); + + // header + peekable.PeekByte(); + + var maskAndLength = peekable.PeekByte(); + + var length = (ulong)(maskAndLength & 127); + if (length < 126) + { + return (ulong)peekable.Length >= (2 + length); + } + else if (length == 126) + { + return (ulong)peekable.Length >= (3 + length); + } + else if (length == 127) + { + return (ulong)peekable.Length >= (10 + length); + } + + return true; + } + + internal unsafe void Read(Stream stream) + { + // For the complete documentation for this section see: + // http://tools.ietf.org/html/rfc6455#section-5.2 + + this.Header = ReadByte(stream); + + // The first byte is the Final Bit and the type of the frame + IsFinal = (this.Header & 0x80) != 0; + Type = (WebSocketFrameTypes)(this.Header & 0xF); + + byte maskAndLength = ReadByte(stream); + + // The second byte is the Mask Bit and the length of the payload data + if ((maskAndLength & 0x80) != 0) + throw new NotImplementedException($"Payload from the server is masked!"); + + // if 0-125, that is the payload length. + var length = (UInt64)(maskAndLength & 127); + + // If 126, the following 2 bytes interpreted as a 16-bit unsigned integer are the payload length. + if (length == 126) + { + byte[] rawLen = BufferPool.Get(2, true); + + stream.ReadBuffer(rawLen, 2); + + if (BitConverter.IsLittleEndian) + Array.Reverse(rawLen, 0, 2); + + length = (UInt64)BitConverter.ToUInt16(rawLen, 0); + + BufferPool.Release(rawLen); + } + else if (length == 127) + { + // If 127, the following 8 bytes interpreted as a 64-bit unsigned integer (the + // most significant bit MUST be 0) are the payload length. + + byte[] rawLen = BufferPool.Get(8, true); + + stream.ReadBuffer(rawLen, 8); + + if (BitConverter.IsLittleEndian) + Array.Reverse(rawLen, 0, 8); + + length = (UInt64)BitConverter.ToUInt64(rawLen, 0); + + BufferPool.Release(rawLen); + } + + if (length == 0L) + { + Data = BufferSegment.Empty; + return; + } + + var buffer = BufferPool.Get((long)length, true); + + uint readLength = 0; + + try + { + do + { + int read = stream.Read(buffer, (int)readLength, (int)(length - readLength)); + + if (read <= 0) + throw ExceptionHelper.ServerClosedTCPStream(); + + readLength += (uint)read; + } while (readLength < length); + } + catch + { + BufferPool.Release(buffer); + throw; + } + this.Data = new BufferSegment(buffer, 0, (int)length); + } + + private static byte ReadByte(Stream stream) + { + int read = stream.ReadByte(); + + if (read < 0) + throw ExceptionHelper.ServerClosedTCPStream(); + + return (byte)read; + } + +#endregion + +#region Public Functions + + /// + /// Assembles all fragments into a final frame. Call this on the last fragment of a frame. + /// + /// The list of previously downloaded and parsed fragments of the frame + public void Assemble(List fragments) + { + // this way the following algorithms will handle this fragment's data too + fragments.Add(this); + + UInt64 finalLength = 0; + for (int i = 0; i < fragments.Count; ++i) + finalLength += (UInt64)fragments[i].Data.Count; + + byte[] buffer = BufferPool.Get((long)finalLength, true); + UInt64 pos = 0; + for (int i = 0; i < fragments.Count; ++i) + { + if (fragments[i].Data.Count > 0) + Array.Copy(fragments[i].Data.Data, fragments[i].Data.Offset, buffer, (int)pos, (int)fragments[i].Data.Count); + fragments[i].ReleaseData(); + + pos += (UInt64)fragments[i].Data.Count; + } + + // All fragments of a message are of the same type, as set by the first fragment's opcode. + this.Type = fragments[0].Type; + + // Reserver flags may be contained only in the first fragment + + this.Header = fragments[0].Header; + this.Data = new BufferSegment(buffer, 0, (int)finalLength); + } + + /// + /// This function will decode the received data incrementally with the associated websocket's extensions. + /// + public void DecodeWithExtensions(WebSocket webSocket) + { + if (webSocket.Extensions != null) + for (int i = 0; i < webSocket.Extensions.Length; ++i) + { + var ext = webSocket.Extensions[i]; + if (ext != null) + { + var newData = ext.Decode(this.Header, this.Data); + if (this.Data != newData) + { + this.ReleaseData(); + this.Data = newData; + } + } + } + + if (this.Type == WebSocketFrameTypes.Text) + { + if (this.Data != BufferSegment.Empty) + { + this.DataAsText = System.Text.Encoding.UTF8.GetString(this.Data.Data, this.Data.Offset, this.Data.Count); + this.ReleaseData(); + } + else + HTTPManager.Logger.Warning("WebSocketFrameReader", "Empty Text frame received!"); + } + } + + public void ReleaseData() + { + BufferPool.Release(this.Data); + this.Data = BufferSegment.Empty; + } + + public override string ToString() + { + return string.Format("[{0} Header: {1:X2}, IsFinal: {2}, Data: {3}]", this.Type.ToString(), this.Header, this.IsFinal, this.Data); + } + +#endregion + } +} + +#endif diff --git a/Runtime/Implementations/Frames/WebSocketFrameReader.cs.meta b/Runtime/Implementations/Frames/WebSocketFrameReader.cs.meta new file mode 100644 index 0000000..eb20f53 --- /dev/null +++ b/Runtime/Implementations/Frames/WebSocketFrameReader.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: bea7b0019ac0e5b4c94ac0149e455e2d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 268757 + packageName: Best WebSockets + packageVersion: 3.0.7 + assetPath: Packages/com.tivadar.best.websockets/Runtime/Implementations/Frames/WebSocketFrameReader.cs + uploadId: 737284 diff --git a/Runtime/Implementations/Frames/WebSocketFrameTypes.cs b/Runtime/Implementations/Frames/WebSocketFrameTypes.cs new file mode 100644 index 0000000..0d879e7 --- /dev/null +++ b/Runtime/Implementations/Frames/WebSocketFrameTypes.cs @@ -0,0 +1,51 @@ +#if !UNITY_WEBGL || UNITY_EDITOR + +namespace Best.WebSockets.Implementations.Frames +{ + /// + /// Enumeration for possible WebSocket frame types. + /// + public enum WebSocketFrameTypes : byte + { + /// + /// A fragmented message's first frame's contain the type of the message(binary or text), all consecutive frame of that message must be a Continuation frame. + /// Last of these frame's Fin bit must be 1. + /// + /// For a text message sent as three fragments, the first fragment would have an opcode of 0x1 (text) and a FIN bit clear, + /// the second fragment would have an opcode of 0x0 (Continuation) and a FIN bit clear, + /// and the third fragment would have an opcode of 0x0 (Continuation) and a FIN bit that is set. + Continuation = 0x0, + Text = 0x1, + Binary = 0x2, + //Reserved1 = 0x3, + //Reserved2 = 0x4, + //Reserved3 = 0x5, + //Reserved4 = 0x6, + //Reserved5 = 0x7, + + /// + /// The Close frame MAY contain a body (the "Application data" portion of the frame) that indicates a reason for closing, + /// such as an endpoint shutting down, an endpoint having received a frame too large, or an endpoint having received a frame that + /// does not conform to the format expected by the endpoint. + /// As the data is not guaranteed to be human readable, clients MUST NOT show it to end users. + /// + ConnectionClose = 0x8, + + /// + /// The Ping frame contains an opcode of 0x9. A Ping frame MAY include "Application data". + /// + Ping = 0x9, + + /// + /// A Pong frame sent in response to a Ping frame must have identical "Application data" as found in the message body of the Ping frame being replied to. + /// + Pong = 0xA, + //Reserved6 = 0xB, + //Reserved7 = 0xC, + //Reserved8 = 0xD, + //Reserved9 = 0xE, + //Reserved10 = 0xF, + } +} + +#endif diff --git a/Runtime/Implementations/Frames/WebSocketFrameTypes.cs.meta b/Runtime/Implementations/Frames/WebSocketFrameTypes.cs.meta new file mode 100644 index 0000000..dd31f2e --- /dev/null +++ b/Runtime/Implementations/Frames/WebSocketFrameTypes.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: c41264f4c8f16a34386bbe57d7d7fd4c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 268757 + packageName: Best WebSockets + packageVersion: 3.0.7 + assetPath: Packages/com.tivadar.best.websockets/Runtime/Implementations/Frames/WebSocketFrameTypes.cs + uploadId: 737284 diff --git a/Runtime/Implementations/HTTP2WebSocketStream.cs b/Runtime/Implementations/HTTP2WebSocketStream.cs new file mode 100644 index 0000000..90d22dd --- /dev/null +++ b/Runtime/Implementations/HTTP2WebSocketStream.cs @@ -0,0 +1,168 @@ +#if (!UNITY_WEBGL || UNITY_EDITOR) && !BESTHTTP_DISABLE_ALTERNATE_SSL +using System; +using System.Collections.Generic; + +using Best.HTTP; +using Best.HTTP.Hosts.Connections.HTTP2; +using Best.HTTP.Shared; +using Best.HTTP.Shared.PlatformSupport.Memory; + +namespace Best.WebSockets.Implementations +{ + public sealed class HTTP2WebSocketStream : HTTP2Stream + { + public override bool HasFrameToSend + { + get + { + // Don't let the connection sleep until + return this.outgoing.Count > 0 || // we already booked at least one frame in advance + (this.State == HTTP2StreamStates.Open && + this.remoteWindow > 0 && + this.lastReadCount > 0 && + (this.websocketOverHTTP2.BufferedFramesCount > 0 || this.chunkQueue.Count > 0)); // we are in the middle of sending request data + } + } + + public override TimeSpan NextInteraction => this.websocketOverHTTP2.GetNextInteraction(); + + private OverHTTP2 websocketOverHTTP2; + + // local list of websocket header-data pairs + private List> chunkQueue = new List>(); + + public HTTP2WebSocketStream(uint id, HTTP2ContentConsumer parentHandler, HTTP2SettingsManager registry, HPACKEncoder hpackEncoder) + : base(id, parentHandler, registry, hpackEncoder) + {} + + public override void Assign(HTTPRequest request) + { + base.Assign(request); + + this.websocketOverHTTP2 = request.Tag as OverHTTP2; + this.websocketOverHTTP2.SetThreadSignaler(this._parentHandler); + } + + protected override void ProcessIncomingDATAFrame(ref HTTP2FrameHeaderAndPayload frame) + { + try + { + if (this.State != HTTP2StreamStates.HalfClosedLocal && this.State != HTTP2StreamStates.Open) + { + // ERROR! + return; + } + + this.downloaded += (uint)frame.Payload.Count; + + this.websocketOverHTTP2.OnReadThread(frame.Payload); + + // frame's buffer will be released later + frame.DontUseMemPool = true; + + this.localWindow -= frame.Payload.Count; + + if ((frame.Flags & (byte)HTTP2DataFlags.END_STREAM) != 0) + this.isEndSTRReceived = true; + + if (this.isEndSTRReceived) + { + HTTPManager.Logger.Information(nameof(HTTP2WebSocketStream), string.Format("[{0}] All data arrived, data length: {1:N0}", this.Id, this.downloaded), this.Context); + + FinishRequest(); + + if (this.State == HTTP2StreamStates.HalfClosedLocal) + this.State = HTTP2StreamStates.Closed; + else + this.State = HTTP2StreamStates.HalfClosedRemote; + } + } + catch (Exception ex) + { + HTTPManager.Logger.Exception(nameof(HTTP2WebSocketStream), nameof(ProcessIncomingDATAFrame), ex, this.Context); + } + } + + protected override void ProcessOpenState(List outgoingFrames) + { + try + { + // remote Window can be negative! See https://httpwg.org/specs/rfc7540.html#InitialWindowSize + if (this.remoteWindow <= 0) + { + HTTPManager.Logger.Information(nameof(HTTP2WebSocketStream), string.Format("[{0}] Skipping data sending as remote Window is {1}!", this.Id, this.remoteWindow), this.Context); + return; + } + + this.websocketOverHTTP2.PreReadCallback(); + + Int64 maxFragmentSize = Math.Min(Best.WebSockets.WebSocket.MaxFragmentSize, this.settings.RemoteSettings[HTTP2Settings.MAX_FRAME_SIZE]); + Int64 maxFrameSize = Math.Min(maxFragmentSize, this.remoteWindow); + + if (chunkQueue.Count == 0) + { + if (this.websocketOverHTTP2.TryDequeueFrame(out var frame)) + frame.WriteTo((header, data) => chunkQueue.Add(new KeyValuePair(header, data)), (uint)maxFragmentSize, false, this.Context); + } + + while (this.remoteWindow >= 6 && chunkQueue.Count > 0) + { + var kvp = chunkQueue[0]; + + BufferSegment header = kvp.Key; + BufferSegment data = kvp.Value; + + int minBytes = header.Count; + int maxBytes = minBytes + data.Count; + + // remote window is less than the minimum we have to send, or + // the frame has data but we have space only to send the websocket header + if (this.remoteWindow < minBytes || (maxBytes > minBytes && this.remoteWindow == minBytes)) + return; + + HTTP2FrameHeaderAndPayload headerFrame = new HTTP2FrameHeaderAndPayload(); + headerFrame.Type = HTTP2FrameTypes.DATA; + headerFrame.StreamId = this.Id; + + headerFrame.Payload = header; + headerFrame.DontUseMemPool = false; + + if (data.Count > 0) + { + HTTP2FrameHeaderAndPayload dataFrame = new HTTP2FrameHeaderAndPayload(); + dataFrame.Type = HTTP2FrameTypes.DATA; + dataFrame.StreamId = this.Id; + + var buff = data.Slice(data.Offset, (int)Math.Min(data.Count, maxFrameSize)); + + dataFrame.Payload = buff; + + data = data.Slice(buff.Offset + buff.Count); + if (data.Count == 0) + chunkQueue.RemoveAt(0); + else + chunkQueue[0] = new KeyValuePair(header, data); + + // release the buffer only with the final frame and with the final frame's last data chunk + bool isLast = (header.Data[header.Offset] & 0x80) != 0 /*&& chunkQueue.Count == 0*/; + dataFrame.DontUseMemPool = !isLast; + + this.outgoing.Enqueue(headerFrame); + this.outgoing.Enqueue(dataFrame); + } + else + { + this.outgoing.Enqueue(headerFrame); + chunkQueue.RemoveAt(0); + } + } + } + catch (Exception ex) + { + HTTPManager.Logger.Exception(nameof(HTTP2WebSocketStream), nameof(ProcessOpenState), ex, this.Context); + } + } + } +} + +#endif diff --git a/Runtime/Implementations/HTTP2WebSocketStream.cs.meta b/Runtime/Implementations/HTTP2WebSocketStream.cs.meta new file mode 100644 index 0000000..f6f9bda --- /dev/null +++ b/Runtime/Implementations/HTTP2WebSocketStream.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 343296b29e940cb4dafe7e6da3b405f2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 268757 + packageName: Best WebSockets + packageVersion: 3.0.7 + assetPath: Packages/com.tivadar.best.websockets/Runtime/Implementations/HTTP2WebSocketStream.cs + uploadId: 737284 diff --git a/Runtime/Implementations/OverHTTP1.cs b/Runtime/Implementations/OverHTTP1.cs new file mode 100644 index 0000000..b3b79f7 --- /dev/null +++ b/Runtime/Implementations/OverHTTP1.cs @@ -0,0 +1,728 @@ +#if !UNITY_WEBGL || UNITY_EDITOR +using Best.HTTP; +using Best.HTTP.Hosts.Connections; +using Best.HTTP.Shared; +using Best.HTTP.Shared.Extensions; +using Best.HTTP.Shared.Logger; +using Best.HTTP.Shared.PlatformSupport.Memory; +using Best.HTTP.Shared.PlatformSupport.Network.Tcp; +using Best.HTTP.Shared.PlatformSupport.Threading; +using Best.HTTP.Shared.Streams; +using Best.WebSockets.Implementations.Frames; + +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Text; +using System.Threading; + +namespace Best.WebSockets.Implementations +{ + /// + /// Implements WebSocket communication through an HTTP/1 connection. + /// + internal sealed class OverHTTP1 : WebSocketBaseImplementation, IContentConsumer, IHeartbeat + { + public PeekableContentProviderStream ContentProvider { get; private set; } + + /// + /// Indicates whether we sent out the connection request to the server. + /// + private bool requestSent; + + private volatile bool _closed; + private UInt16 _closeCode; + private string _closeMessage; + + private ConcurrentQueue unsentFrames = new ConcurrentQueue(); + private volatile AutoResetEvent newFrameSignal = new AutoResetEvent(false); + + public OverHTTP1(WebSocket parent, Uri uri, string origin, string protocol) : base(parent, uri, origin, protocol) + { + string scheme = HTTPProtocolFactory.IsSecureProtocol(uri) ? "wss" : "ws"; + int port = uri.Port != -1 ? uri.Port : (scheme.Equals("wss", StringComparison.OrdinalIgnoreCase) ? 443 : 80); + + // Somehow if i use the UriBuilder it's not the same as if the uri is constructed from a string... + //uri = new UriBuilder(uri.Scheme, uri.Host, uri.Scheme.Equals("wss", StringComparison.OrdinalIgnoreCase) ? 443 : 80, uri.PathAndQuery).Uri; + base.Uri = new Uri(scheme + "://" + uri.Host + ":" + port + uri.GetRequestPathAndQueryURL()); + } + + protected override void CreateInternalRequest() + { + if (this._internalRequest != null) + return; + + this._internalRequest = new HTTPRequest(base.Uri, OnInternalRequestCallback); + + this._internalRequest.Context.Add("WebSocket", this.Parent.Context); + + // Called when the regular GET request is successfully upgraded to WebSocket + this._internalRequest.DownloadSettings.OnUpgraded = OnInternalRequestUpgraded; + + //http://tools.ietf.org/html/rfc6455#section-4 + + // The request MUST contain an |Upgrade| header field whose value MUST include the "websocket" keyword. + this._internalRequest.SetHeader("Upgrade", "websocket"); + + // The request MUST contain a |Connection| header field whose value MUST include the "Upgrade" token. + this._internalRequest.SetHeader("Connection", "Upgrade"); + + // The request MUST include a header field with the name |Sec-WebSocket-Key|. The value of this header field MUST be a nonce consisting of a + // randomly selected 16-byte value that has been base64-encoded (see Section 4 of [RFC4648]). The nonce MUST be selected randomly for each connection. + this._internalRequest.SetHeader("Sec-WebSocket-Key", WebSocket.GetSecKey(new object[] { this, InternalRequest, base.Uri, new object() })); + + // The request MUST include a header field with the name |Origin| [RFC6454] if the request is coming from a browser client. + // If the connection is from a non-browser client, the request MAY include this header field if the semantics of that client match the use-case described here for browser clients. + // More on Origin Considerations: http://tools.ietf.org/html/rfc6455#section-10.2 + if (!string.IsNullOrEmpty(Origin)) + this._internalRequest.SetHeader("Origin", Origin); + + // The request MUST include a header field with the name |Sec-WebSocket-Version|. The value of this header field MUST be 13. + this._internalRequest.SetHeader("Sec-WebSocket-Version", "13"); + + if (!string.IsNullOrEmpty(Protocol)) + this._internalRequest.SetHeader("Sec-WebSocket-Protocol", Protocol); + + // Disable caching + this._internalRequest.SetHeader("Cache-Control", "no-cache"); + + this._internalRequest.DownloadSettings.DisableCache = true; + +#if !UNITY_WEBGL || UNITY_EDITOR + this._internalRequest.ProxySettings = this.Parent.GetProxy(this.Uri); +#endif + + this._internalRequest.RedirectSettings.OnBeforeRedirection += InternalRequest_OnBeforeRedirection; + + if (this.Parent.OnInternalRequestCreated != null) + { + try + { + this.Parent.OnInternalRequestCreated(this.Parent, this._internalRequest); + } + catch (Exception ex) + { + HTTPManager.Logger.Exception(nameof(OverHTTP1), "CreateInternalRequest", ex, this.Parent.Context); + } + } + + this._internalRequest.OnCancellationRequested += OnCancellationRequested; + } + + private void OnCancellationRequested(HTTPRequest req) + { + HTTPManager.Logger.Information(nameof(OverHTTP1), $"{nameof(OnCancellationRequested)}", this.Parent.Context); + this._internalRequest.OnCancellationRequested -= OnCancellationRequested; + + this._closed = true; + this.newFrameSignal?.Set(); + } + + private bool InternalRequest_OnBeforeRedirection(HTTPRequest originalRequest, HTTPResponse response, Uri redirectUri) + { + HTTPManager.Logger.Information(nameof(OverHTTP1), $"{nameof(InternalRequest_OnBeforeRedirection)}", this.Parent.Context); + + // We have to re-select/reset the implementation in the parent Websocket, as the redirected request might gets served over a HTTP/2 connection! + this.Parent.SelectImplementation(redirectUri, originalRequest.GetFirstHeaderValue("Origin"), originalRequest.GetFirstHeaderValue("Sec-WebSocket-Protocol")) + .StartOpen(); + + originalRequest.Callback = null; + return false; + } + + private bool OnInternalRequestUpgraded(HTTPRequest req, HTTPResponse resp, PeekableContentProviderStream contentProvider) + { + HTTPManager.Logger.Information(nameof(OverHTTP1), $"{nameof(OnInternalRequestUpgraded)}", this.Parent.Context); + + if (this.State == WebSocketStates.Closed) + return false; + + if (!resp.HasHeader("sec-websocket-accept")) + throw new Exception("No Sec-Websocket-Accept header is sent by the server!"); + + base.ParseExtensionResponse(resp); + + // Save the provider + this.ContentProvider = contentProvider; + + // Websocket continously reading from the stream, but it could stuck with frames larger than the MaxBufferSize. + if (this.ContentProvider is ITCPStreamerContentConsumer consumer && consumer is not null) + consumer.MaxBufferSize = long.MaxValue; + + // Switch the comsumer to this websocket implementation instead of the http1 consumer. + contentProvider.SetTwoWayBinding(this); + + // Start send thread + Best.HTTP.Shared.PlatformSupport.Threading.ThreadedRunner.RunLongLiving(SendThread); + + return true; + } + + private void OnInternalRequestCallback(HTTPRequest req, HTTPResponse resp) + { + HTTPManager.Logger.Information(nameof(OverHTTP1), $"{nameof(OnInternalRequestCallback)}", this.Parent.Context); + + Cleanup(); + + string reason = string.Empty; + + switch (req.State) + { + case HTTPRequestStates.Finished: + HTTPManager.Logger.Information(nameof(OverHTTP1), string.Format("Request finished. Status Code: {0} Message: {1}", resp.StatusCode.ToString(), resp.Message), this.Parent.Context); + + if (resp.IsUpgraded) + { + return; + } + else + reason = string.Format("Request Finished Successfully, but the server sent an error. Status Code: {0}-{1} Message: {2}", + resp.StatusCode, + resp.Message, + resp.DataAsText); + break; + + // The request finished with an unexpected error. The request's Exception property may contain more info about the error. + case HTTPRequestStates.Error: + reason = req.Exception != null ? req.Exception.Message : string.Empty; + break; + + // The request aborted, initiated by the user. + case HTTPRequestStates.Aborted: + reason = "Request Aborted!"; + break; + + // Connecting to the server is timed out. + case HTTPRequestStates.ConnectionTimedOut: + reason = "Connection Timed Out!"; + break; + + // The request didn't finished in the given time. + case HTTPRequestStates.TimedOut: + reason = "Processing the request Timed Out!"; + break; + + default: + return; + } + + /*if (this.State != WebSocketStates.Connecting || !string.IsNullOrEmpty(reason)) + { + if (this.Parent.OnError != null) + this.Parent.OnError(this.Parent, reason); + else if (!HTTPManager.IsQuitting) + HTTPManager.Logger.Error(nameof(OverHTTP1), reason, this.Parent.Context); + }*/ + + if (this.Parent.OnClosed != null) + { + this.Parent.OnClosed(this.Parent, + !string.IsNullOrEmpty(reason) ? WebSocketStatusCodes.ClosedAbnormally : WebSocketStatusCodes.NormalClosure, + reason ?? "Closed while opening"); + } + + this._closed = true; + this.State = WebSocketStates.Closed; + this.newFrameSignal?.Set(); + + this.ContentProvider?.Unbind(); + } + + private void SendThread() + { + HTTPManager.Logger.Information(nameof(OverHTTP1), "SendThread - created", this.Parent.Context); + + ThreadedRunner.SetThreadName("Best.WebSockets Send"); + + try + { + bool doMask = !HTTPProtocolFactory.IsSecureProtocol(this.Uri); + var pingFreq = this.Parent.SendPings ? this.Parent.PingFrequency : TimeSpan.Zero; + + using (WriteOnlyBufferedStream bufferedStream = new WriteOnlyBufferedStream(this.ContentProvider as Stream, 16 * 1024, this.Parent.Context)) + { + while (!this._closed) + { + //if (HTTPManager.Logger.Level <= Logger.Loglevels.All) + // HTTPManager.Logger.Information(nameof(OverHTTP1), "SendThread - Waiting...", this.Context); + + TimeSpan waitTime = TimeSpan.FromMilliseconds(int.MaxValue); + + if (pingFreq != TimeSpan.Zero) + { + DateTime now = DateTime.UtcNow; + DateTime pingTime = this.lastPing; + waitTime = pingTime + pingFreq - now; + + if (waitTime <= TimeSpan.Zero) + { + if (!waitingForPong && now - pingTime >= pingFreq) + { + SendPing(); + pingTime = this.lastPing; + } + + waitTime = this.Parent.CloseAfterNoMessage; + } + + if (waitingForPong && (now - pingTime > this.Parent.CloseAfterNoMessage) && (now - LastMessageReceived > this.Parent.CloseAfterNoMessage)) + { + HTTPManager.Logger.Warning(nameof(OverHTTP1), + $"No pong received in the given time! LastPing: {pingTime}, PingFrequency: {pingFreq}, Close After: {this.Parent.CloseAfterNoMessage}, Now: {now}", + this.Parent.Context); + + RequestEventHelper.EnqueueRequestEvent(new RequestEventInfo(this._internalRequest, HTTPRequestStates.Error, new Exception("No PONG received in the given time!"))); + this._closed = true; + continue; + } + } + + newFrameSignal.WaitOne(waitTime); + + try + { + //if (HTTPManager.Logger.Level <= Logger.Loglevels.All) + // HTTPManager.Logger.Information(nameof(OverHTTP1), "SendThread - Wait is over, about " + this.unsentFrames.Count.ToString() + " new frames!", this.Context); + + WebSocketFrame frame; + while (!this._closeSent && this.unsentFrames.TryDequeue(out frame)) + { + // save data count as per-message deflate can compress, and it would be different after calling WriteTo + int originalFrameDataLength = frame.Data.Count; + + frame.WriteTo((header, chunk) => + { + bufferedStream.Write(header.Data, header.Offset, header.Count); + BufferPool.Release(header); + + if (chunk != BufferSegment.Empty) + bufferedStream.Write(chunk.Data, chunk.Offset, chunk.Count); + }, WebSocket.MaxFragmentSize, doMask, this.Parent.Context); + BufferPool.Release(frame.Data); + + if (frame.Type == WebSocketFrameTypes.ConnectionClose) + { + this._closeSent = true; + if (this._closeReceived) + { + this._closed = true; + this.State = WebSocketStates.Closed; + } + } + + Interlocked.Add(ref this._bufferedAmount, -originalFrameDataLength); + } + + bufferedStream.Flush(); + } + catch (Exception ex) + { + //this._internalRequest.Timing.Finish(Timing_Name); + + if (HTTPUpdateDelegator.IsCreated) + { + //this._internalRequest.Exception = ex; + //this._internalRequest.State = HTTPRequestStates.Error; + RequestEventHelper.EnqueueRequestEvent(new RequestEventInfo(this._internalRequest, HTTPRequestStates.Error, ex)); + } + else + { + //this._internalRequest.State = HTTPRequestStates.Aborted; + RequestEventHelper.EnqueueRequestEvent(new RequestEventInfo(this._internalRequest, HTTPRequestStates.Aborted, null)); + } + + HTTPManager.Logger.Exception(nameof(OverHTTP1), "Frame sending", ex, this.Parent.Context); + + this._closed = true; + this.State = WebSocketStates.Closed; + } + } + + HTTPManager.Logger.Information(nameof(OverHTTP1), string.Format("Ending Send thread. Closed: {0}, closeSent: {1}", this._closed, this._closeSent), this.Parent.Context); + } + } + catch (Exception ex) + { + if (HTTPManager.Logger.Level == Loglevels.All) + HTTPManager.Logger.Exception(nameof(OverHTTP1), "SendThread", ex, this.Parent.Context); + } + finally + { + HTTPManager.Logger.Information(nameof(OverHTTP1), "SendThread - Closed!", this.Parent.Context); + this.newFrameSignal?.Dispose(); + this.newFrameSignal = null; + } + } + + private void SendPing() + { + HTTPManager.Logger.Information(nameof(OverHTTP1), "Sending Ping frame, waiting for a pong...", this.Parent.Context); + + lastPing = DateTime.UtcNow; + waitingForPong = true; + + Send(new WebSocketFrame(this.Parent, WebSocketFrameTypes.Ping, BufferSegment.Empty)); + } + + public override void StartOpen() + { + HTTPManager.Logger.Information(nameof(OverHTTP1), $"{nameof(StartOpen)}", this.Parent.Context); + if (requestSent) + throw new InvalidOperationException("Open already called! You can't reuse this WebSocket instance!"); + + if (this.Parent.Extensions != null) + { + try + { + for (int i = 0; i < this.Parent.Extensions.Length; ++i) + { + var ext = this.Parent.Extensions[i]; + if (ext != null) + ext.AddNegotiation(InternalRequest); + } + } + catch (Exception ex) + { + HTTPManager.Logger.Exception(nameof(OverHTTP1), "Open", ex, this.Parent.Context); + } + } + + InternalRequest.Send(); + requestSent = true; + this.State = WebSocketStates.Connecting; + HTTPManager.Heartbeats.Subscribe(this); + } + + public override void StartClose(WebSocketStatusCodes code, string message) + { + HTTPManager.Logger.Information(nameof(OverHTTP1), $"{nameof(StartClose)}({code}, {message})", this.Parent.Context); + + if (this.State == WebSocketStates.Connecting) + { + if (this.InternalRequest != null) + this.InternalRequest.Abort(); + + this.State = WebSocketStates.Closed; + if (this.Parent.OnClosed != null) + this.Parent.OnClosed(this.Parent, WebSocketStatusCodes.NormalClosure, string.Empty); + } + else + { + this.State = WebSocketStates.Closing; + + Send(new WebSocketFrame(this.Parent, WebSocketFrameTypes.ConnectionClose, WebSocket.EncodeCloseData(code, message), false)); + } + } + + public override void Send(string message) + { + if (message == null) + throw new ArgumentNullException("message must not be null!"); + + int count = System.Text.Encoding.UTF8.GetByteCount(message); + byte[] data = BufferPool.Get(count, true); + System.Text.Encoding.UTF8.GetBytes(message, 0, message.Length, data, 0); + + Send(WebSocketFrameTypes.Text, data.AsBuffer(count)); + } + + public override void Send(byte[] data) + { + if (data == null) + throw new ArgumentNullException("data must not be null!"); + + WebSocketFrame frame = new WebSocketFrame(this.Parent, WebSocketFrameTypes.Binary, new BufferSegment(data, 0, data.Length)); + Send(frame); + } + + public override void Send(byte[] data, ulong offset, ulong count) + { + if (data == null) + throw new ArgumentNullException("data must not be null!"); + if (offset + count > (ulong)data.Length) + throw new ArgumentOutOfRangeException("offset + count >= data.Length"); + + WebSocketFrame frame = new WebSocketFrame(this.Parent, WebSocketFrameTypes.Binary, new BufferSegment(data, (int)offset, (int)count), true); + Send(frame); + } + + public void Send(WebSocketFrameTypes type, BufferSegment data) + { + WebSocketFrame frame = new WebSocketFrame(this.Parent, type, data, false); + Send(frame); + } + + public override void SendAsBinary(BufferSegment data) + { + Send(WebSocketFrameTypes.Binary, data); + } + + public override void SendAsText(BufferSegment data) + { + Send(WebSocketFrameTypes.Text, data); + } + + public override void Send(WebSocketFrame frame) + { + if (this._closed || this._closeSent) + return; + + this.unsentFrames.Enqueue(frame); + + Interlocked.Add(ref this._bufferedAmount, frame.Data.Count); + + newFrameSignal.Set(); + } + + public void SetBinding(PeekableContentProviderStream stream) + { + this.ContentProvider = stream; + + // Read any frames already in the buffers + OnContent(); + } + + public void UnsetBinding() + { + this.ContentProvider?.Dispose(); + this.ContentProvider = null; + } + + public void OnContent() + { + this.LastMessageReceived = DateTime.UtcNow; + + if (this._closeReceived || this._closed) + return; + + while (CanReadFullFrame(this.ContentProvider)) + { + WebSocketFrameReader frame = new WebSocketFrameReader(); + frame.Read(this.ContentProvider); + + if (HTTPManager.Logger.IsDiagnostic) + HTTPManager.Logger.Verbose(nameof(OverHTTP1), "Frame received: " + frame.ToString(), this.Parent.Context); + + if (!frame.IsFinal) + { + IncompleteFrames.Add(frame); + continue; + } + + switch (frame.Type) + { + // For a complete documentation and rules on fragmentation see http://tools.ietf.org/html/rfc6455#section-5.4 + // A fragmented Frame's last fragment's opcode is 0 (Continuation) and the FIN bit is set to 1. + case WebSocketFrameTypes.Continuation: + // Do an assemble pass only if OnFragment is not set. Otherwise put it in the CompletedFrames, we will handle it in the HandleEvent phase. + frame.Assemble(IncompleteFrames); + + // Remove all incomplete frames + IncompleteFrames.Clear(); + + // Control frames themselves MUST NOT be fragmented. So, its a normal text or binary frame. Go, handle it as usual. + goto case WebSocketFrameTypes.Binary; + + case WebSocketFrameTypes.Text: + case WebSocketFrameTypes.Binary: + frame.DecodeWithExtensions(this.Parent); + CompletedFrames.Enqueue(frame); + break; + + // Upon receipt of a Ping frame, an endpoint MUST send a Pong frame in response, unless it already received a Close frame. + case WebSocketFrameTypes.Ping: + if (!_closeSent && this.State != WebSocketStates.Closed) + { + // copy data set to true here, as the frame's data is released back to the pool after the switch + Send(new WebSocketFrame(this.Parent, WebSocketFrameTypes.Pong, frame.Data, true)); + } + break; + + case WebSocketFrameTypes.Pong: + // https://tools.ietf.org/html/rfc6455#section-5.5 + // A Pong frame MAY be sent unsolicited. This serves as a + // unidirectional heartbeat. A response to an unsolicited Pong frame is + // not expected. + if (!waitingForPong) + break; + + waitingForPong = false; + // the difference between the current time and the time when the ping message is sent + TimeSpan diff = DateTime.UtcNow - lastPing; + + // add it to the buffer + this.rtts.Add((int)diff.TotalMilliseconds); + + // and calculate the new latency + base.Latency = CalculateLatency(); + break; + + // If an endpoint receives a Close frame and did not previously send a Close frame, the endpoint MUST send a Close frame in response. + case WebSocketFrameTypes.ConnectionClose: + this._closeReceived = true; + + HTTPManager.Logger.Information(nameof(OverHTTP1), $"ConnectionClose packet received! ({this._closeReceived}, {this._closeSent})", this.Parent.Context); + + CompletedFrames.Enqueue(frame); + break; + } + } + } + + public void OnConnectionClosed() + { + if (this._closed) + return; + + //this._internalRequest.Timing.Finish(Timing_Name); + + RequestEventHelper.EnqueueRequestEvent(new RequestEventInfo(this._internalRequest, HTTPRequestStates.Error, new Exception("Connection closed unexpectedly!"))); + } + + public void OnError(Exception ex) + { + if (this._closed) + return; + + //this._internalRequest.Timing.Finish(Timing_Name); + + RequestEventHelper.EnqueueRequestEvent(new RequestEventInfo(this._internalRequest, HTTPRequestStates.Error, ex)); + } + + public void OnHeartbeatUpdate(DateTime now, TimeSpan dif) + { + if (HTTPManager.IsQuitting) + this.StartClose(WebSocketStatusCodes.GoingAway, "Editor closing"); + + switch (this.State) + { + case WebSocketStates.Connecting: + if (requestSent && this._internalRequest?.Response?.IsUpgraded is bool upgraded && upgraded) + { + this.State = WebSocketStates.Open; + + // The request upgraded successfully. + if (this.Parent.OnOpen != null) + this.Parent.OnOpen(this.Parent); + + OnHeartbeatUpdate(now, dif); + } + break; + + case WebSocketStates.Closing: + // TODO: define and handle a timeout + + HandleCompletedFrames(); + break; + + case WebSocketStates.Closed: + HandleCompletedFrames(); + + HTTPManager.Heartbeats.Unsubscribe(this); + this.ContentProvider?.Unbind(); + + if (this._internalRequest != null && this._internalRequest.State < HTTPRequestStates.Finished) + RequestEventHelper.EnqueueRequestEvent(new RequestEventInfo(this._internalRequest, HTTPRequestStates.Finished, null)); + + // TODO: go through any lists and queues to empty and recycle buffer segments + // this.unsentFrames.TryDequeue(out frame) + + if (this.Parent.OnClosed != null) + { + try + { + this.Parent.OnClosed(this.Parent, (WebSocketStatusCodes)this._closeCode, this._closeMessage); + this.Parent.OnClosed = null; + } + catch (Exception ex) + { + HTTPManager.Logger.Exception(nameof(OverHTTP1), "HandleEvents - OnClosed", ex, this.Parent.Context); + } + } + break; + + default: + HandleCompletedFrames(); + break; + } + } + + private void HandleCompletedFrames() + { + while (CompletedFrames.TryDequeue(out var frame)) + { + try + { + switch (frame.Type) + { + case WebSocketFrameTypes.Continuation: + if (HTTPManager.Logger.Level == Loglevels.All) + HTTPManager.Logger.Verbose(nameof(OverHTTP1), "HandleEvents - OnIncompleteFrame", this.Parent.Context); + break; + + case WebSocketFrameTypes.Text: + // Any not Final frame is handled as a fragment + if (!frame.IsFinal) + goto case WebSocketFrameTypes.Continuation; + + if (HTTPManager.Logger.Level == Loglevels.All) + HTTPManager.Logger.Verbose(nameof(OverHTTP1), $"HandleEvents - OnText(\"{frame.DataAsText}\")", this.Parent.Context); + + if (this.Parent.OnMessage != null) + this.Parent.OnMessage(this.Parent, frame.DataAsText); + break; + + case WebSocketFrameTypes.Binary: + // Any not Final frame is handled as a fragment + if (!frame.IsFinal) + goto case WebSocketFrameTypes.Continuation; + + if (HTTPManager.Logger.Level == Loglevels.All) + HTTPManager.Logger.Verbose(nameof(OverHTTP1), $"HandleEvents - OnBinary({frame.Data})", this.Parent.Context); + + if (this.Parent.OnBinary != null) + this.Parent.OnBinary(this.Parent, frame.Data); + break; + + case WebSocketFrameTypes.ConnectionClose: + HTTPManager.Logger.Verbose(nameof(OverHTTP1), "HandleEvents - Calling OnClosed", this.Parent.Context); + + if (!this._closeSent) + { + this.State = WebSocketStates.Closing; + Send(new WebSocketFrame(this.Parent, WebSocketFrameTypes.ConnectionClose, BufferSegment.Empty)); + } + else + { + this._closed = true; + this.State = WebSocketStates.Closed; + this.newFrameSignal?.Set(); + } + + if (frame.Data != BufferSegment.Empty && frame.Data.Count >= 2) + { + if (BitConverter.IsLittleEndian) + Array.Reverse(frame.Data.Data, frame.Data.Offset, 2); + this._closeCode = BitConverter.ToUInt16(frame.Data.Data, frame.Data.Offset); + + if (frame.Data.Count > 2) + this._closeMessage = Encoding.UTF8.GetString(frame.Data.Data, frame.Data.Offset + 2, frame.Data.Count - 2); + + frame.ReleaseData(); + } + break; + } + } + catch (Exception ex) + { + HTTPManager.Logger.Exception(nameof(OverHTTP1), string.Format("HandleEvents({0})", frame.ToString()), ex, this.Parent.Context); + } + finally + { + frame.ReleaseData(); + } + } + } + } +} +#endif diff --git a/Runtime/Implementations/OverHTTP1.cs.meta b/Runtime/Implementations/OverHTTP1.cs.meta new file mode 100644 index 0000000..61d68b3 --- /dev/null +++ b/Runtime/Implementations/OverHTTP1.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: c7c7b94536f8416468021fa15f00b11e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 268757 + packageName: Best WebSockets + packageVersion: 3.0.7 + assetPath: Packages/com.tivadar.best.websockets/Runtime/Implementations/OverHTTP1.cs + uploadId: 737284 diff --git a/Runtime/Implementations/OverHTTP2.cs b/Runtime/Implementations/OverHTTP2.cs new file mode 100644 index 0000000..8228c9c --- /dev/null +++ b/Runtime/Implementations/OverHTTP2.cs @@ -0,0 +1,584 @@ +#if (!UNITY_WEBGL || UNITY_EDITOR) && !BESTHTTP_DISABLE_ALTERNATE_SSL +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; + +using Best.HTTP; +using Best.HTTP.Hosts.Connections; +using Best.HTTP.Hosts.Connections.HTTP2; +using Best.HTTP.Shared; +using Best.HTTP.Shared.Extensions; +using Best.HTTP.Shared.Logger; +using Best.HTTP.Shared.PlatformSupport.Memory; +using Best.WebSockets.Implementations.Frames; + +namespace Best.WebSockets.Implementations +{ + /// + /// Implements RFC 8441 (https://tools.ietf.org/html/rfc8441) to use Websocket over HTTP/2 + /// + public sealed class OverHTTP2 : WebSocketBaseImplementation, IHeartbeat + { + public override int Latency { get { return base.Latency; } } + public int BufferedFramesCount { get => base.frames.Count; } + + private IThreadSignaler threadSignaler; + + public OverHTTP2(WebSocket parent, Uri uri, string origin, string protocol) : base(parent, uri, origin, protocol) + { + // use https scheme so it will be served over HTTP/2. The request's Tag will be set to this class' instance so HTTP2Handler will know it has to create a HTTP2WebSocketStream instance to + // process the request. + string scheme = "https"; + int port = uri.Port != -1 ? uri.Port : 443; + + base.Uri = new Uri(scheme + "://" + uri.Host + ":" + port + uri.GetRequestPathAndQueryURL()); + } + + internal void SetThreadSignaler(IThreadSignaler signaler) => this.threadSignaler = signaler; + + protected override void CreateInternalRequest() + { + HTTPManager.Logger.Verbose("OverHTTP2", "CreateInternalRequest", this.Parent.Context); + + base._internalRequest = new HTTPRequest(base.Uri, HTTPMethods.Connect, OnInternalRequestCallback); + base._internalRequest.Context.Add("WebSocket", this.Parent.Context); + + base._internalRequest.SetHeader(":protocol", "websocket"); + + // The request MUST include a header field with the name |Sec-WebSocket-Key|. The value of this header field MUST be a nonce consisting of a + // randomly selected 16-byte value that has been base64-encoded (see Section 4 of [RFC4648]). The nonce MUST be selected randomly for each connection. + base._internalRequest.SetHeader("sec-webSocket-key", WebSocket.GetSecKey(new object[] { this, InternalRequest, base.Uri, new object() })); + + // The request MUST include a header field with the name |Origin| [RFC6454] if the request is coming from a browser client. + // If the connection is from a non-browser client, the request MAY include this header field if the semantics of that client match the use-case described here for browser clients. + // More on Origin Considerations: http://tools.ietf.org/html/rfc6455#section-10.2 + if (!string.IsNullOrEmpty(base.Origin)) + base._internalRequest.SetHeader("origin", base.Origin); + + // The request MUST include a header field with the name |Sec-WebSocket-Version|. The value of this header field MUST be 13. + base._internalRequest.SetHeader("sec-webSocket-version", "13"); + + if (!string.IsNullOrEmpty(base.Protocol)) + base._internalRequest.SetHeader("sec-webSocket-protocol", base.Protocol); + + // Disable caching + base._internalRequest.SetHeader("cache-control", "no-cache"); + + base._internalRequest.DownloadSettings.DisableCache = true; + + base._internalRequest.DownloadSettings.OnHeadersReceived += OnHeadersReceived; + + // set a fake upload stream, so HPACKEncoder will not set the END_STREAM flag + base._internalRequest.UploadSettings.UploadStream = new MemoryStream(0); + // TODO: + //base._internalRequest.UseUploadStreamLength = false; + + this.LastMessageReceived = DateTime.UtcNow; + base._internalRequest.Tag = (CustomHTTP2StreamFactory)HTTP2WebSocketStreamFactory; + + if (this.Parent.OnInternalRequestCreated != null) + { + try + { + this.Parent.OnInternalRequestCreated(this.Parent, base._internalRequest); + } + catch (Exception ex) + { + HTTPManager.Logger.Exception("OverHTTP2", "CreateInternalRequest", ex, this.Parent.Context); + } + } + } + + private HTTP2WebSocketStream HTTP2WebSocketStreamFactory(HTTPRequest request, uint id, HTTP2ContentConsumer parentHandler, HTTP2SettingsManager registry, HPACKEncoder hpackEncoder) + { + request.Tag = this; + return new HTTP2WebSocketStream(id, parentHandler, registry, hpackEncoder); + } + + private void OnHeadersReceived(HTTPRequest req, HTTPResponse resp, Dictionary> newHeaders) + { + HTTPManager.Logger.Verbose("OverHTTP2", $"OnHeadersReceived - StatusCode: {resp?.StatusCode}", this.Parent.Context); + + if (resp != null && resp.StatusCode == 200) + { + base.ParseExtensionResponse(resp); + + this.State = WebSocketStates.Open; + + if (this.Parent.OnOpen != null) + { + try + { + this.Parent.OnOpen(this.Parent); + } + catch (Exception ex) + { + HTTPManager.Logger.Exception("OverHTTP2", "OnOpen", ex, this.Parent.Context); + } + } + + if (this.Parent.SendPings) + { + this.LastMessageReceived = DateTime.UtcNow; + SendPing(); + } + } + else + req.Abort(); + } + + internal void OnReadThread(BufferSegment buffer) + { + this.LastMessageReceived = DateTime.UtcNow; + + this.incomingSegmentStream.Write(buffer); + + while (CanReadFullFrame(this.incomingSegmentStream)) + { + WebSocketFrameReader frame = new WebSocketFrameReader(); + frame.Read(this.incomingSegmentStream); + + if (HTTPManager.Logger.IsDiagnostic) + HTTPManager.Logger.Verbose("OverHTTP2", "Frame received: " + frame.ToString(), this.Parent.Context); + + if (!frame.IsFinal) + { + IncompleteFrames.Add(frame); + continue; + } + + switch (frame.Type) + { + // For a complete documentation and rules on fragmentation see http://tools.ietf.org/html/rfc6455#section-5.4 + // A fragmented Frame's last fragment's opcode is 0 (Continuation) and the FIN bit is set to 1. + case WebSocketFrameTypes.Continuation: + frame.Assemble(IncompleteFrames); + + // Remove all incomplete frames + IncompleteFrames.Clear(); + + // Control frames themselves MUST NOT be fragmented. So, its a normal text or binary frame. Go, handle it as usual. + goto case WebSocketFrameTypes.Binary; + + case WebSocketFrameTypes.Text: + case WebSocketFrameTypes.Binary: + frame.DecodeWithExtensions(this.Parent); + CompletedFrames.Enqueue(frame); + break; + + // Upon receipt of a Ping frame, an endpoint MUST send a Pong frame in response, unless it already received a Close frame. + case WebSocketFrameTypes.Ping: + if (!_closeSent && this.State != WebSocketStates.Closed) + { + // copy data set to true here, as the frame's data is released back to the pool after the switch + Send(new WebSocketFrame(this.Parent, WebSocketFrameTypes.Pong, frame.Data, true)); + } + break; + + case WebSocketFrameTypes.Pong: + // https://tools.ietf.org/html/rfc6455#section-5.5 + // A Pong frame MAY be sent unsolicited. This serves as a + // unidirectional heartbeat. A response to an unsolicited Pong frame is + // not expected. + if (!waitingForPong) + break; + + waitingForPong = false; + // the difference between the current time and the time when the ping message is sent + TimeSpan diff = DateTime.UtcNow - lastPing; + + // add it to the buffer + this.rtts.Add((int)diff.TotalMilliseconds); + + // and calculate the new latency + base.Latency = CalculateLatency(); + break; + + // If an endpoint receives a Close frame and did not previously send a Close frame, the endpoint MUST send a Close frame in response. + case WebSocketFrameTypes.ConnectionClose: + HTTPManager.Logger.Information("OverHTTP2", "ConnectionClose packet received!", this.Parent.Context); + + CompletedFrames.Enqueue(frame); + + if (!_closeSent) + Send(new WebSocketFrame(this.Parent, WebSocketFrameTypes.ConnectionClose, BufferSegment.Empty)); + + this.State = WebSocketStates.Closed; + break; + } + } + } + + private void OnInternalRequestCallback(HTTPRequest req, HTTPResponse resp) + { + HTTPManager.Logger.Verbose("OverHTTP2", $"OnInternalRequestCallback - this.State: {this.State}", this.Parent.Context); + + Cleanup(); + + // If it's already closed, all events are called too. + if (this.State == WebSocketStates.Closed) + return; + + if (this.State == WebSocketStates.Connecting && HTTPManager.PerHostSettings.Get(this.Uri).HTTP2ConnectionSettings.WebSocketOverHTTP2Settings.EnableImplementationFallback) + { + this.Parent.FallbackToHTTP1(); + HTTPManager.Heartbeats.Unsubscribe(this); + return; + } + + string reason = string.Empty; + + switch (req.State) + { + case HTTPRequestStates.Finished: + HTTPManager.Logger.Information("OverHTTP2", string.Format("Request finished. Status Code: {0} Message: {1}", resp.StatusCode.ToString(), resp.Message), this.Parent.Context); + + if (resp.StatusCode == 101) + { + // The request upgraded successfully. + return; + } + else + reason = string.Format("Request Finished Successfully, but the server sent an error. Status Code: {0}-{1} Message: {2}", + resp.StatusCode, + resp.Message, + resp.DataAsText); + break; + + // The request finished with an unexpected error. The request's Exception property may contain more info about the error. + case HTTPRequestStates.Error: + reason = "Request Finished with Error! " + (req.Exception != null ? ("Exception: " + req.Exception.Message + req.Exception.StackTrace) : string.Empty); + break; + + // The request aborted, initiated by the user. + case HTTPRequestStates.Aborted: + reason = "Request Aborted!"; + break; + + // Connecting to the server is timed out. + case HTTPRequestStates.ConnectionTimedOut: + reason = "Connection Timed Out!"; + break; + + // The request didn't finished in the given time. + case HTTPRequestStates.TimedOut: + reason = "Processing the request Timed Out!"; + break; + + default: + return; + } + + if (this.Parent.OnClosed != null) + { + try + { + this.Parent.OnClosed(this.Parent, + !string.IsNullOrEmpty(reason) ? WebSocketStatusCodes.ClosedAbnormally : WebSocketStatusCodes.NormalClosure, + reason ?? "Closed while opening"); + } + catch (Exception ex) + { + HTTPManager.Logger.Exception("OverHTTP2", "OnClosed", ex, this.Parent.Context); + } + } + + this.State = WebSocketStates.Closed; + } + + public override void StartOpen() + { + HTTPManager.Logger.Verbose("OverHTTP2", "StartOpen", this.Parent.Context); + + if (this.Parent.Extensions != null) + { + try + { + for (int i = 0; i < this.Parent.Extensions.Length; ++i) + { + var ext = this.Parent.Extensions[i]; + if (ext != null) + ext.AddNegotiation(base.InternalRequest); + } + } + catch (Exception ex) + { + HTTPManager.Logger.Exception("OverHTTP2", "Open", ex, this.Parent.Context); + } + } + + base.InternalRequest.Send(); + HTTPManager.Heartbeats.Subscribe(this); + + this.State = WebSocketStates.Connecting; + } + + public override void StartClose(WebSocketStatusCodes code, string message) + { + HTTPManager.Logger.Verbose("OverHTTP2", "StartClose", this.Parent.Context); + + if (this.State == WebSocketStates.Connecting) + { + if (this.InternalRequest != null) + this.InternalRequest.Abort(); + + this.State = WebSocketStates.Closed; + if (this.Parent.OnClosed != null) + this.Parent.OnClosed(this.Parent, WebSocketStatusCodes.NormalClosure, message); + } + else + { + Send(new WebSocketFrame(this.Parent, WebSocketFrameTypes.ConnectionClose, WebSocket.EncodeCloseData(code, message), false)); + this.State = WebSocketStates.Closing; + } + } + + public override void Send(string message) + { + if (message == null) + throw new ArgumentNullException("message must not be null!"); + + int count = System.Text.Encoding.UTF8.GetByteCount(message); + byte[] data = BufferPool.Get(count, true); + System.Text.Encoding.UTF8.GetBytes(message, 0, message.Length, data, 0); + + SendAsText(data.AsBuffer(0, count)); + } + + public override void Send(byte[] buffer) + { + if (buffer == null) + throw new ArgumentNullException("data must not be null!"); + + Send(new WebSocketFrame(this.Parent, WebSocketFrameTypes.Binary, new BufferSegment(buffer, 0, buffer.Length))); + } + + public override void Send(byte[] data, ulong offset, ulong count) + { + if (data == null) + throw new ArgumentNullException("data must not be null!"); + if (offset + count > (ulong)data.Length) + throw new ArgumentOutOfRangeException("offset + count >= data.Length"); + + Send(new WebSocketFrame(this.Parent, WebSocketFrameTypes.Binary, new BufferSegment(data, (int)offset, (int)count), true)); + } + + public override void Send(WebSocketFrame frame) + { + if (this.State == WebSocketStates.Closed || _closeSent) + return; + + this.frames.Enqueue(frame); + this.threadSignaler.SignalThread(); + + Interlocked.Add(ref base._bufferedAmount, frame.Data.Count); + + if (frame.Type == WebSocketFrameTypes.ConnectionClose) + this._closeSent = true; + } + + public override void SendAsBinary(BufferSegment data) + { + Send(WebSocketFrameTypes.Binary, data); + } + + public override void SendAsText(BufferSegment data) + { + Send(WebSocketFrameTypes.Text, data); + } + + private void Send(WebSocketFrameTypes type, BufferSegment data) + { + Send(new WebSocketFrame(this.Parent, type, data, false)); + } + + internal void PreReadCallback() + { + if (this.Parent.SendPings) + { + DateTime now = DateTime.UtcNow; + + if (!waitingForPong && now - LastMessageReceived >= this.Parent.PingFrequency) + SendPing(); + + if (waitingForPong && now - lastPing > this.Parent.CloseAfterNoMessage) + { + if (this.State != WebSocketStates.Closed) + { + HTTPManager.Logger.Warning("OverHTTP2", + string.Format("No message received in the given time! Closing WebSocket. LastPing: {0}, PingFrequency: {1}, Close After: {2}, Now: {3}", + this.lastPing, this.Parent.PingFrequency, this.Parent.CloseAfterNoMessage, now), this.Parent.Context); + + CloseWithError("No message received in the given time!"); + } + } + } + } + + public void OnHeartbeatUpdate(DateTime now, TimeSpan dif) + { + switch (this.State) + { + case WebSocketStates.Connecting: + if (now - this.InternalRequest.Timing.Created >= this.Parent.CloseAfterNoMessage) + { + if (HTTPManager.PerHostSettings.Get(this.Uri).HTTP2ConnectionSettings.WebSocketOverHTTP2Settings.EnableImplementationFallback) + { + this.State = WebSocketStates.Closed; + this.InternalRequest.DownloadSettings.OnHeadersReceived = null; + this.InternalRequest.Callback = null; + this.Parent.FallbackToHTTP1(); + + HTTPManager.Heartbeats.Unsubscribe(this); + } + else + { + CloseWithError("WebSocket Over HTTP/2 Implementation failed to connect in the given time!"); + } + } + break; + + default: + while (CompletedFrames.TryDequeue(out var frame)) + { + // Bugs in the clients shouldn't interrupt the code, so we need to try-catch and ignore any exception occurring here + try + { + switch (frame.Type) + { + case WebSocketFrameTypes.Continuation: + if (HTTPManager.Logger.Level == Loglevels.All) + HTTPManager.Logger.Verbose("OverHTTP2", "HandleEvents - OnIncompleteFrame", this.Parent.Context); + break; + + case WebSocketFrameTypes.Text: + // Any not Final frame is handled as a fragment + if (!frame.IsFinal) + goto case WebSocketFrameTypes.Continuation; + + if (HTTPManager.Logger.Level == Loglevels.All) + HTTPManager.Logger.Verbose("OverHTTP2", $"HandleEvents - OnText(\"{frame.DataAsText}\")", this.Parent.Context); + + if (this.Parent.OnMessage != null) + this.Parent.OnMessage(this.Parent, frame.DataAsText); + break; + + case WebSocketFrameTypes.Binary: + // Any not Final frame is handled as a fragment + if (!frame.IsFinal) + goto case WebSocketFrameTypes.Continuation; + + if (HTTPManager.Logger.Level == Loglevels.All) + HTTPManager.Logger.Verbose("OverHTTP2", $"HandleEvents - OnBinary({frame.Data})", this.Parent.Context); + + if (this.Parent.OnBinary != null) + this.Parent.OnBinary(this.Parent, frame.Data); + break; + + case WebSocketFrameTypes.ConnectionClose: + HTTPManager.Logger.Verbose("OverHTTP2", "HandleEvents - Calling OnClosed", this.Parent.Context); + if (this.Parent.OnClosed != null) + { + try + { + UInt16 statusCode = 0; + string msg = string.Empty; + + // If we received any data, we will get the status code and the message from it + if (/*CloseFrame != null && */ frame.Data != BufferSegment.Empty && frame.Data.Count >= 2) + { + if (BitConverter.IsLittleEndian) + Array.Reverse(frame.Data.Data, frame.Data.Offset, 2); + statusCode = BitConverter.ToUInt16(frame.Data.Data, frame.Data.Offset); + + if (frame.Data.Count > 2) + msg = Encoding.UTF8.GetString(frame.Data.Data, frame.Data.Offset + 2, frame.Data.Count - 2); + + frame.ReleaseData(); + } + + this.Parent.OnClosed(this.Parent, (WebSocketStatusCodes)statusCode, msg); + this.Parent.OnClosed = null; + } + catch (Exception ex) + { + HTTPManager.Logger.Exception("OverHTTP2", "HandleEvents - OnClosed", ex, this.Parent.Context); + } + } + + HTTPManager.Heartbeats.Unsubscribe(this); + break; + } + } + catch (Exception ex) + { + HTTPManager.Logger.Exception("OverHTTP2", string.Format("HandleEvents({0})", frame.ToString()), ex, this.Parent.Context); + } + finally + { + frame.ReleaseData(); + } + } + break; + } + } + + /// + /// Next interaction relative to *now*. + /// + public TimeSpan GetNextInteraction() + { + if (waitingForPong) + return TimeSpan.MaxValue; + + return (LastMessageReceived + this.Parent.PingFrequency) - DateTime.UtcNow; + } + + private void SendPing() + { + HTTPManager.Logger.Information("OverHTTP2", "Sending Ping frame, waiting for a pong...", this.Parent.Context); + + lastPing = DateTime.UtcNow; + waitingForPong = true; + + Send(new WebSocketFrame(this.Parent, WebSocketFrameTypes.Ping, BufferSegment.Empty)); + } + + private void CloseWithError(string message) + { + HTTPManager.Logger.Verbose("OverHTTP2", $"CloseWithError(\"{message}\")", this.Parent.Context); + + this.State = WebSocketStates.Closed; + + if (this.Parent.OnClosed != null) + { + try + { + this.Parent.OnClosed(this.Parent, WebSocketStatusCodes.ClosedAbnormally, message); + } + catch (Exception ex) + { + HTTPManager.Logger.Exception("OverHTTP2", "CloseWithError", ex, this.Parent.Context); + } + } + + this.InternalRequest.Abort(); + + HTTPManager.Heartbeats.Unsubscribe(this); + } + + internal bool TryDequeueFrame(out WebSocketFrame frame) + { + if (base.frames.TryDequeue(out frame)) + { + Interlocked.Add(ref base._bufferedAmount, frame.Data.Count); + return true; + } + + return false; + } + } +} +#endif diff --git a/Runtime/Implementations/OverHTTP2.cs.meta b/Runtime/Implementations/OverHTTP2.cs.meta new file mode 100644 index 0000000..da7d000 --- /dev/null +++ b/Runtime/Implementations/OverHTTP2.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 4ad5000fcffa4a444a7f317f3f589200 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 268757 + packageName: Best WebSockets + packageVersion: 3.0.7 + assetPath: Packages/com.tivadar.best.websockets/Runtime/Implementations/OverHTTP2.cs + uploadId: 737284 diff --git a/Runtime/Implementations/Utils.meta b/Runtime/Implementations/Utils.meta new file mode 100644 index 0000000..dd4af48 --- /dev/null +++ b/Runtime/Implementations/Utils.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8c9d3df5d26747e4e933e4a7d69a9b4b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Implementations/Utils/LockedBufferSegmenStream.cs b/Runtime/Implementations/Utils/LockedBufferSegmenStream.cs new file mode 100644 index 0000000..5c63d15 --- /dev/null +++ b/Runtime/Implementations/Utils/LockedBufferSegmenStream.cs @@ -0,0 +1,56 @@ +#if (!UNITY_WEBGL || UNITY_EDITOR) && !BESTHTTP_DISABLE_ALTERNATE_SSL +using Best.HTTP.Shared.PlatformSupport.Memory; +using Best.HTTP.Shared.Streams; + +namespace Best.WebSockets.Implementations.Utils +{ + public sealed class LockedBufferSegmenStream : BufferSegmentStream + { + public bool IsClosed { get; private set; } + + public override int Read(byte[] buffer, int offset, int count) + { + lock (base.bufferList) + { + if (this.IsClosed && base.bufferList.Count == 0) + return 0; + + int sumReadCount = base.Read(buffer, offset, count); + + return sumReadCount == 0 ? -1 : sumReadCount; + } + } + + public override void Write(BufferSegment bufferSegment) + { + lock (base.bufferList) + { + if (this.IsClosed) + return; + + base.Write(bufferSegment); + } + } + + public override void Reset() + { + lock (base.bufferList) + { + base.Reset(); + } + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + Reset(); + } + + public override void Close() + { + this.IsClosed = true; + } + } +} +#endif diff --git a/Runtime/Implementations/Utils/LockedBufferSegmenStream.cs.meta b/Runtime/Implementations/Utils/LockedBufferSegmenStream.cs.meta new file mode 100644 index 0000000..ab5dc5b --- /dev/null +++ b/Runtime/Implementations/Utils/LockedBufferSegmenStream.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: bc8a4ee1efb2f884695f2ec1339025e4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 268757 + packageName: Best WebSockets + packageVersion: 3.0.7 + assetPath: Packages/com.tivadar.best.websockets/Runtime/Implementations/Utils/LockedBufferSegmenStream.cs + uploadId: 737284 diff --git a/Runtime/Implementations/WebGLBrowser.cs b/Runtime/Implementations/WebGLBrowser.cs new file mode 100644 index 0000000..b3ae552 --- /dev/null +++ b/Runtime/Implementations/WebGLBrowser.cs @@ -0,0 +1,283 @@ +#if UNITY_WEBGL && !UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +using Best.HTTP.Shared; +using Best.HTTP.Shared.PlatformSupport.Memory; + +namespace Best.WebSockets.Implementations +{ + delegate void OnWebGLWebSocketOpenDelegate(uint id); + delegate void OnWebGLWebSocketTextDelegate(uint id, [MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.U1, SizeParamIndex = 2)] byte[] textBuffer, int allocatedLength, int length); + delegate void OnWebGLWebSocketBinaryDelegate(uint id, [MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.U1, SizeParamIndex = 2)] byte[] buffer, int allocatedLength, int length); + delegate void OnWebGLWebSocketErrorDelegate(uint id, string error); + delegate void OnWebGLWebSocketCloseDelegate(uint id, int code, string reason); + delegate IntPtr OnWebGLAllocArray(int nativeId, int size); + internal sealed class WebGLBrowser : WebSocketBaseImplementation + { + public override WebSocketStates State => ImplementationId != 0 ? WS_GetState(ImplementationId) : WebSocketStates.Unknown; + + public override bool IsOpen => ImplementationId != 0 && WS_GetState(ImplementationId) == WebSocketStates.Open; + public override int BufferedAmount => WS_GetBufferedAmount(ImplementationId); + + internal static Dictionary WebSockets = new Dictionary(); + + private uint ImplementationId; + + public WebGLBrowser(WebSocket parent, Uri uri, string origin, string protocol) : base(parent, uri, origin, protocol) + { + } + + public override void StartOpen() + { + try + { + ImplementationId = WS_Create(this.Uri.OriginalString, this.Protocol, OnOpenCallback, OnTextCallback, OnBinaryCallback, OnErrorCallback, OnCloseCallback, Allocator); + WebSockets.Add(ImplementationId, this.Parent); + } + catch(Exception ex) + { + HTTPManager.Logger.Exception("WebSocket", "Open", ex, this.Parent.Context); + } + } + + public override void StartClose(WebSocketStatusCodes code, string message) + { + WS_Close(this.ImplementationId, (ushort)code, message); + } + + public override void Send(string message) + { + var count = System.Text.Encoding.UTF8.GetByteCount(message); + var buffer = BufferPool.Get(count, true); + + System.Text.Encoding.UTF8.GetBytes(message, 0, message.Length, buffer, 0); + + WS_Send_String(this.ImplementationId, buffer, 0, count); + + BufferPool.Release(buffer); + } + + public override void Send(byte[] buffer) + { + WS_Send_Binary(this.ImplementationId, buffer, 0, buffer.Length); + } + + public override void Send(byte[] buffer, ulong offset, ulong count) + { + WS_Send_Binary(this.ImplementationId, buffer, (int)offset, (int)count); + } + + public override void SendAsBinary(BufferSegment data) + { + WS_Send_Binary(this.ImplementationId, data.Data, data.Offset, data.Count); + BufferPool.Release(data); + } + + public override void SendAsText(BufferSegment data) + { + WS_Send_String(this.ImplementationId, data.Data, data.Offset, data.Count); + BufferPool.Release(data); + } + + [DllImport("__Internal")] + static extern uint WS_Create(string url, + string protocol, + OnWebGLWebSocketOpenDelegate onOpen, + OnWebGLWebSocketTextDelegate onText, + OnWebGLWebSocketBinaryDelegate onBinary, + OnWebGLWebSocketErrorDelegate onError, + OnWebGLWebSocketCloseDelegate onClose, + OnWebGLAllocArray allocator); + + [DllImport("__Internal")] + static extern WebSocketStates WS_GetState(uint id); + + [DllImport("__Internal")] + static extern int WS_GetBufferedAmount(uint id); + + [DllImport("__Internal")] + static extern int WS_Send_String(uint id, byte[] strData, int pos, int length); + + [DllImport("__Internal")] + static extern int WS_Send_Binary(uint id, byte[] buffer, int pos, int length); + + [DllImport("__Internal")] + static extern void WS_Close(uint id, ushort code, string reason); + + [DllImport("__Internal")] + static extern void WS_Release(uint id); + + [AOT.MonoPInvokeCallback(typeof(OnWebGLAllocArray))] + static unsafe IntPtr Allocator(int nativeId, int length) + { + byte[] buffer = BufferPool.Get(length, true); + + if (HTTPManager.Logger.IsDiagnostic) + HTTPManager.Logger.Verbose(nameof(WebGLBrowser), $"Allocator - allocated: {buffer.Length}"); + + buffer[0] = (byte)(buffer.Length >> 24); + buffer[1] = (byte)(buffer.Length >> 16); + buffer[2] = (byte)(buffer.Length >> 8); + buffer[3] = (byte)(buffer.Length); + + fixed (byte* ptr = buffer) + { + var p = (IntPtr)ptr; + + if (HTTPManager.Logger.IsDiagnostic) + HTTPManager.Logger.Verbose(nameof(WebGLBrowser), $"({p}) <= Allocator({nativeId}, {length})"); + + return p; + } + } + + [AOT.MonoPInvokeCallback(typeof(OnWebGLWebSocketOpenDelegate))] + static void OnOpenCallback(uint id) + { + WebSocket ws; + if (WebSockets.TryGetValue(id, out ws)) + { + if (ws.OnOpen != null) + { + try + { + ws.OnOpen(ws); + } + catch(Exception ex) + { + HTTPManager.Logger.Exception("WebSocket", "OnOpen", ex, ws.Context); + } + } + } + else + HTTPManager.Logger.Warning("WebSocket", "OnOpenCallback - No WebSocket found for id: " + id.ToString(), ws.Context); + } + + [AOT.MonoPInvokeCallback(typeof(OnWebGLWebSocketTextDelegate))] + static void OnTextCallback(uint id, [MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.U1, SizeParamIndex = 2)] byte[] textBuffer, int allocatedLength, int length) + { + try + { + WebSocket ws; + if (WebSockets.TryGetValue(id, out ws)) + { + if (ws.OnMessage != null) + { + try + { + var text = System.Text.Encoding.UTF8.GetString(textBuffer, 0, length); + + if (HTTPManager.Logger.IsDiagnostic) + HTTPManager.Logger.Verbose(nameof(WebGLBrowser), $"{id}, {textBuffer}, {length} => {text}", ws.Context); + ws.OnMessage(ws, text); + } + catch (Exception ex) + { + HTTPManager.Logger.Exception("WebSocket", "OnMessage", ex, ws.Context); + } + } + } + else + HTTPManager.Logger.Warning("WebSocket", "OnTextCallback - No WebSocket found for id: " + id.ToString()); + } + finally + { + BufferPool.Release(textBuffer); + } + } + + [AOT.MonoPInvokeCallback(typeof(OnWebGLWebSocketBinaryDelegate))] + static void OnBinaryCallback(uint id, [MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.U1, SizeParamIndex = 2)] byte[] buffer, int allocatedLength, int length) + { + WebSocket ws; + if (WebSockets.TryGetValue(id, out ws)) + { + if (ws.OnBinary != null) + { + try + { + ws.OnBinary(ws, new BufferSegment(buffer, 0, length)); + + BufferPool.Release(buffer); + } + catch (Exception ex) + { + HTTPManager.Logger.Exception("WebSocket", "OnBinary", ex, ws.Context); + } + } + } + else + HTTPManager.Logger.Warning("WebSocket", "OnBinaryCallback - No WebSocket found for id: " + id.ToString()); + } + + [AOT.MonoPInvokeCallback(typeof(OnWebGLWebSocketErrorDelegate))] + static void OnErrorCallback(uint id, string error) + { + WebSocket ws; + if (WebSockets.TryGetValue(id, out ws)) + { + WebSockets.Remove(id); + + if (ws.OnClosed != null) + { + try + { + ws.OnClosed(ws, WebSocketStatusCodes.ClosedAbnormally, error); + } + catch (Exception ex) + { + HTTPManager.Logger.Exception("WebSocket", "OnError", ex, ws.Context); + } + } + } + else + HTTPManager.Logger.Warning("WebSocket", "OnErrorCallback - No WebSocket found for id: " + id.ToString()); + + try + { + WS_Release(id); + } + catch(Exception ex) + { + HTTPManager.Logger.Exception("WebSocket", "WS_Release", ex); + } + } + + [AOT.MonoPInvokeCallback(typeof(OnWebGLWebSocketCloseDelegate))] + static void OnCloseCallback(uint id, int code, string reason) + { + WebSocket ws; + if (WebSockets.TryGetValue(id, out ws)) + { + WebSockets.Remove(id); + + if (ws.OnClosed != null) + { + try + { + ws.OnClosed(ws, (WebSocketStatusCodes)code, reason); + } + catch (Exception ex) + { + HTTPManager.Logger.Exception("WebSocket", "OnClosed", ex, ws.Context); + } + } + } + else + HTTPManager.Logger.Warning("WebSocket", "OnCloseCallback - No WebSocket found for id: " + id.ToString()); + + try + { + WS_Release(id); + } + catch(Exception ex) + { + HTTPManager.Logger.Exception("WebSocket", "WS_Release", ex); + } + } + } +} + +#endif diff --git a/Runtime/Implementations/WebGLBrowser.cs.meta b/Runtime/Implementations/WebGLBrowser.cs.meta new file mode 100644 index 0000000..8d7e500 --- /dev/null +++ b/Runtime/Implementations/WebGLBrowser.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 00814e591b0212b4ca8a4ae3c6cea571 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 268757 + packageName: Best WebSockets + packageVersion: 3.0.7 + assetPath: Packages/com.tivadar.best.websockets/Runtime/Implementations/WebGLBrowser.cs + uploadId: 737284 diff --git a/Runtime/Implementations/WebSocketBaseImplementation.cs b/Runtime/Implementations/WebSocketBaseImplementation.cs new file mode 100644 index 0000000..6be780e --- /dev/null +++ b/Runtime/Implementations/WebSocketBaseImplementation.cs @@ -0,0 +1,262 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +using Best.HTTP.Shared; +using Best.HTTP.Shared.Extensions; +using Best.HTTP.Shared.PlatformSupport.Memory; +using Best.HTTP.Shared.Streams; + +#if !UNITY_WEBGL || UNITY_EDITOR +using Best.WebSockets.Implementations.Frames; +#endif + +namespace Best.WebSockets.Implementations +{ + /// + /// States of the underlying implementation's state. + /// + public enum WebSocketStates : byte + { + Connecting = 0, + Open = 1, + Closing = 2, + Closed = 3, + Unknown + }; + + public delegate void OnWebSocketOpenDelegate(WebSocket webSocket); + public delegate void OnWebSocketMessageDelegate(WebSocket webSocket, string message); + public delegate void OnWebSocketBinaryNoAllocDelegate(WebSocket webSocket, BufferSegment data); + public delegate void OnWebSocketClosedDelegate(WebSocket webSocket, WebSocketStatusCodes code, string message); + +#if !UNITY_WEBGL || UNITY_EDITOR + public delegate void OnWebSocketIncompleteFrameDelegate(WebSocket webSocket, WebSocketFrameReader frame); +#endif + + /// + /// Abstract class for concrete websocket communication implementations. + /// + public abstract class WebSocketBaseImplementation + { + /// + /// Capacity of the RTT buffer where the latencies are kept. + /// + public static int RTTBufferCapacity = 5; + + public const string Timing_Name = "Websocket"; + + public virtual WebSocketStates State { get; protected set; } + +#if UNITY_WEBGL && !UNITY_EDITOR + public virtual bool IsOpen { get; protected set; } + + public virtual int BufferedAmount { get; protected set; } +#else + public bool IsOpen => this.State == WebSocketStates.Open; + + public int BufferedAmount { get => this._bufferedAmount; } + protected volatile int _bufferedAmount; + + public HTTP.HTTPRequest InternalRequest + { + get + { + if (this._internalRequest == null) + CreateInternalRequest(); + + return this._internalRequest; + } + } + protected HTTP.HTTPRequest _internalRequest; + + public virtual int Latency { get; protected set; } + public virtual DateTime LastMessageReceived { get; protected set; } + + /// + /// A circular buffer to store the last N rtt times calculated by the pong messages. + /// + protected CircularBuffer rtts = new CircularBuffer(WebSocketBaseImplementation.RTTBufferCapacity); + + /// + /// When we sent out the last ping. + /// + protected DateTime lastPing = DateTime.MinValue; + + protected bool waitingForPong = false; + + protected List IncompleteFrames = new List(); + protected PeekableIncomingSegmentStream incomingSegmentStream = new PeekableIncomingSegmentStream(); + protected ConcurrentQueue CompletedFrames = new ConcurrentQueue(); + protected ConcurrentQueue frames = new ConcurrentQueue(); + + /// + /// True if we sent out a Close message to the server + /// + internal volatile bool _closeSent; + internal volatile bool _closeReceived; +#endif + + public WebSocket Parent { get; } + public Uri Uri { get; protected set; } + public string Origin { get; } + public string Protocol { get; } + + public WebSocketBaseImplementation(WebSocket parent, Uri uri, string origin, string protocol) + { + this.Parent = parent; + this.Uri = uri; + this.Origin = origin; + this.Protocol = protocol; + +#if !UNITY_WEBGL || UNITY_EDITOR + this.LastMessageReceived = DateTime.MinValue; + + // Set up some default values. + this.Parent.PingFrequency = TimeSpan.FromMilliseconds(10_000); + this.Parent.CloseAfterNoMessage = TimeSpan.FromSeconds(2); +#endif + } + + public abstract void StartOpen(); + public abstract void StartClose(WebSocketStatusCodes code, string message); + + public abstract void Send(string message); + public abstract void Send(byte[] buffer); + public abstract void Send(byte[] buffer, ulong offset, ulong count); + public abstract void SendAsBinary(BufferSegment data); + public abstract void SendAsText(BufferSegment data); + +#if !UNITY_WEBGL || UNITY_EDITOR + + protected void ParseExtensionResponse(HTTP.HTTPResponse resp) + { + if (this.Parent.Extensions != null) + { + for (int i = 0; i < this.Parent.Extensions.Length; ++i) + { + var ext = this.Parent.Extensions[i]; + + try + { + if (ext != null && !ext.ParseNegotiation(resp)) + this.Parent.Extensions[i] = null; // Keep extensions only that successfully negotiated + } + catch (Exception ex) + { + HTTPManager.Logger.Exception("WebSocketBaseImplementation", "ParseNegotiation", ex, this.Parent.Context); + + // Do not try to use a defective extension in the future + this.Parent.Extensions[i] = null; + } + } + } + } + + protected abstract void CreateInternalRequest(); + + /// + /// It will send the given frame to the server. + /// + public abstract void Send(WebSocketFrame frame); + + protected virtual void Cleanup() + { + for (int i = 0; i < this.IncompleteFrames.Count; ++i) + { + var frame = this.IncompleteFrames[i]; + BufferPool.Release(frame.Data); + } + this.IncompleteFrames.Clear(); + this.Parent.DisposeExtensions(); + } + + protected int CalculateLatency() + { + if (this.rtts.Count == 0) + return 0; + + int sumLatency = 0; + for (int i = 0; i < this.rtts.Count; ++i) + sumLatency += this.rtts[i]; + + return sumLatency / this.rtts.Count; + } + + public static bool CanReadFullFrame(PeekableStream stream) + { + if (stream.Length < 2) + return false; + + stream.BeginPeek(); + + int headerLength = 2; + + int header = stream.PeekByte(); + if (header == -1) + return false; + + int maskAndLength = stream.PeekByte(); + if (maskAndLength == -1) + return false; + + // The second byte is the Mask Bit and the length of the payload data + var HasMask = (maskAndLength & 0x80) != 0; + if (HasMask) + throw new NotSupportedException("Server-sent frames must not be masked!"); + + // if 0-125, that is the payload length. + int payloadLength = (int)(maskAndLength & 127); + + // If 126, the following 2 bytes interpreted as a 16-bit unsigned integer are the payload length. + if (payloadLength == 126) + { + byte[] rawLen = BufferPool.Get(2, true); + + for (int i = 0; i < 2; i++) + { + int data = stream.PeekByte(); + if (data < 0) + return false; + + rawLen[i] = (byte)data; + } + + if (BitConverter.IsLittleEndian) + Array.Reverse(rawLen, 0, 2); + + payloadLength = (int)BitConverter.ToUInt16(rawLen, 0); + headerLength += 2; + + BufferPool.Release(rawLen); + } + else if (payloadLength == 127) + { + // If 127, the following 8 bytes interpreted as a 64-bit unsigned integer (the + // most significant bit MUST be 0) are the payload length. + + byte[] rawLen = BufferPool.Get(8, true); + + for (int i = 0; i < 8; i++) + { + int data = stream.PeekByte(); + if (data < 0) + return false; + + rawLen[i] = (byte)data; + } + + if (BitConverter.IsLittleEndian) + Array.Reverse(rawLen, 0, 8); + + payloadLength = (int)BitConverter.ToUInt64(rawLen, 0); + headerLength += 8; + + BufferPool.Release(rawLen); + } + + return stream.Length >= (headerLength + payloadLength); + } +#endif + } +} diff --git a/Runtime/Implementations/WebSocketBaseImplementation.cs.meta b/Runtime/Implementations/WebSocketBaseImplementation.cs.meta new file mode 100644 index 0000000..fb31d3a --- /dev/null +++ b/Runtime/Implementations/WebSocketBaseImplementation.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: c1a975643d2b82840a918d261fe4e8f9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 268757 + packageName: Best WebSockets + packageVersion: 3.0.7 + assetPath: Packages/com.tivadar.best.websockets/Runtime/Implementations/WebSocketBaseImplementation.cs + uploadId: 737284 diff --git a/Runtime/Plugins.meta b/Runtime/Plugins.meta new file mode 100644 index 0000000..df6db42 --- /dev/null +++ b/Runtime/Plugins.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0b3eb1b987a1d894bbb4b5c350a83237 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Plugins/WebGL.meta b/Runtime/Plugins/WebGL.meta new file mode 100644 index 0000000..337dd7d --- /dev/null +++ b/Runtime/Plugins/WebGL.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2b34941212eaeb44cba28c82ac07d752 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Plugins/WebGL/BestHTTP_WebSocket.jslib b/Runtime/Plugins/WebGL/BestHTTP_WebSocket.jslib new file mode 100644 index 0000000..0d3ce02 --- /dev/null +++ b/Runtime/Plugins/WebGL/BestHTTP_WebSocket.jslib @@ -0,0 +1,191 @@ +// https://docs.unity3d.com/6000.0/Documentation/Manual/web-interacting-browser-deprecated.html +// Deprecated code | Replacement code +// -----------------+----------------- +// dynCall() | makeDynCall() + +var Lib_BEST_HTTP_WebGL_WS_Bridge = +{ + $ws: { + webSocketInstances: {}, + nextInstanceId : 1, + /*UTF8Decoder: new TextDecoder('utf8'),*/ + + Set : function(socket) { + ws.webSocketInstances[ws.nextInstanceId] = socket; + return ws.nextInstanceId++; + }, + + Get : function(id) { + return ws.webSocketInstances[id]; + }, + + Remove: function(id) { + delete ws.webSocketInstances[id]; + }, + + _callOnClose: function(onClose, id, code, reason) + { + var length = lengthBytesUTF8(reason) + 1; + var buffer = _malloc(length); + + stringToUTF8Array(reason, HEAPU8, buffer, length); + + {{{ makeDynCall('viii', 'onClose') }}}(id, code, buffer); + + _free(buffer); + }, + + _callOnError: function(errCallback, id, reason) + { + var length = lengthBytesUTF8(reason) + 1; + var buffer = _malloc(length); + + stringToUTF8Array(reason, HEAPU8, buffer, length); + + {{{ makeDynCall('vii', 'errCallback') }}}(id, buffer); + + _free(buffer); + } + }, + + WS_Create: function(url, protocol, onOpen, onText, onBinary, onError, onClose, allocator) + { + var urlStr = new URL(UTF8ToString(url)); ///*encodeURI*/(UTF8ToString(url)).replace(/\+/g, '%2B').replace(/%252[fF]/ig, '%2F'); + var proto = UTF8ToString(protocol); + + console.log('WS_Create(' + urlStr + ', "' + proto + '")'); + + var socket = { + onError: onError, + onClose: onClose + }; + + if (proto == '') + socket.socketImpl = new WebSocket(urlStr); + else + socket.socketImpl = new WebSocket(urlStr, proto.trim().split(",")); + + var id = ws.nextInstanceId; + socket.socketImpl.binaryType = "arraybuffer"; + + socket.socketImpl.onopen = function(e) { + console.log(id + ' WS_Create - onOpen'); + + {{{ makeDynCall('vi', 'onOpen') }}}(id); + }; + + const encoder = new TextEncoder(); + socket.socketImpl.onmessage = function (e) + { + // Binary? + if (e.data instanceof ArrayBuffer) + { + var byteArray = new Uint8Array(e.data); + const array = {{{ makeDynCall('iii', 'allocator') }}}(id, byteArray.length); + + const numArr = HEAPU8.subarray(array, array + 4); + const originalLength = (numArr[0] << 24) | (numArr[1] << 16) | (numArr[2] << 8) | (numArr[3]); + HEAPU8.set(byteArray, array); + + {{{ makeDynCall('viiii', 'onBinary') }}}(id, array, originalLength, byteArray.length); + } + else // Text + { + // https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder/encodeInto#buffer_sizing + // "If the output allocation (typically within Wasm heap) is expected to be short-lived, it makes sense to allocate s.length * 3 bytes for the output, + // in which case the first conversion attempt is guaranteed to convert the whole string." + const length = e.data.length * 3; + const array = {{{ makeDynCall('iii', 'allocator') }}}(id, length); + + const numArr = HEAPU8.subarray(array, array + 4); + const originalLength = (numArr[0] << 24) | (numArr[1] << 16) | (numArr[2] << 8) | (numArr[3]); + const ret = encoder.encodeInto(e.data, HEAPU8.subarray(array)); + + {{{ makeDynCall('viiii', 'onText') }}}(id, array, originalLength, ret.written); + } + }; + socket.socketImpl.onclose = function (e) { + console.log(id + ' WS_Create - onClose ' + e.code + ' ' + e.reason); + + ws._callOnClose(onClose, id, e.code, e.reason); + }; + + return ws.Set(socket); + }, + + WS_GetState: function (id) + { + var socket = ws.Get(id); + + if (typeof socket === 'undefined' || + socket == null || + typeof socket.socketImpl === 'undefined' || + socket.socketImpl == null) + return 3; // closed + + return socket.socketImpl.readyState; + }, + + WS_GetBufferedAmount: function (id) + { + var socket = ws.Get(id); + return socket.socketImpl.bufferedAmount; + }, + + WS_Send_String: function (id, ptr, pos, length) + { + var socket = ws.Get(id); + + var startPtr = ptr + pos; + var endPtr = startPtr + length; + + var UTF8Decoder = new TextDecoder('utf8'); + var str = UTF8Decoder.decode(HEAPU8.subarray ? HEAPU8.subarray(startPtr, endPtr) : new Uint8Array(HEAPU8.slice(startPtr, endPtr))); + + try + { + socket.socketImpl.send(str); + } + catch(e) { + ws._callOnError(socket.onError, id, ' ' + e.name + ': ' + e.message); + } + + return socket.socketImpl.bufferedAmount; + }, + + WS_Send_Binary: function(id, ptr, pos, length) + { + var socket = ws.Get(id); + + try + { + var buff = HEAPU8.subarray(ptr + pos, ptr + pos + length); + socket.socketImpl.send(buff /*HEAPU8.buffer.slice(ptr + pos, ptr + pos + length)*/); + } + catch(e) { + ws._callOnError(socket.onError, id, ' ' + e.name + ': ' + e.message); + } + + return socket.socketImpl.bufferedAmount; + }, + + WS_Close: function (id, code, reason) + { + var socket = ws.Get(id); + var reasonStr = UTF8ToString(reason); + + console.log(id + ' WS_Close(' + code + ', ' + reasonStr + ')'); + + socket.socketImpl.close(/*ulong*/code, reasonStr); + }, + + WS_Release: function(id) + { + console.log(id + ' WS_Release'); + + ws.Remove(id); + } +}; + +autoAddDeps(Lib_BEST_HTTP_WebGL_WS_Bridge, '$ws'); +mergeInto(LibraryManager.library, Lib_BEST_HTTP_WebGL_WS_Bridge); diff --git a/Runtime/Plugins/WebGL/BestHTTP_WebSocket.jslib.meta b/Runtime/Plugins/WebGL/BestHTTP_WebSocket.jslib.meta new file mode 100644 index 0000000..dc422fa --- /dev/null +++ b/Runtime/Plugins/WebGL/BestHTTP_WebSocket.jslib.meta @@ -0,0 +1,39 @@ +fileFormatVersion: 2 +guid: 8efe6cedf2832a647a33465b85e4f311 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + WebGL: WebGL + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 268757 + packageName: Best WebSockets + packageVersion: 3.0.7 + assetPath: Packages/com.tivadar.best.websockets/Runtime/Plugins/WebGL/BestHTTP_WebSocket.jslib + uploadId: 737284 diff --git a/Runtime/WebSocket.cs b/Runtime/WebSocket.cs new file mode 100644 index 0000000..738fb3c --- /dev/null +++ b/Runtime/WebSocket.cs @@ -0,0 +1,427 @@ +using System; +using System.Text; + +using Best.HTTP.Shared.Extensions; +using Best.HTTP.Shared.Logger; +using Best.HTTP.Shared.PlatformSupport.Memory; +using Best.HTTP.Shared.Streams; +using Best.HTTP.HostSetting; +using Best.HTTP.Request.Settings; +using Best.HTTP.Shared; +using Best.HTTP.Hosts.Connections; +using Best.WebSockets.Implementations; + +#if (!UNITY_WEBGL || UNITY_EDITOR) && !BESTHTTP_DISABLE_ALTERNATE_SSL +using Best.HTTP.Hosts.Connections.HTTP2; +#endif + +#if !UNITY_WEBGL || UNITY_EDITOR +using Best.WebSockets.Extensions; +#endif + +using Best.HTTP.Shared.Compression.Zlib; + +/* + * This is a wrapper class organizing where and how the websocket request is sent out. If there's an already open HTTP/2 connection with + * an ENABLE_CONNECT_PROTOCOL flag, it tries using the HTTP/2 connection first and if it fails, a new HTTP/1 connection is created. + * */ + +namespace Best.WebSockets +{ + /// + /// Implements the WebSocket standard for duplex, two-way communications. + /// + public sealed class WebSocket + { + /// + /// Maximum payload size of a websocket frame. Its default value is 32 KiB. + /// + public static uint MaxFragmentSize = UInt16.MaxValue / 2; + +#if !UNITY_WEBGL || UNITY_EDITOR + public static IExtension[] GetDefaultExtensions() + { + return new IExtension[] { new PerMessageCompression(/*compression level: */ CompressionLevel.Default, + /*clientNoContextTakeover: */ false, + /*serverNoContextTakeover: */ false, + /*clientMaxWindowBits: */ ZlibConstants.WindowBitsMax, + /*desiredServerMaxWindowBits: */ ZlibConstants.WindowBitsMax, + /*minDatalengthToCompress: */ PerMessageCompression.MinDataLengthToCompressDefault) }; + } +#endif + + public WebSocketStates State { get { return this.implementation.State; } } + + /// + /// The connection to the WebSocket server is open. + /// + public bool IsOpen { get { return this.implementation.IsOpen; } } + + /// + /// Data waiting to be written to the wire. + /// + public int BufferedAmount { get { return this.implementation.BufferedAmount; } } + +#if !UNITY_WEBGL || UNITY_EDITOR + + /// + /// Set to true to start sending Ping frames to the WebSocket server. + /// + public bool SendPings { get; set; } + + /// + /// The delay between two Pings in milliseconds. Minimum value is 100ms, default is 10 seconds. + /// + public TimeSpan PingFrequency { get; set; } + + /// + /// If set to true, the plugin will close the connection and emit an event if no + /// message is received from the server in the given time. Its default value is 2 sec. + /// + public TimeSpan CloseAfterNoMessage { get; set; } + + /// + /// The internal object. + /// + public HTTP.HTTPRequest InternalRequest { get { return this.implementation.InternalRequest; } } + + /// + /// implementations the plugin will negotiate with the server to use. + /// + public IExtension[] Extensions { get; private set; } + + /// + /// Latency calculated from ping-pong message round-trip times. + /// + public int Latency { get { return this.implementation.Latency; } } + + /// + /// When the WebSocket instance received the last message from the server. + /// + public DateTime LastMessageReceived { get { return this.implementation.LastMessageReceived; } } + + /// + /// When the Websocket Over HTTP/2 implementation fails to connect and . is true, the plugin tries to fall back to the HTTP/1 implementation. + /// When this happens a new is created and all previous custom modifications (like added headers) are lost. With OnInternalRequestCreated these modifications can be reapplied. + /// + public Action OnInternalRequestCreated; +#endif + + /// + /// Called when the connection to the WebSocket server is established. + /// + public OnWebSocketOpenDelegate OnOpen; + + /// + /// Called when a new textual message is received from the server. + /// + public OnWebSocketMessageDelegate OnMessage; + + /// + /// Called when a Binary message received. + /// The content of the must be used or copied to a new array in the callbacks because the plugin reuses the memory immediately after the callback by placing it back to the ! + /// + /// Note that the memory will be reused when this event returns. Either process it in this call or make a copy from the received data. + public OnWebSocketBinaryNoAllocDelegate OnBinary; + + /// + /// Called when the WebSocket connection is closed. + /// + public OnWebSocketClosedDelegate OnClosed; + + /// + /// Logging context of this websocket instance. + /// + public LoggingContext Context { get; private set; } + + /// + /// The underlying, real implementation. + /// + private WebSocketBaseImplementation implementation; + + /// + /// Creates a WebSocket instance from the given uri. + /// + /// The uri of the WebSocket server + public WebSocket(Uri uri) + :this(uri, string.Empty, string.Empty) + { +#if (!UNITY_WEBGL || UNITY_EDITOR) + this.Extensions = WebSocket.GetDefaultExtensions(); +#endif + } + +#if !UNITY_WEBGL || UNITY_EDITOR + /// + /// Creates a WebSocket instance from the given uri. + /// + /// Uri of the WebSocket endpoint. + /// Where the WebSocket originating from. + /// The application-level protocol that the client want to use(eg. "chat", "leaderboard", etc.). Can be null or empty string if not used. + public WebSocket(Uri uri, string origin, string protocol) + :this(uri, origin, protocol, null) + { +#if (!UNITY_WEBGL || UNITY_EDITOR) + this.Extensions = WebSocket.GetDefaultExtensions(); +#endif + } +#endif + + /// + /// Creates a WebSocket instance from the given uri, protocol and origin. + /// + /// The uri of the WebSocket server + /// Servers that are not intended to process input from any web page but only for certain sites SHOULD verify the |Origin| field is an origin they expect. + /// If the origin indicated is unacceptable to the server, then it SHOULD respond to the WebSocket handshake with a reply containing HTTP 403 Forbidden status code. + /// The application-level protocol that the client want to use(eg. "chat", "leaderboard", etc.). Can be null or empty string if not used. + /// Optional implementations + public WebSocket(Uri uri, string origin, string protocol +#if !UNITY_WEBGL || UNITY_EDITOR + , params IExtension[] extensions +#endif + ) + + { + this.Context = new LoggingContext(this); + +#if !UNITY_WEBGL || UNITY_EDITOR + this.Extensions = extensions; +#endif + + SelectImplementation(uri, origin, protocol); + + // Under WebGL when only the WebSocket protocol is used Setup() isn't called, so we have to call it here. + HTTPManager.Setup(); + } + + internal WebSocketBaseImplementation SelectImplementation(Uri uri, string origin, string protocol) + { +#if !UNITY_WEBGL || UNITY_EDITOR +#if !BESTHTTP_DISABLE_ALTERNATE_SSL + + if (HTTPProtocolFactory.IsSecureProtocol(uri) && + HTTPManager.PerHostSettings.Get(uri).HTTP2ConnectionSettings.WebSocketOverHTTP2Settings.EnableWebSocketOverHTTP2) + { + // Try to find a HTTP/2 connection that supports the connect protocol. + var connectionKey = HostKey.From(new UriBuilder("https", uri.Host, uri.Port).Uri, GetProxy(uri)); + + var con = HostManager.GetHostVariant(connectionKey).Find(c => { + var httpConnection = c as HTTPOverTCPConnection; + var http2Handler = httpConnection?.requestHandler as HTTP2ContentConsumer; + + return http2Handler != null && http2Handler.settings.RemoteSettings[HTTP2Settings.ENABLE_CONNECT_PROTOCOL] != 0; + }); + + if (con != null) + { + HTTPManager.Logger.Information("WebSocket", "Connection with enabled Connect Protocol found!", this.Context); + + var httpConnection = con as HTTPOverTCPConnection; + var http2Handler = httpConnection?.requestHandler as HTTP2ContentConsumer; + + this.implementation = new OverHTTP2(this, uri, origin, protocol); + } + } +#endif + if (this.implementation == null) + this.implementation = new OverHTTP1(this, uri, origin, protocol); +#else + this.implementation = new WebGLBrowser(this, uri, origin, protocol); +#endif + + return this.implementation; + } + +#if !UNITY_WEBGL || UNITY_EDITOR + internal void FallbackToHTTP1() + { + HTTPManager.Logger.Verbose("WebSocket", "FallbackToHTTP1", this.Context); + + if (this.implementation == null) + return; + + this.implementation = new OverHTTP1(this, this.implementation.Uri, this.implementation.Origin, this.implementation.Protocol); + this.implementation.StartOpen(); + } +#endif + + /// + /// Start the opening process. + /// + /// It's a non-blocking call. To get notified when the WebSocket instance is considered open and can send/receive, use the event. + public void Open() + { + this.implementation.StartOpen(); + } + + /// + /// It will send the given textual message to the remote server. + /// + public void Send(string message) + { + if (!IsOpen) + return; + + this.implementation.Send(message); + } + + /// + /// It will send the given binary message to the remote server. + /// + public void Send(byte[] buffer) + { + if (!IsOpen) + return; + + this.implementation.Send(buffer); + } + + /// + /// It will send the given binary message to the remote server. + /// + public void Send(byte[] buffer, ulong offset, ulong count) + { + if (!IsOpen) + return; + + this.implementation.Send(buffer, offset, count); + } + + /// + /// Will send the data in one or more binary frame and takes ownership over it calling BufferPool.Release when the data sent. + /// + public void SendAsBinary(BufferSegment data) + { + if (!IsOpen) + { + BufferPool.Release(data); + return; + } + + this.implementation.SendAsBinary(data); + } + + /// + /// Will send data as a text frame and takes owenership over the memory region releasing it to the BufferPool as soon as possible. + /// + public void SendAsText(BufferSegment data) + { + if (!IsOpen) + { + BufferPool.Release(data); + return; + } + + this.implementation.SendAsText(data); + } + + /// + /// It will initiate the closing of the connection to the server. + /// + public void Close() + { + if (State >= WebSocketStates.Closing) + return; + + this.implementation.StartClose(WebSocketStatusCodes.NormalClosure, "Bye!"); + } + + /// + /// It will initiate the closing of the connection to the server sending the given code and message. + /// + public void Close(WebSocketStatusCodes code, string message) + { + if (!IsOpen) + return; + + this.implementation.StartClose(code, message); + } + +#if !UNITY_WEBGL || UNITY_EDITOR + internal ProxySettings GetProxy(Uri uri) + { + // WebSocket is not a request-response based protocol, so we need a 'tunnel' through the proxy + var proxy = HTTPManager.Proxy as HTTP.Proxies.HTTPProxy; + if (proxy != null && proxy.UseProxyForAddress(uri)) + proxy = new HTTP.Proxies.HTTPProxy(proxy.Address, + proxy.Credentials, + false, /*turn on 'tunneling'*/ + false, /*sendWholeUri*/ + proxy.NonTransparentForHTTPS); + + return new ProxySettings { Proxy = proxy }; + } + + internal void DisposeExtensions() + { + if (this.Extensions != null) + { + for (int i = 0; i < this.Extensions.Length; ++i) + { + var ext = this.Extensions[i]; + + try + { + ext?.Dispose(); + } + catch (Exception ex) + { + HTTPManager.Logger.Exception("OverHTTP1", "OnInternalRequestCallback - extension dispose", ex, this.Context); + } + + this.Extensions[i] = null; + } + + this.Extensions = null; + } + } +#endif + +#if !UNITY_WEBGL || UNITY_EDITOR + + internal static BufferSegment EncodeCloseData(WebSocketStatusCodes code, string message) + { + //If there is a body, the first two bytes of the body MUST be a 2-byte unsigned integer + // (in network byte order) representing a status code with value /code/ defined in Section 7.4 (http://tools.ietf.org/html/rfc6455#section-7.4). Following the 2-byte integer, + // the body MAY contain UTF-8-encoded data with value /reason/, the interpretation of which is not defined by this specification. + // This data is not necessarily human readable but may be useful for debugging or passing information relevant to the script that opened the connection. + int msgLen = Encoding.UTF8.GetByteCount(message); + using (var ms = new BufferPoolMemoryStream(2 + msgLen)) + { + byte[] buff = BitConverter.GetBytes((ushort)code); + if (BitConverter.IsLittleEndian) + Array.Reverse(buff, 0, buff.Length); + + ms.Write(buff, 0, buff.Length); + + buff = Encoding.UTF8.GetBytes(message); + ms.Write(buff, 0, buff.Length); + + buff = ms.ToArray(); + + return buff.AsBuffer(buff.Length); + } + } + + internal static string GetSecKey(object[] from) + { + const int keysLength = 16; + byte[] keys = BufferPool.Get(keysLength, true); + int pos = 0; + + for (int i = 0; i < from.Length; ++i) + { + byte[] hash = BitConverter.GetBytes((Int32)from[i].GetHashCode()); + + for (int cv = 0; cv < hash.Length && pos < keysLength; ++cv) + keys[pos++] = hash[cv]; + } + + var result = Convert.ToBase64String(keys, 0, keysLength); + BufferPool.Release(keys); + + return result; + } +#endif + } +} \ No newline at end of file diff --git a/Runtime/WebSocket.cs.meta b/Runtime/WebSocket.cs.meta new file mode 100644 index 0000000..48ee1a9 --- /dev/null +++ b/Runtime/WebSocket.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 188f56aaa31c94f498b24e9498a85a0c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 268757 + packageName: Best WebSockets + packageVersion: 3.0.7 + assetPath: Packages/com.tivadar.best.websockets/Runtime/WebSocket.cs + uploadId: 737284 diff --git a/Runtime/WebSocketStatusCodes.cs b/Runtime/WebSocketStatusCodes.cs new file mode 100644 index 0000000..b439ebe --- /dev/null +++ b/Runtime/WebSocketStatusCodes.cs @@ -0,0 +1,79 @@ +namespace Best.WebSockets +{ + /// + /// + /// + public enum WebSocketStatusCodes : ushort + { + /// + /// Indicates a normal closure, meaning that the purpose for which the connection was established has been fulfilled. + /// + NormalClosure = 1000, + + /// + /// Indicates that an endpoint is "going away", such as a server going down or a browser having navigated away from a page. + /// + GoingAway = 1001, + + /// + /// Indicates that an endpoint is terminating the connection due to a protocol error. + /// + ProtocolError = 1002, + + /// + /// Indicates that an endpoint is terminating the connection because it has received a type of data it cannot accept + /// (e.g., an endpoint that understands only text data MAY send this if it receives a binary message). + /// + WrongDataType = 1003, + + /// + /// Reserved. The specific meaning might be defined in the future. + /// + Reserved = 1004, + + /// + /// A reserved value and MUST NOT be set as a status code in a Close control frame by an endpoint. + /// It is designated for use in applications expecting a status code to indicate that no status code was actually present. + /// + NoStatusCode = 1005, + + /// + /// A reserved value and MUST NOT be set as a status code in a Close control frame by an endpoint. + /// It is designated for use in applications expecting a status code to indicate that the connection was closed abnormally, e.g., without sending or receiving a Close control frame. + /// + ClosedAbnormally = 1006, + + /// + /// Indicates that an endpoint is terminating the connection because it has received data within a message that was not consistent with the type of the message (e.g., non-UTF-8 [RFC3629] data within a text message). + /// + DataError = 1007, + + /// + /// Indicates that an endpoint is terminating the connection because it has received a message that violates its policy. + /// This is a generic status code that can be returned when there is no other more suitable status code (e.g., 1003 or 1009) or if there is a need to hide specific details about the policy. + /// + PolicyError = 1008, + + /// + /// Indicates that an endpoint is terminating the connection because it has received a message that is too big for it to process. + /// + TooBigMessage = 1009, + + /// + /// Indicates that an endpoint (client) is terminating the connection because it has expected the server to negotiate one or more extension, + /// but the server didn't return them in the response message of the WebSocket handshake. + /// The list of extensions that are needed SHOULD appear in the /reason/ part of the Close frame. Note that this status code is not used by the server, because it can fail the WebSocket handshake instead. + /// + ExtensionExpected = 1010, + + /// + /// Indicates that a server is terminating the connection because it encountered an unexpected condition that prevented it from fulfilling the request. + /// + WrongRequest = 1011, + + /// + /// A reserved value and MUST NOT be set as a status code in a Close control frame by an endpoint. It is designated for use in applications expecting a status code to indicate that the connection was closed due to a failure to perform a TLS handshake (e.g., the server certificate can't be verified). + /// + TLSHandshakeError = 1015 + } +} diff --git a/Runtime/WebSocketStatusCodes.cs.meta b/Runtime/WebSocketStatusCodes.cs.meta new file mode 100644 index 0000000..11823db --- /dev/null +++ b/Runtime/WebSocketStatusCodes.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: aa4ede837cdd86248b95e6ff4854831e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 268757 + packageName: Best WebSockets + packageVersion: 3.0.7 + assetPath: Packages/com.tivadar.best.websockets/Runtime/WebSocketStatusCodes.cs + uploadId: 737284 diff --git a/Runtime/com.Tivadar.Best.WebSockets.asmdef b/Runtime/com.Tivadar.Best.WebSockets.asmdef new file mode 100644 index 0000000..0ff9f35 --- /dev/null +++ b/Runtime/com.Tivadar.Best.WebSockets.asmdef @@ -0,0 +1,22 @@ +{ + "name": "com.Tivadar.Best.WebSockets", + "rootNamespace": "Best.WebSockets", + "references": [ + "Unity.Burst", + "com.Tivadar.Best.HTTP" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": true, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [ { + "name": "com.unity.burst", + "expression": "1.8.7", + "define": "WITH_BURST" + } + ], + "noEngineReferences": false +} \ No newline at end of file diff --git a/Runtime/com.Tivadar.Best.WebSockets.asmdef.meta b/Runtime/com.Tivadar.Best.WebSockets.asmdef.meta new file mode 100644 index 0000000..3615b31 --- /dev/null +++ b/Runtime/com.Tivadar.Best.WebSockets.asmdef.meta @@ -0,0 +1,14 @@ +fileFormatVersion: 2 +guid: bba0e49b0b1b9924ab06cd58867f661c +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 268757 + packageName: Best WebSockets + packageVersion: 3.0.7 + assetPath: Packages/com.tivadar.best.websockets/Runtime/com.Tivadar.Best.WebSockets.asmdef + uploadId: 737284 diff --git a/Runtime/csc.rsp b/Runtime/csc.rsp new file mode 100644 index 0000000..ebd6cb4 --- /dev/null +++ b/Runtime/csc.rsp @@ -0,0 +1,5 @@ +#https://forum.unity.com/threads/rsp-file-per-assembly-or-folder.572758/ + +#https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/compiler-options/errors-warnings +-warnaserror +-nowarn:CS1701,CS0612,CS1591,CS1571,CS1572,CS1573,CS1574,CS1587 diff --git a/Runtime/csc.rsp.meta b/Runtime/csc.rsp.meta new file mode 100644 index 0000000..f3bce6c --- /dev/null +++ b/Runtime/csc.rsp.meta @@ -0,0 +1,14 @@ +fileFormatVersion: 2 +guid: 2a3b358778e405d448e3de83204258d4 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 268757 + packageName: Best WebSockets + packageVersion: 3.0.7 + assetPath: Packages/com.tivadar.best.websockets/Runtime/csc.rsp + uploadId: 737284 diff --git a/package.json b/package.json new file mode 100644 index 0000000..01b3152 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "com.tivadar.best.websockets", + "displayName": "Best WebSockets", + "description": "Best WebSockets is a premier networking library for Unity, tailored specifically for seamless WebSocket integration. It's perfect for applications that require real-time, bi-directional communication such as chat applications, multiplayer games, and live interactive systems.", + "version": "3.0.7", + "unity": "2021.1", + "documentationUrl": "https://bestdocshub.pages.dev/WebSockets/", + "changelogUrl": "https://bestdocshub.pages.dev/WebSockets/changelog/", + "licensesUrl": "https://bestdocshub.pages.dev/HTTP/third-party-notices/", + "author": { + "name": "Tivadar György Nagy", + "email": "besthttp@gmail.com", + "url": "https://bestdocshub.pages.dev/" + }, + "keywords": [ + "best", + "besthttp", + "http", + "tls", + "websocket", + "ws", + "wss", + "dns", + "tcp", + "http2" + ], + "dependencies": { + "com.unity.burst": "1.8.10", + "com.tivadar.best.http": "3.0.10" + }, + "samples": [ + { + "displayName": "Simple Sample", + "description": "Contains sample codes for various WebSocket related scenarios.", + "path": "Samples~/With UI" + } + ], + "_fingerprint": "3a85df99e0e81cf6effff7ba2e690276f2aee437" +} diff --git a/package.json.meta b/package.json.meta new file mode 100644 index 0000000..492134a --- /dev/null +++ b/package.json.meta @@ -0,0 +1,14 @@ +fileFormatVersion: 2 +guid: f957ac0c1e625e54d8ba12e2a5121954 +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 268757 + packageName: Best WebSockets + packageVersion: 3.0.7 + assetPath: Packages/com.tivadar.best.websockets/package.json + uploadId: 737284