diff --git a/Documentation~/README.md b/Documentation~/README.md
new file mode 100644
index 0000000..30c1854
--- /dev/null
+++ b/Documentation~/README.md
@@ -0,0 +1,149 @@
+# com.utilities.websockets
+
+[![Discord](https://img.shields.io/discord/855294214065487932.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/xQgMW9ufN4) [![openupm](https://img.shields.io/npm/v/com.utilities.websockets?label=openupm®istry_uri=https://package.openupm.com)](https://openupm.com/packages/com.utilities.websockets/) [![openupm](https://img.shields.io/badge/dynamic/json?color=brightgreen&label=downloads&query=%24.downloads&suffix=%2Fmonth&url=https%3A%2F%2Fpackage.openupm.com%2Fdownloads%2Fpoint%2Flast-month%2Fcom.utilities.websockets)](https://openupm.com/packages/com.utilities.websockets/)
+
+A simple websocket package for the [Unity](https://unity.com/) Game Engine.
+
+## Installing
+
+Requires Unity 2021.3 LTS or higher.
+
+The recommended installation method is though the unity package manager and [OpenUPM](https://openupm.com/packages/com.utilities.websockets).
+
+### Via Unity Package Manager and OpenUPM
+
+- Open your Unity project settings
+- Select the `Package Manager`
+![scoped-registries](images/package-manager-scopes.png)
+- Add the OpenUPM package registry:
+ - Name: `OpenUPM`
+ - URL: `https://package.openupm.com`
+ - Scope(s):
+ - `com.utilities`
+- Open the Unity Package Manager window
+- Change the Registry from Unity to `My Registries`
+- Add the `Utilities.Websockets` package
+
+### Via Unity Package Manager and Git url
+
+- Open your Unity Package Manager
+- Add package from git url: `https://github.com/RageAgainstThePixel/com.utilities.websockets.git#upm`
+ > Note: this repo has dependencies on other repositories! You are responsible for adding these on your own.
+ - [com.utilities.async](https://github.com/RageAgainstThePixel/com.utilities.async)
+
+---
+
+## Documentation
+
+### Table Of Contents
+
+- [Connect to a Server](#connect-to-a-server)
+- [Handling Events](#handling-events)
+ - [OnOpen](#onopen)
+ - [OnMessage](#onmessage)
+ - [OnError](#onerror)
+ - [OnClose](#onclose)
+- [Sending Messages](#sending-messages)
+ - [Text](#sending-text)
+ - [Binary](#sending-binary)
+- [Disconnect from a Server](#disconnect-from-a-server)
+
+### Connect to a Server
+
+To setup a new connection, create a new instance of WebSocket and subscribe to event callbacks, and call `Connect` or `ConnectAsync` methods.
+
+> Note: WebSocket implements `IDisposable` and should be properly disposed after use!
+
+```csharp
+var address = "wss://echo.websocket.events";
+using var socket = new WebSocket(address);
+socket.OnOpen += () => Debug.Log($"Connection Established @ {address}");
+socket.OnMessage += (dataFrame) => {
+ switch (dataFrame.Type)
+ {
+ case OpCode.Text:
+ AddLog($"<- Received: {dataFrame.Text}");
+ break;
+ case OpCode.Binary:
+ AddLog($"<- Received: {dataFrame.Data.Length} Bytes");
+ break;
+ }
+};
+socket.OnError += (exception) => Debug.LogException(exception);
+socket.OnClose += (code, reason) => Debug.Log($"Connection Closed: {code} {reason}");
+socket.Connect();
+```
+
+### Handling Events
+
+You can subscribe to the `OnOpen`, `OnMessage`, `OnError`, and `OnClose` events to handle respective situations:
+
+#### OnOpen
+
+Event triggered when the WebSocket connection has been established.
+
+```csharp
+socket.OnOpen += () => Debug.Log("Connection Established!");
+```
+
+#### OnMessage
+
+Event triggered when the WebSocket receives a message. The callback contains a data frame, which can be either text or binary.
+
+```csharp
+socket.OnMessage += (dataFrame) => {
+ switch (dataFrame.Type)
+ {
+ case OpCode.Text:
+ AddLog($"<- Received: {dataFrame.Text}");
+ break;
+ case OpCode.Binary:
+ AddLog($"<- Received: {dataFrame.Data.Length} Bytes");
+ break;
+ }
+};
+```
+
+#### OnError
+
+Event triggered when the WebSocket raises an error. The callback contains an exception which can be handled, re-thrown, or logged.
+
+```csharp
+socket.OnError += (exception) => Debug.LogException(exception);
+```
+
+#### OnClose
+
+Event triggered when the WebSocket connection has been closed. The callback contains the close code and reason.
+
+```csharp
+socket.OnClose += (code, reason) => Debug.Log($"Connection Closed: {code} {reason}");
+```
+
+### Sending Messages
+
+#### Sending Text
+
+Perfect for sending json payloads and other text messages.
+
+```csharp
+await socket.SendAsync("{\"message\":\"Hello World!\"}");
+```
+
+#### Sending Binary
+
+Perfect for sending binary data and files.
+
+```csharp
+var bytes = System.Text.Encoding.UTF8.GetBytes("Hello World!");
+await socket.SendAsync(bytes);
+```
+
+### Disconnect from a Server
+
+To disconnect from the server, use `Close` or `CloseAsync` methods and dispose of the WebSocket.
+
+```csharp
+socket.Close();
+socket.Dispose();
+```
diff --git a/Documentation~/images/package-manager-scopes.png b/Documentation~/images/package-manager-scopes.png
new file mode 100644
index 0000000..999ac4a
Binary files /dev/null and b/Documentation~/images/package-manager-scopes.png differ
diff --git a/Editor.meta b/Editor.meta
new file mode 100644
index 0000000..bb383ac
--- /dev/null
+++ b/Editor.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 829a2a29ce04aed4aa31a5d54660c44c
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Editor/AssemblyInfo.cs b/Editor/AssemblyInfo.cs
new file mode 100644
index 0000000..26b0717
--- /dev/null
+++ b/Editor/AssemblyInfo.cs
@@ -0,0 +1 @@
+// Licensed under the MIT License. See LICENSE in the project root for license information.
diff --git a/Editor/AssemblyInfo.cs.meta b/Editor/AssemblyInfo.cs.meta
new file mode 100644
index 0000000..d12d33f
--- /dev/null
+++ b/Editor/AssemblyInfo.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 20b20f831b318e5459f672cb68f03cf5
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Editor/Utilities.Websockets.Editor.asmdef b/Editor/Utilities.Websockets.Editor.asmdef
new file mode 100644
index 0000000..0cde049
--- /dev/null
+++ b/Editor/Utilities.Websockets.Editor.asmdef
@@ -0,0 +1,18 @@
+{
+ "name": "Utilities.WebSockets.Editor",
+ "rootNamespace": "Utilities.WebSockets.Editor",
+ "references": [
+ "Utilities.WebSockets"
+ ],
+ "includePlatforms": [
+ "Editor"
+ ],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
\ No newline at end of file
diff --git a/Editor/Utilities.Websockets.Editor.asmdef.meta b/Editor/Utilities.Websockets.Editor.asmdef.meta
new file mode 100644
index 0000000..a05b158
--- /dev/null
+++ b/Editor/Utilities.Websockets.Editor.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: edb38984470ed7b42b651ccb73c1ff02
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..ae10890
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 RageAgainstThePixel
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/LICENSE.md.meta b/LICENSE.md.meta
new file mode 100644
index 0000000..24bb1a1
--- /dev/null
+++ b/LICENSE.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 1873865bc54a29a4fb38b402c58b00a2
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime.meta b/Runtime.meta
new file mode 100644
index 0000000..c880f55
--- /dev/null
+++ b/Runtime.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 5fcb3c3f74f4fdc47bc5ffec866b8f25
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/AssemblyInfo.cs b/Runtime/AssemblyInfo.cs
new file mode 100644
index 0000000..26b0717
--- /dev/null
+++ b/Runtime/AssemblyInfo.cs
@@ -0,0 +1 @@
+// Licensed under the MIT License. See LICENSE in the project root for license information.
diff --git a/Runtime/AssemblyInfo.cs.meta b/Runtime/AssemblyInfo.cs.meta
new file mode 100644
index 0000000..a3b791b
--- /dev/null
+++ b/Runtime/AssemblyInfo.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 1916059d33c5f6246b3e05e32a48a1ed
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/CloseStatusCode.cs b/Runtime/CloseStatusCode.cs
new file mode 100644
index 0000000..51035ff
--- /dev/null
+++ b/Runtime/CloseStatusCode.cs
@@ -0,0 +1,80 @@
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+
+namespace Utilities.WebSockets
+{
+ ///
+ /// When closing an established connection (e.g., when sending a Close frame, after the opening handshake has completed),
+ /// an endpoint MAY indicate a reason for closure.
+ ///
+ ///
+ /// The values of this enumeration are defined in .
+ ///
+ public enum CloseStatusCode : ushort
+ {
+ ///
+ /// Indicates a normal closure, meaning that the purpose for which the connection was established has been fulfilled.
+ ///
+ Normal = 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).
+ ///
+ UnsupportedData = 1003,
+ ///
+ /// Reserved and MUST NOT be set as a status code in a Close control frame by an endpoint.
+ /// The specific meaning might be defined in the future.
+ ///
+ Reserved = 1004,
+ ///
+ /// Reserved 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.
+ ///
+ NoStatus = 1005,
+ ///
+ /// Reserved 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.
+ ///
+ AbnormalClosure = 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.
+ ///
+ InvalidPayloadData = 1007,
+ ///
+ /// Indicates that an endpoint is terminating the connection because it 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.
+ ///
+ PolicyViolation = 1008,
+ ///
+ /// Indicates that an endpoint is terminating the connection because it has received a message that is too big for it to process.
+ ///
+ TooBigToProcess = 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.
+ ///
+ MandatoryExtension = 1010,
+ ///
+ /// Indicates that a server is terminating the connection because it encountered an unexpected condition that prevented it from fulfilling the request.
+ ///
+ ServerError = 1011,
+ ///
+ /// Reserved 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).
+ ///
+ TlsHandshakeFailure = 1015
+ }
+}
diff --git a/Runtime/CloseStatusCode.cs.meta b/Runtime/CloseStatusCode.cs.meta
new file mode 100644
index 0000000..a00f26c
--- /dev/null
+++ b/Runtime/CloseStatusCode.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 9b749be3e094b1b48b5233eba7710b23
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/DataFrame.cs b/Runtime/DataFrame.cs
new file mode 100644
index 0000000..14ec72b
--- /dev/null
+++ b/Runtime/DataFrame.cs
@@ -0,0 +1,24 @@
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+
+using System;
+
+namespace Utilities.WebSockets
+{
+ public class DataFrame
+ {
+ public OpCode Type { get; }
+
+ public ReadOnlyMemory Data { get; }
+
+ public string Text { get; }
+
+ public DataFrame(OpCode type, ReadOnlyMemory data)
+ {
+ Type = type;
+ Data = data;
+ Text = type == OpCode.Text
+ ? System.Text.Encoding.UTF8.GetString(data.Span)
+ : string.Empty;
+ }
+ }
+}
diff --git a/Runtime/DataFrame.cs.meta b/Runtime/DataFrame.cs.meta
new file mode 100644
index 0000000..872672f
--- /dev/null
+++ b/Runtime/DataFrame.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 196ef085c1e622d4992be519a21fad3a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/IWebSocket.cs b/Runtime/IWebSocket.cs
new file mode 100644
index 0000000..ccb7dd3
--- /dev/null
+++ b/Runtime/IWebSocket.cs
@@ -0,0 +1,85 @@
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Utilities.WebSockets
+{
+ public interface IWebSocket : IDisposable
+ {
+ ///
+ /// Occurs when the connection has been established.
+ ///
+ event Action OnOpen;
+
+ ///
+ /// Occurs when the receives a message.
+ ///
+ event Action OnMessage;
+
+ ///
+ /// Occurs when the raises an error.
+ ///
+ event Action OnError;
+
+ ///
+ /// Occurs when the connection has been closed.
+ ///
+ event Action OnClose;
+
+ ///
+ /// The address of the .
+ ///
+ Uri Address { get; }
+
+ ///
+ /// The sub-protocols used by the .
+ ///
+ IReadOnlyList SubProtocols { get; }
+
+ ///
+ /// The current state of the .
+ ///
+ State State { get; }
+
+ ///
+ /// Connect to the server.
+ ///
+ void Connect();
+
+ ///
+ /// Connect to the server asynchronously.
+ ///
+ /// Optional, .
+ Task ConnectAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Send a text message to the .
+ ///
+ /// The text message to send.
+ /// Optional, .
+ Task SendAsync(string text, CancellationToken cancellationToken = default);
+
+ ///
+ /// Send a binary message to the .
+ ///
+ /// The binary message to send.
+ /// Optional, .
+ Task SendAsync(ArraySegment data, CancellationToken cancellationToken = default);
+
+ ///
+ /// Close the .
+ ///
+ void Close();
+
+ ///
+ /// Close the asynchronously.
+ ///
+ /// The close status code.
+ /// The reason for closing the connection.
+ /// Optional, .
+ Task CloseAsync(CloseStatusCode code = CloseStatusCode.Normal, string reason = "", CancellationToken cancellationToken = default);
+ }
+}
diff --git a/Runtime/IWebSocket.cs.meta b/Runtime/IWebSocket.cs.meta
new file mode 100644
index 0000000..aefe1f7
--- /dev/null
+++ b/Runtime/IWebSocket.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 251bd742d58dd8f48a56394e5586a74c
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/OpCode.cs b/Runtime/OpCode.cs
new file mode 100644
index 0000000..a9f2ecd
--- /dev/null
+++ b/Runtime/OpCode.cs
@@ -0,0 +1,10 @@
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+
+namespace Utilities.WebSockets
+{
+ public enum OpCode
+ {
+ Text,
+ Binary
+ }
+}
diff --git a/Runtime/OpCode.cs.meta b/Runtime/OpCode.cs.meta
new file mode 100644
index 0000000..21b21ed
--- /dev/null
+++ b/Runtime/OpCode.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: e0325f235b52ed04fb6eab828f85b3ec
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/Plugins.meta b/Runtime/Plugins.meta
new file mode 100644
index 0000000..18508e5
--- /dev/null
+++ b/Runtime/Plugins.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 150fe18e639eaf74aba90fe3723ddb84
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/Plugins/WebSocket.jslib b/Runtime/Plugins/WebSocket.jslib
new file mode 100644
index 0000000..d1d39ce
--- /dev/null
+++ b/Runtime/Plugins/WebSocket.jslib
@@ -0,0 +1,247 @@
+var UnityWebSocketLibrary = {
+ /**
+ * Pointer index for WebSocket objects.
+ */
+ $ptrIndex: 0,
+ /**
+ * Array of instanced WebSocket objects.
+ */
+ $webSockets: [],
+ /**
+ * Create a new WebSocket instance and adds it to the $webSockets array.
+ * @param {string} url - The URL to which to connect.
+ * @param {string[]} subProtocols - An array of strings that indicate the sub-protocols the client is willing to speak.
+ * @returns {number} - A pointer to the WebSocket instance.
+ * @param {function} onOpenCallback - The callback function. WebSocket_OnOpenDelegate(IntPtr websocketPtr) in C#.
+ * @param {function} onMessageCallback - The callback function. WebSocket_OnMessageDelegate(IntPtr websocketPtr, IntPtr data, int length, int type) in C#.
+ * @param {function} onErrorCallback - The callback function. WebSocket_OnErrorDelegate(IntPtr websocketPtr, IntPtr messagePtr) in C#.
+ * @param {function} onCloseCallback - The callback function. WebSocket_OnCloseDelegate(IntPtr websocketPtr, int code, IntPtr reasonPtr) in C#.
+ */
+ WebSocket_Create: function (url, subProtocols, onOpenCallback, onMessageCallback, onErrorCallback, onCloseCallback) {
+ var urlStr = UTF8ToString(url);
+
+ try {
+ var subProtocolsStr = UTF8ToString(subProtocols);
+ var subProtocolsArr = subProtocolsStr ? subProtocolsStr.split(',') : undefined;
+
+ for (var i = 0; i < webSockets.length; i++) {
+ var instance = webSockets[i];
+
+ if (instance !== undefined && instance.url !== undefined && instance.url === urlStr) {
+ console.error('WebSocket connection already exists for URL: ', urlStr);
+ return 0;
+ }
+ }
+
+ var socketPtr = ++ptrIndex;
+ webSockets[socketPtr] = {
+ socket: null,
+ url: urlStr,
+ onOpenCallback: onOpenCallback,
+ onMessageCallback: onMessageCallback,
+ onErrorCallback: onErrorCallback,
+ onCloseCallback: onCloseCallback
+ };
+
+ if (subProtocolsArr) {
+ webSockets[socketPtr].subProtocols = subProtocolsArr;
+ }
+
+ // console.log('Created WebSocket object with websocketPtr: ', socketPtr, ' for URL: ', urlStr, ' and sub-protocols: ', subProtocolsArr)
+ return socketPtr;
+ } catch (error) {
+ console.error('Error creating WebSocket object for URL: ', urlStr, ' Error: ', error);
+ return 0;
+ }
+ },
+ /**
+ * Get the current state of the WebSocket connection.
+ * @param socketPtr - A pointer to the WebSocket object. IntPtr in C#.
+ * @returns {number} - The current state of the WebSocket connection.
+ */
+ WebSocket_GetState: function (socketPtr) {
+ try {
+ var instance = webSockets[socketPtr];
+
+ if (!instance || !instance.socket) {
+ return 0;
+ }
+
+ return instance.socket.readyState;
+ } catch (error) {
+ console.error('Error getting WebSocket state for websocketPtr: ', socketPtr, ' Error: ', error);
+ return 3;
+ }
+ },
+ /**
+ * Connect the WebSocket connection.
+ * @param socketPtr - A pointer to the WebSocket object. IntPtr in C#.
+ */
+ WebSocket_Connect: function (socketPtr) {
+ try {
+ var instance = webSockets[socketPtr];
+
+ if (!instance.subProtocols || instance.subProtocols.length === 0) {
+ instance.socket = new WebSocket(instance.url);
+ } else {
+ instance.socket = new WebSocket(instance.url, instance.subProtocols);
+ }
+
+ instance.socket.binaryType = 'arraybuffer';
+ instance.socket.onopen = function () {
+ try {
+ // console.log('WebSocket connection opened for websocketPtr: ', socketPtr);
+ Module.dynCall_vi(instance.onOpenCallback, socketPtr);
+ } catch (error) {
+ console.error('Error calling onOpen callback for websocketPtr: ', socketPtr, ' Error: ', error);
+ }
+ };
+ instance.socket.onmessage = function (event) {
+ try {
+ // console.log('Received message for websocketPtr: ', socketPtr, ' with data: ', event.data);
+ if (event.data instanceof ArrayBuffer) {
+ var array = new Uint8Array(event.data);
+ var buffer = Module._malloc(array.length);
+ writeArrayToMemory(array, buffer);
+
+ try {
+ Module.dynCall_viiii(instance.onMessageCallback, socketPtr, buffer, array.length, 1);
+ } finally {
+ Module._free(buffer);
+ }
+ } else if (typeof event.data === 'string') {
+ var length = lengthBytesUTF8(event.data) + 1;
+ var buffer = Module._malloc(length);
+ stringToUTF8(event.data, buffer, length);
+
+ try {
+ Module.dynCall_viiii(instance.onMessageCallback, socketPtr, buffer, length, 0);
+ } finally {
+ Module._free(buffer);
+ }
+ } else {
+ console.error('Error parsing message for websocketPtr: ', socketPtr, ' with data: ', event.data);
+ }
+ } catch (error) {
+ console.error('Error calling onMessage callback for websocketPtr: ', socketPtr, ' Error: ', error);
+ }
+ };
+ instance.socket.onerror = function (event) {
+ try {
+ console.error('WebSocket error for websocketPtr: ', socketPtr, ' with message: ', event);
+ var json = JSON.stringify(event);
+ var length = lengthBytesUTF8(json) + 1;
+ var buffer = Module._malloc(length);
+ stringToUTF8(json, buffer, length);
+
+ try {
+ Module.dynCall_vii(instance.onErrorCallback, socketPtr, buffer);
+ } finally {
+ Module._free(buffer);
+ }
+ } catch (error) {
+ console.error('Error calling onError callback for websocketPtr: ', socketPtr, ' Error: ', error);
+ }
+ };
+ instance.socket.onclose = function (event) {
+ try {
+ // console.log('WebSocket connection closed for websocketPtr: ', socketPtr, ' with code: ', event.code, ' and reason: ', event.reason);
+ var length = lengthBytesUTF8(event.reason) + 1;
+ var buffer = Module._malloc(length);
+ stringToUTF8(event.reason, buffer, length);
+
+ try {
+ Module.dynCall_viii(instance.onCloseCallback, socketPtr, event.code, buffer);
+ } finally {
+ Module._free(buffer);
+ }
+ } catch (error) {
+ console.error('Error calling onClose callback for websocketPtr: ', socketPtr, ' Error: ', error);
+ }
+ };
+ // console.log('Connecting WebSocket connection for websocketPtr: ', socketPtr);
+ } catch (error) {
+ console.error('Error connecting WebSocket connection for websocketPtr: ', socketPtr, ' Error: ', error);
+ }
+ },
+ /**
+ * Send data to the WebSocket connection.
+ * @param socketPtr - A pointer to the WebSocket object. IntPtr in C#.
+ * @param data - A pointer to the data to send.
+ * @param length - The length of the data to send.
+ */
+ WebSocket_SendData: function (socketPtr, data, length) {
+ try {
+ var instance = webSockets[socketPtr];
+
+ if (!instance || !instance.socket || instance.socket.readyState !== 1) {
+ console.error('WebSocket connection does not exist for websocketPtr: ', socketPtr);
+ return;
+ }
+
+ // console.log('Sending message to WebSocket connection for websocketPtr: ', socketPtr, ' with data: ', data, ' and length: ', length);
+ instance.socket.send(buffer.slice(data, data + length));
+ } catch (error) {
+ console.error('Error sending message to WebSocket connection for websocketPtr: ', socketPtr, ' Error: ', error);
+ }
+ },
+ /**
+ * Send a string to the WebSocket connection.
+ * @param socketPtr - A pointer to the WebSocket object. IntPtr in C#.
+ * @param data - The string to send.
+ */
+ WebSocket_SendString: function (socketPtr, data) {
+ try {
+ var instance = webSockets[socketPtr];
+
+ if (!instance || !instance.socket || instance.socket.readyState !== 1) {
+ console.error('WebSocket connection does not exist for websocketPtr: ', socketPtr);
+ return;
+ }
+
+ var dataStr = UTF8ToString(data);
+ // console.log('Sending message to WebSocket connection for websocketPtr: ', socketPtr, ' with data: ', dataStr);
+ instance.socket.send(dataStr);
+ } catch (error) {
+ console.error('Error sending message to WebSocket connection for websocketPtr: ', socketPtr, ' Error: ', error);
+ }
+ },
+ /**
+ * Close the WebSocket connection.
+ * @param socketPtr - A pointer to the WebSocket object. IntPtr in C#.
+ * @param code - The status code for the close.
+ * @param reason - The reason for the close.
+ */
+ WebSocket_Close: function (socketPtr, code, reason) {
+ try {
+ var instance = webSockets[socketPtr];
+
+ if (!instance || !instance.socket || instance.socket.readyState >= 2) {
+ console.error('WebSocket connection already closed for websocketPtr: ', socketPtr);
+ return;
+ }
+
+ var reasonStr = UTF8ToString(reason);
+ // console.log('Closing WebSocket connection for websocketPtr: ', socketPtr, ' with code: ', code, ' and reason: ', reasonStr);
+ instance.socket.close(code, reasonStr);
+ } catch (error) {
+ console.error('Error closing WebSocket connection for websocketPtr: ', socketPtr, ' Error: ', error);
+ }
+ },
+ /**
+ * Destroy a WebSocket object.
+ * @param socketPtr - A pointer to the WebSocket object. IntPtr in C#.
+ */
+ WebSocket_Dispose: function (socketPtr) {
+ try {
+ // console.log('Disposing WebSocket object with websocketPtr: ', socketPtr);
+ delete webSockets[socketPtr];
+ } catch (error) {
+ console.error('Error disposing WebSocket object with websocketPtr: ', socketPtr, ' Error: ', error);
+ }
+ }
+};
+
+autoAddDeps(UnityWebSocketLibrary, '$ptrIndex');
+autoAddDeps(UnityWebSocketLibrary, '$webSockets');
+mergeInto(LibraryManager.library, UnityWebSocketLibrary);
diff --git a/Runtime/Plugins/WebSocket.jslib.meta b/Runtime/Plugins/WebSocket.jslib.meta
new file mode 100644
index 0000000..b0d60b5
--- /dev/null
+++ b/Runtime/Plugins/WebSocket.jslib.meta
@@ -0,0 +1,32 @@
+fileFormatVersion: 2
+guid: 0989d70b042875249a815faa52ebd8ce
+PluginImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ iconMap: {}
+ executionOrder: {}
+ defineConstraints: []
+ isPreloaded: 0
+ isOverridable: 1
+ 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:
diff --git a/Runtime/State.cs b/Runtime/State.cs
new file mode 100644
index 0000000..ddacc76
--- /dev/null
+++ b/Runtime/State.cs
@@ -0,0 +1,32 @@
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+
+using System;
+
+namespace Utilities.WebSockets
+{
+ ///
+ /// Indicates the state of the
+ ///
+ ///
+ /// The values of this enumeration are defined in
+ ///
+ public enum State : ushort
+ {
+ ///
+ /// The connection has not yet been established.
+ ///
+ Connecting = 0,
+ ///
+ /// The connection has been established and communication is possible.
+ ///
+ Open = 1,
+ ///
+ /// The connection is going through the closing handshake or close has been requested.
+ ///
+ Closing = 2,
+ ///
+ /// The connection has been closed or could not be opened.
+ ///
+ Closed = 3
+ }
+}
diff --git a/Runtime/State.cs.meta b/Runtime/State.cs.meta
new file mode 100644
index 0000000..38929d1
--- /dev/null
+++ b/Runtime/State.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: ca7645d177c3b1a4dab2d88dc2da8f56
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/Utilities.Websockets.asmdef b/Runtime/Utilities.Websockets.asmdef
new file mode 100644
index 0000000..6757be2
--- /dev/null
+++ b/Runtime/Utilities.Websockets.asmdef
@@ -0,0 +1,16 @@
+{
+ "name": "Utilities.WebSockets",
+ "rootNamespace": "Utilities.WebSockets",
+ "references": [
+ "GUID:a6609af893242c7438d701ddd4cce46a"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
\ No newline at end of file
diff --git a/Runtime/Utilities.Websockets.asmdef.meta b/Runtime/Utilities.Websockets.asmdef.meta
new file mode 100644
index 0000000..2396c7d
--- /dev/null
+++ b/Runtime/Utilities.Websockets.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 9fb4e1e06cb4c804ebfb0cff2b90e6d3
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/WebSocket.cs b/Runtime/WebSocket.cs
new file mode 100644
index 0000000..c294701
--- /dev/null
+++ b/Runtime/WebSocket.cs
@@ -0,0 +1,286 @@
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+
+#if !PLATFORM_WEBGL || UNITY_EDITOR
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Net.WebSockets;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using UnityEngine;
+using Utilities.Async;
+
+namespace Utilities.WebSockets
+{
+ public class WebSocket : IWebSocket
+ {
+ public WebSocket(string url, IReadOnlyList subProtocols = null)
+ : this(new Uri(url), subProtocols)
+ {
+ }
+
+ public WebSocket(Uri uri, IReadOnlyList subProtocols = null)
+ {
+ var protocol = uri.Scheme;
+
+ if (!protocol.Equals("ws") && !protocol.Equals("wss"))
+ {
+ throw new ArgumentException($"Unsupported protocol: {protocol}");
+ }
+
+ Address = uri;
+ SubProtocols = subProtocols ?? new List();
+ _socket = new ClientWebSocket();
+ RunMessageQueue();
+ }
+
+ private async void RunMessageQueue()
+ {
+ while (_semaphore != null)
+ {
+ // syncs with update loop
+ await Awaiters.UnityMainThread;
+
+ while (_events.TryDequeue(out var action))
+ {
+ try
+ {
+ action.Invoke();
+ }
+ catch (Exception e)
+ {
+ Debug.LogException(e);
+ OnError?.Invoke(e);
+ }
+ }
+ }
+ }
+
+ ~WebSocket()
+ {
+ Dispose(false);
+ }
+
+ #region IDisposable
+
+ private void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ lock (_lock)
+ {
+ if (State == State.Open)
+ {
+ CloseAsync().Wait();
+ }
+
+ _socket?.Dispose();
+ _socket = null;
+
+ _lifetimeCts?.Cancel();
+ _lifetimeCts?.Dispose();
+ _lifetimeCts = null;
+
+ _semaphore?.Dispose();
+ _semaphore = null;
+ }
+ }
+ }
+
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ #endregion IDisposable
+
+ ///
+ public event Action OnOpen;
+
+ ///
+ public event Action OnMessage;
+
+ ///
+ public event Action OnError;
+
+ ///
+ public event Action OnClose;
+
+ ///
+ public Uri Address { get; }
+
+ ///
+ public IReadOnlyList SubProtocols { get; }
+
+ ///
+ public State State => _socket?.State switch
+ {
+ WebSocketState.Connecting => State.Connecting,
+ WebSocketState.Open => State.Open,
+ WebSocketState.CloseSent or WebSocketState.CloseReceived => State.Closing,
+ _ => State.Closed
+ };
+
+ private object _lock = new();
+ private ClientWebSocket _socket;
+ private SemaphoreSlim _semaphore = new(1, 1);
+ private CancellationTokenSource _lifetimeCts;
+ private readonly ConcurrentQueue _events = new();
+
+ ///
+ public async void Connect()
+ => await ConnectAsync();
+
+ ///
+ public async Task ConnectAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ if (State == State.Open)
+ {
+ Debug.LogWarning("Websocket is already open!");
+ return;
+ }
+
+ _lifetimeCts?.Cancel();
+ _lifetimeCts?.Dispose();
+ _lifetimeCts = new CancellationTokenSource();
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(_lifetimeCts.Token, cancellationToken);
+
+ foreach (var subProtocol in SubProtocols)
+ {
+ _socket.Options.AddSubProtocol(subProtocol);
+ }
+
+ await _socket.ConnectAsync(Address, cts.Token).ConfigureAwait(false);
+ _events.Enqueue(() => OnOpen?.Invoke());
+ var buffer = new Memory(new byte[8192]);
+
+ while (State == State.Open)
+ {
+ ValueWebSocketReceiveResult result;
+ using var stream = new MemoryStream();
+
+ do
+ {
+ result = await _socket.ReceiveAsync(buffer, cts.Token).ConfigureAwait(false);
+ stream.Write(buffer.Span[..result.Count]);
+ } while (!result.EndOfMessage);
+
+ await stream.FlushAsync(cts.Token).ConfigureAwait(false);
+ var memory = new ReadOnlyMemory(stream.GetBuffer(), 0, (int)stream.Length);
+
+ if (result.MessageType != WebSocketMessageType.Close)
+ {
+ _events.Enqueue(() => OnMessage?.Invoke(new DataFrame((OpCode)(int)result.MessageType, memory)));
+ }
+ else
+ {
+ await CloseAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false);
+ break;
+ }
+ }
+
+ try
+ {
+ await _semaphore.WaitAsync(CancellationToken.None).ConfigureAwait(false);
+ }
+ finally
+ {
+ _semaphore.Release();
+ }
+ }
+ catch (Exception e)
+ {
+ switch (e)
+ {
+ case TaskCanceledException:
+ case OperationCanceledException:
+ break;
+ default:
+ Debug.LogException(e);
+ _events.Enqueue(() => OnError?.Invoke(e));
+ _events.Enqueue(() => OnClose?.Invoke(CloseStatusCode.AbnormalClosure, e.Message));
+ break;
+ }
+ }
+ }
+
+ ///
+ public async Task SendAsync(string text, CancellationToken cancellationToken = default)
+ => await Internal_SendAsync(Encoding.UTF8.GetBytes(text), WebSocketMessageType.Text, cancellationToken);
+
+ ///
+ public async Task SendAsync(ArraySegment data, CancellationToken cancellationToken = default)
+ => await Internal_SendAsync(data, WebSocketMessageType.Binary, cancellationToken);
+
+ private async Task Internal_SendAsync(ArraySegment data, WebSocketMessageType opCode, CancellationToken cancellationToken)
+ {
+ try
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(_lifetimeCts.Token, cancellationToken);
+ await _semaphore.WaitAsync(cts.Token).ConfigureAwait(false);
+
+ if (State != State.Open)
+ {
+ throw new InvalidOperationException("WebSocket is not ready!");
+ }
+
+ await _socket.SendAsync(data, opCode, true, cts.Token).ConfigureAwait(false);
+ }
+ catch (Exception e)
+ {
+ switch (e)
+ {
+ case TaskCanceledException:
+ case OperationCanceledException:
+ break;
+ default:
+ Debug.LogException(e);
+ _events.Enqueue(() => OnError?.Invoke(e));
+ break;
+ }
+ }
+ finally
+ {
+ _semaphore.Release();
+ }
+ }
+
+ ///
+ public async void Close()
+ => await CloseAsync();
+
+ ///
+ public async Task CloseAsync(CloseStatusCode code = CloseStatusCode.Normal, string reason = "", CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ if (State == State.Open)
+ {
+ await _socket.CloseAsync((WebSocketCloseStatus)(int)code, reason, cancellationToken).ConfigureAwait(false);
+ _events.Enqueue(() => OnClose?.Invoke(code, reason));
+ }
+ }
+ catch (Exception e)
+ {
+ switch (e)
+ {
+ case TaskCanceledException:
+ case OperationCanceledException:
+ break;
+ default:
+ Debug.LogException(e);
+ _events.Enqueue(() => OnError?.Invoke(e));
+ break;
+ }
+ }
+ }
+ }
+}
+#endif // !PLATFORM_WEBGL || UNITY_EDITOR
diff --git a/Runtime/WebSocket.cs.meta b/Runtime/WebSocket.cs.meta
new file mode 100644
index 0000000..bbc7a89
--- /dev/null
+++ b/Runtime/WebSocket.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 21cd68194448d4542bb0ce2ed9b1acd5
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/WebSocket_WebGL.cs b/Runtime/WebSocket_WebGL.cs
new file mode 100644
index 0000000..18e3717
--- /dev/null
+++ b/Runtime/WebSocket_WebGL.cs
@@ -0,0 +1,264 @@
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+
+#if PLATFORM_WEBGL && !UNITY_EDITOR
+
+using AOT;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using UnityEngine;
+using Utilities.Async;
+
+namespace Utilities.WebSockets
+{
+ public class WebSocket : IWebSocket
+ {
+ public WebSocket(string url, IReadOnlyList subProtocols = null)
+ : this(new Uri(url), subProtocols)
+ {
+ }
+
+ public WebSocket(Uri uri, IReadOnlyList subProtocols = null)
+ {
+ var protocol = uri.Scheme;
+
+ if (!protocol.Equals("ws") && !protocol.Equals("wss"))
+ {
+ throw new ArgumentException($"Unsupported protocol: {protocol}");
+ }
+
+ Address = uri;
+ SubProtocols = subProtocols ?? new List();
+ _socket = WebSocket_Create(uri.ToString(), string.Join(',', SubProtocols), WebSocket_OnOpen, WebSocket_OnMessage, WebSocket_OnError, WebSocket_OnClose);
+
+ if (_socket == IntPtr.Zero || !_sockets.TryAdd(_socket, this))
+ {
+ throw new InvalidOperationException("Failed to create WebSocket instance!");
+ }
+
+ RunMessageQueue();
+ }
+
+ ~WebSocket()
+ {
+ Dispose(false);
+ }
+
+ private async void RunMessageQueue()
+ {
+ while (_semaphore != null)
+ {
+ // syncs with update loop
+ await Awaiters.UnityMainThread;
+
+ while (_events.TryDequeue(out var action))
+ {
+ try
+ {
+ action.Invoke();
+ }
+ catch (Exception e)
+ {
+ Debug.LogException(e);
+ OnError?.Invoke(e);
+ }
+ }
+ }
+ }
+
+ #region IDisposable
+
+ private void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ lock (_lock)
+ {
+ if (State == State.Open)
+ {
+ CloseAsync().Wait();
+ }
+
+ WebSocket_Dispose(_socket);
+ _socket = IntPtr.Zero;
+
+ _lifetimeCts?.Cancel();
+ _lifetimeCts?.Dispose();
+ _lifetimeCts = null;
+
+ _semaphore?.Dispose();
+ _semaphore = null;
+ }
+ }
+ }
+
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ #endregion IDisposable
+
+ #region Native Interop
+
+ private static ConcurrentDictionary _sockets = new();
+
+ [DllImport("__Internal")]
+ private static extern IntPtr WebSocket_Create(string url, string subProtocols, WebSocket_OnOpenDelegate onOpen, WebSocket_OnMessageDelegate onMessage, WebSocket_OnErrorDelegate onError, WebSocket_OnCloseDelegate onClose);
+
+ private delegate void WebSocket_OnOpenDelegate(IntPtr websocketPtr);
+
+ [MonoPInvokeCallback(typeof(WebSocket_OnOpenDelegate))]
+ private static void WebSocket_OnOpen(IntPtr websocketPtr)
+ {
+ if (_sockets.TryGetValue(websocketPtr, out var socket))
+ {
+ socket._events.Enqueue(() => socket.OnOpen?.Invoke());
+ }
+ else
+ {
+ Debug.LogError($"{nameof(WebSocket_OnOpen)}: Invalid websocket pointer! {websocketPtr.ToInt64()}");
+ }
+ }
+
+ private delegate void WebSocket_OnMessageDelegate(IntPtr websocketPtr, IntPtr dataPtr, int length, OpCode type);
+
+ [MonoPInvokeCallback(typeof(WebSocket_OnMessageDelegate))]
+ private static void WebSocket_OnMessage(IntPtr websocketPtr, IntPtr dataPtr, int length, OpCode type)
+ {
+ if (_sockets.TryGetValue(websocketPtr, out var socket))
+ {
+ var buffer = new byte[length];
+ Marshal.Copy(dataPtr, buffer, 0, length);
+ socket._events.Enqueue(() => socket.OnMessage?.Invoke(new DataFrame(type, buffer)));
+ }
+ else
+ {
+ Debug.LogError($"{nameof(WebSocket_OnMessage)}: Invalid websocket pointer! {websocketPtr.ToInt64()}");
+ }
+ }
+
+ private delegate void WebSocket_OnErrorDelegate(IntPtr websocketPtr, IntPtr messagePtr);
+
+ [MonoPInvokeCallback(typeof(WebSocket_OnErrorDelegate))]
+ private static void WebSocket_OnError(IntPtr websocketPtr, IntPtr messagePtr)
+ {
+ if (_sockets.TryGetValue(websocketPtr, out var socket))
+ {
+ var message = Marshal.PtrToStringUTF8(messagePtr);
+ socket._events.Enqueue(() => socket.OnError?.Invoke(new Exception(message)));
+ }
+ else
+ {
+ Debug.LogError($"{nameof(WebSocket_OnError)}: Invalid websocket pointer! {websocketPtr.ToInt64()}");
+ }
+ }
+
+ private delegate void WebSocket_OnCloseDelegate(IntPtr websocketPtr, CloseStatusCode code, IntPtr reasonPtr);
+
+ [MonoPInvokeCallback(typeof(WebSocket_OnCloseDelegate))]
+ private static void WebSocket_OnClose(IntPtr websocketPtr, CloseStatusCode code, IntPtr reasonPtr)
+ {
+ if (_sockets.TryGetValue(websocketPtr, out var socket))
+ {
+ var reason = Marshal.PtrToStringUTF8(reasonPtr);
+ socket._events.Enqueue(() => socket.OnClose?.Invoke(code, reason));
+ }
+ else
+ {
+ Debug.LogError($"{nameof(WebSocket_OnClose)}: Invalid websocket pointer! {websocketPtr.ToInt64()}");
+ }
+ }
+
+ [DllImport("__Internal")]
+ private static extern int WebSocket_GetState(IntPtr websocketPtr);
+
+ [DllImport("__Internal")]
+ private static extern void WebSocket_Connect(IntPtr websocketPtr);
+
+ [DllImport("__Internal")]
+ private static extern void WebSocket_SendData(IntPtr websocketPtr, byte[] data, int length);
+
+ [DllImport("__Internal")]
+ private static extern void WebSocket_SendString(IntPtr websocketPtr, string text);
+
+ [DllImport("__Internal")]
+ private static extern void WebSocket_Close(IntPtr websocketPtr, CloseStatusCode code, string reason);
+
+ [DllImport("__Internal")]
+ private static extern void WebSocket_Dispose(IntPtr websocketPtr);
+
+ #endregion Native Interop
+
+ ///
+ public event Action OnOpen;
+
+ ///
+ public event Action OnMessage;
+
+ ///
+ public event Action OnError;
+
+ ///
+ public event Action OnClose;
+
+ ///
+ public Uri Address { get; }
+
+ ///
+ public IReadOnlyList SubProtocols { get; }
+
+ ///
+ public State State => _socket != IntPtr.Zero
+ ? (State)WebSocket_GetState(_socket)
+ : State.Closed;
+
+ private object _lock = new();
+ private IntPtr _socket;
+ private SemaphoreSlim _semaphore = new(1, 1);
+ private CancellationTokenSource _lifetimeCts;
+ private readonly ConcurrentQueue _events = new();
+
+ ///
+ public async void Connect()
+ => await ConnectAsync();
+
+ ///
+ public async Task ConnectAsync(CancellationToken cancellationToken = default)
+ {
+ WebSocket_Connect(_socket);
+ await Task.CompletedTask;
+ }
+
+ ///
+ public async Task SendAsync(string text, CancellationToken cancellationToken = default)
+ {
+ WebSocket_SendString(_socket, text);
+ await Task.CompletedTask;
+ }
+
+ ///
+ public async Task SendAsync(ArraySegment data, CancellationToken cancellationToken = default)
+ {
+ WebSocket_SendData(_socket, data.Array, data.Count);
+ await Task.CompletedTask;
+ }
+
+ ///
+ public async void Close()
+ => await CloseAsync();
+
+ ///
+ public async Task CloseAsync(CloseStatusCode code = CloseStatusCode.Normal, string reason = "", CancellationToken cancellationToken = default)
+ {
+ WebSocket_Close(_socket, code, reason);
+ await Task.CompletedTask;
+ }
+ }
+}
+#endif // PLATFORM_WEBGL && !UNITY_EDITOR
diff --git a/Runtime/WebSocket_WebGL.cs.meta b/Runtime/WebSocket_WebGL.cs.meta
new file mode 100644
index 0000000..c79aae4
--- /dev/null
+++ b/Runtime/WebSocket_WebGL.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: f165aea53e8eb7547bb3053a78570d70
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Samples~/WebsocketDemo.meta b/Samples~/WebsocketDemo.meta
new file mode 100644
index 0000000..0f2cd7b
--- /dev/null
+++ b/Samples~/WebsocketDemo.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 687c34e3b9e88cc459456398119546e0
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Samples~/WebsocketDemo/DefaultRuntimeTheme.tss b/Samples~/WebsocketDemo/DefaultRuntimeTheme.tss
new file mode 100644
index 0000000..79453c7
--- /dev/null
+++ b/Samples~/WebsocketDemo/DefaultRuntimeTheme.tss
@@ -0,0 +1,2 @@
+@import url("unity-theme://default");
+VisualElement {}
\ No newline at end of file
diff --git a/Samples~/WebsocketDemo/DefaultRuntimeTheme.tss.meta b/Samples~/WebsocketDemo/DefaultRuntimeTheme.tss.meta
new file mode 100644
index 0000000..20c8290
--- /dev/null
+++ b/Samples~/WebsocketDemo/DefaultRuntimeTheme.tss.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: de08e26de5e540e4a90b6be51bf83b27
+ScriptedImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 2
+ userData:
+ assetBundleName:
+ assetBundleVariant:
+ script: {fileID: 12388, guid: 0000000000000000e000000000000000, type: 0}
+ disableValidation: 0
diff --git a/Samples~/WebsocketDemo/Utilities.WebSockets.Sample.asmdef b/Samples~/WebsocketDemo/Utilities.WebSockets.Sample.asmdef
new file mode 100644
index 0000000..836e370
--- /dev/null
+++ b/Samples~/WebsocketDemo/Utilities.WebSockets.Sample.asmdef
@@ -0,0 +1,16 @@
+{
+ "name": "Utilities.WebSockets.Sample",
+ "rootNamespace": "Utilities.WebSockets.Sample",
+ "references": [
+ "GUID:9fb4e1e06cb4c804ebfb0cff2b90e6d3"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
\ No newline at end of file
diff --git a/Samples~/WebsocketDemo/Utilities.WebSockets.Sample.asmdef.meta b/Samples~/WebsocketDemo/Utilities.WebSockets.Sample.asmdef.meta
new file mode 100644
index 0000000..09c4327
--- /dev/null
+++ b/Samples~/WebsocketDemo/Utilities.WebSockets.Sample.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 3354db43e4e35024e83586c087159d2b
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Samples~/WebsocketDemo/WebSocketBindings.cs b/Samples~/WebsocketDemo/WebSocketBindings.cs
new file mode 100644
index 0000000..a78d236
--- /dev/null
+++ b/Samples~/WebsocketDemo/WebSocketBindings.cs
@@ -0,0 +1,331 @@
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Text;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace Utilities.WebSockets.Sample
+{
+ [RequireComponent(typeof(UIDocument))]
+ public class WebSocketBindings : MonoBehaviour
+ {
+ [SerializeField]
+ private UIDocument uiDocument;
+
+ [SerializeField]
+ private string address = "wss://echo.websocket.events";
+
+ private Label statusLabel;
+ private Label fpsLabel;
+ private TextField addressTextField;
+ private Button connectButton;
+ private Button disconnectButton;
+ private TextField sendMessageTextField;
+ private VisualElement sendMessageButtonGroup;
+ private Button sendTextButton;
+ private Button sendBytesButton;
+ private Button sendText1000Button;
+ private Button sendBytes1000Button;
+ private Toggle logMessagesToggle;
+ private Label sendCountLabel;
+ private Label receiveCountLabel;
+ private Button clearLogsButton;
+ private ListView messageListView;
+
+ private int frame;
+ private int sendCount;
+ private int receiveCount;
+
+ private float time;
+ private float fps;
+
+ private WebSocket webSocket;
+
+ private readonly List> logs = new();
+
+ private void OnValidate()
+ {
+ if (!uiDocument)
+ {
+ uiDocument = GetComponent();
+ }
+ }
+
+ private void Awake()
+ {
+ OnValidate();
+
+ var root = uiDocument.rootVisualElement;
+
+ statusLabel = root.Q