diff --git a/ModExamples/24Websocket/24Websocket.csproj b/ModExamples/24Websocket/24Websocket.csproj
new file mode 100644
index 00000000..009a01d3
--- /dev/null
+++ b/ModExamples/24Websocket/24Websocket.csproj
@@ -0,0 +1,40 @@
+
+
+
+ net9.0
+ _24Websocket
+ enable
+ enable
+ Library
+
+
+
+
+
+ ..\TempReferences\Core.dll
+ false
+ false
+ false
+
+
+ ..\TempReferences\SptCommon.dll
+ false
+ false
+ false
+
+
+ ..\TempReferences\SptDependencyInjection.dll
+ false
+ false
+ false
+
+
+
+
+ true
+ PreserveNewest
+ PreserveNewest
+
+
+
+
diff --git a/ModExamples/24Websocket/WebsocketConnectionHandler.cs b/ModExamples/24Websocket/WebsocketConnectionHandler.cs
new file mode 100644
index 00000000..b22c92b5
--- /dev/null
+++ b/ModExamples/24Websocket/WebsocketConnectionHandler.cs
@@ -0,0 +1,194 @@
+using System.Net.WebSockets;
+using System.Text;
+using Core.Helpers;
+using Core.Models.Eft.Ws;
+using Core.Models.Utils;
+using Core.Servers.Ws;
+using Core.Servers.Ws.Message;
+using Core.Utils;
+using SptCommon.Annotations;
+using LogLevel = Core.Models.Spt.Logging.LogLevel;
+
+namespace _24Websocket;
+// TODO: this is basically a copy of what we do, what is NEEDED of each method and add comments
+[Injectable(InjectionType = InjectionType.Singleton)]
+public class WebsocketConnectionHandler : IWebSocketConnectionHandler
+{
+ private readonly ISptLogger _logger;
+ private readonly ProfileHelper _profileHelper;
+ private readonly JsonUtil _jsonUtil;
+ private readonly IEnumerable _messageHandlers;
+
+ protected WsPing _defaultNotification = new();
+ protected Lock _lockObject = new();
+ protected Dictionary _receiveTasks = new();
+ protected Dictionary _socketAliveTimers = new();
+
+ protected Dictionary _sockets = new();
+
+ public WebsocketConnectionHandler(
+ ISptLogger logger,
+ ProfileHelper profileHelper,
+ JsonUtil jsonUtil,
+ IEnumerable messageHandlers
+ )
+ {
+ _logger = logger;
+ _profileHelper = profileHelper;
+ _jsonUtil = jsonUtil;
+ }
+
+ public string GetHookUrl()
+ {
+ return "/custom/socket/";
+ }
+
+ public string GetSocketId()
+ {
+ return "My Custom WebSocket";
+ }
+
+ public Task OnConnection(WebSocket ws, HttpContext context)
+ {
+ return Task.Factory.StartNew(
+ () =>
+ {
+ var splitUrl = context.Request.Path.Value.Split("/");
+ var sessionID = splitUrl.Last();
+ var playerProfile = _profileHelper.GetFullProfile(sessionID);
+ var playerInfoText = $"{playerProfile.ProfileInfo.Username} ({sessionID})";
+ _logger.Info($"Custom web socket is now connected!: {playerInfoText}");
+
+ _sockets.Add(sessionID, ws);
+
+ lock (_lockObject)
+ {
+ _receiveTasks.Add(sessionID, new CancellationTokenSource());
+ var cancelToken = _receiveTasks[sessionID].Token;
+ Task.Factory.StartNew(_ => ReceiveTask(sessionID, ws, cancelToken), null, cancelToken);
+ }
+
+ while (ws.State == WebSocketState.Open)
+ {
+ Thread.Sleep(1000);
+ }
+
+ // Once the websocket dies, we dispose of it
+ lock (_lockObject)
+ {
+ if (_socketAliveTimers.TryGetValue(sessionID, out var timer))
+ {
+ timer.Change(Timeout.Infinite, Timeout.Infinite);
+ _socketAliveTimers.Remove(sessionID);
+ }
+
+ if (_sockets.ContainsKey(sessionID))
+ {
+ _sockets.Remove(sessionID);
+ }
+
+ if (_receiveTasks.TryGetValue(sessionID, out var receiveTask))
+ {
+ receiveTask.CancelAsync().Wait();
+ }
+ }
+ }
+ );
+ }
+
+ private void ReceiveTask(string sessionId, WebSocket ws, CancellationToken cancelToken)
+ {
+ List readBytes = new();
+ while (ws.State == WebSocketState.Open)
+ {
+ try
+ {
+ if (cancelToken.IsCancellationRequested)
+ {
+ break;
+ }
+
+ var isEndOfMessage = false;
+ while (!isEndOfMessage)
+ {
+ var buffer = new ArraySegment(new byte[1024 * 4]);
+ var readTask = ws.ReceiveAsync(buffer, cancelToken);
+ readTask.Wait(cancelToken);
+ readBytes.AddRange(buffer);
+ isEndOfMessage = readTask.Result.EndOfMessage;
+ }
+
+ foreach (var sptWebSocketMessageHandler in _messageHandlers)
+ {
+ sptWebSocketMessageHandler.OnSptMessage(sessionId, ws, readBytes.ToArray()).Wait();
+ }
+ }
+ catch (OperationCanceledException _)
+ {
+ _logger.Info("WebSocket disconnecting, receive task finalized...");
+ }
+ catch (Exception _)
+ {
+ lock (_lockObject)
+ {
+ _sockets.Remove(sessionId);
+ _socketAliveTimers.Remove(sessionId);
+ _receiveTasks.Remove(sessionId);
+ var playerProfile = _profileHelper.GetFullProfile(sessionId);
+ var playerInfoText = $"{playerProfile.ProfileInfo.Username} ({sessionId})";
+ _logger.Info($"[ws] player: {playerInfoText} has disconnected");
+ }
+
+ ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Client closed connection", CancellationToken.None);
+ }
+ finally
+ {
+ readBytes.Clear();
+ }
+ }
+ }
+
+ public bool IsWebSocketConnected(string sessionId)
+ {
+ return _sockets.TryGetValue(sessionId, out var socket) && socket.State == WebSocketState.Open;
+ }
+
+ public void SendMessage(string sessionID, WsNotificationEvent output)
+ {
+ try
+ {
+ if (IsWebSocketConnected(sessionID))
+ {
+ var ws = GetSessionWebSocket(sessionID);
+
+ var sendTask = ws.SendAsync(
+ Encoding.UTF8.GetBytes(_jsonUtil.Serialize(output, output.GetType())),
+ WebSocketMessageType.Text,
+ true,
+ CancellationToken.None
+ );
+ sendTask.Wait();
+ if (_logger.IsLogEnabled(LogLevel.Debug))
+ {
+ _logger.Debug("Sent Message");
+ }
+ }
+ else
+ {
+ if (_logger.IsLogEnabled(LogLevel.Debug))
+ {
+ _logger.Debug("Couldnt send Message");
+ }
+ }
+ }
+ catch (Exception err)
+ {
+ _logger.Error("message failed with error");
+ }
+ }
+
+ public WebSocket GetSessionWebSocket(string sessionID)
+ {
+ return _sockets[sessionID];
+ }
+}
diff --git a/ModExamples/24Websocket/WebsocketMessageHandler.cs b/ModExamples/24Websocket/WebsocketMessageHandler.cs
new file mode 100644
index 00000000..8b60d19e
--- /dev/null
+++ b/ModExamples/24Websocket/WebsocketMessageHandler.cs
@@ -0,0 +1,23 @@
+using System.Net.WebSockets;
+using Core.Models.Utils;
+using Core.Servers.Ws.Message;
+
+namespace _24Websocket;
+
+public class WebsocketMessageHandler : ISptWebSocketMessageHandler
+{
+ private readonly ISptLogger _logger;
+
+ public WebsocketMessageHandler(
+ ISptLogger logger
+ )
+ {
+ _logger = logger;
+ }
+
+ public Task OnSptMessage(string sessionID, WebSocket client, byte[] rawData)
+ {
+ _logger.Info($"Custom SPT WebSocket Message handler received a message for {sessionID}: {rawData.ToString()}");
+ return Task.CompletedTask;
+ }
+}
diff --git a/ModExamples/24Websocket/package.json b/ModExamples/24Websocket/package.json
new file mode 100644
index 00000000..8fb13a94
--- /dev/null
+++ b/ModExamples/24Websocket/package.json
@@ -0,0 +1,13 @@
+{
+ "Name": "24Websocket",
+ "Version": "1.0.0",
+ "SptVersion": "~4.0",
+ "LoadBefore": [],
+ "LoadAfter": [],
+ "IncompatibileMods": [],
+ "Url": "https://github.com/sp-tarkov/server-csharp/tree/develop/ExampleMods/Mods",
+ "IsBundleMod": false,
+ "Author": "SPT",
+ "Contributors": [],
+ "Licence": "MIT"
+}
diff --git a/ModExamples/ModExamples.sln b/ModExamples/ModExamples.sln
index d5ee619c..430fa4dc 100644
--- a/ModExamples/ModExamples.sln
+++ b/ModExamples/ModExamples.sln
@@ -47,6 +47,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "22CustomSptCommand", "22Cus
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "23CustomAbstractChatBot", "23CustomAbstractChatBot\23CustomAbstractChatBot.csproj", "{B97A7EE2-04DD-4690-866E-9179F5E01908}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "24Websocket", "24Websocket\24Websocket.csproj", "{94B1B1E4-C47D-4AA7-BA3A-88F3D30439DF}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -141,6 +143,10 @@ Global
{B97A7EE2-04DD-4690-866E-9179F5E01908}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B97A7EE2-04DD-4690-866E-9179F5E01908}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B97A7EE2-04DD-4690-866E-9179F5E01908}.Release|Any CPU.Build.0 = Release|Any CPU
+ {94B1B1E4-C47D-4AA7-BA3A-88F3D30439DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {94B1B1E4-C47D-4AA7-BA3A-88F3D30439DF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {94B1B1E4-C47D-4AA7-BA3A-88F3D30439DF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {94B1B1E4-C47D-4AA7-BA3A-88F3D30439DF}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE