From 29e3c6aceeb4a4ce5560233b2a73d0a6203de2c5 Mon Sep 17 00:00:00 2001 From: Infi Date: Sat, 24 Aug 2024 19:52:53 +0200 Subject: [PATCH] Redo the message protocols to work with SSE data directly --- ChatTwo/Http/MessageProtocol/DataStructure.cs | 19 ++++++++++ .../Http/MessageProtocol/OutgoingMessage.cs | 27 ++++++------- ChatTwo/Http/Processing.cs | 8 +--- ChatTwo/Http/RouteController.cs | 9 +++-- ChatTwo/Http/SSEConnection.cs | 12 +++--- ChatTwo/Http/ServerCore.cs | 29 +++++++++++--- ChatTwo/Http/static/start.js | 10 +++-- ChatTwo/Ui/ChatLogWindow.cs | 38 +++++++++++-------- 8 files changed, 98 insertions(+), 54 deletions(-) create mode 100644 ChatTwo/Http/MessageProtocol/DataStructure.cs diff --git a/ChatTwo/Http/MessageProtocol/DataStructure.cs b/ChatTwo/Http/MessageProtocol/DataStructure.cs new file mode 100644 index 0000000..bd60411 --- /dev/null +++ b/ChatTwo/Http/MessageProtocol/DataStructure.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +namespace ChatTwo.Http.MessageProtocol; + +public struct SwitchChannel(string name) +{ + [JsonProperty("channel")] public string Name = name; +} + +public struct Messages(MessageResponse[] set) +{ + [JsonProperty("messages")] public MessageResponse[] Set = set; +} + +public struct MessageResponse() +{ + [JsonProperty("timestamp")] public string Timestamp = ""; + [JsonProperty("messageHTML")] public string Message = ""; +} \ No newline at end of file diff --git a/ChatTwo/Http/MessageProtocol/OutgoingMessage.cs b/ChatTwo/Http/MessageProtocol/OutgoingMessage.cs index 0b5084f..22625c5 100644 --- a/ChatTwo/Http/MessageProtocol/OutgoingMessage.cs +++ b/ChatTwo/Http/MessageProtocol/OutgoingMessage.cs @@ -1,21 +1,22 @@ -using Newtonsoft.Json; +using System.Text; +using Newtonsoft.Json; namespace ChatTwo.Http.MessageProtocol; -public struct MessageResponse() -{ - [JsonProperty("timestamp")] public string Timestamp = ""; - [JsonProperty("messageHTML")] public string Message = ""; -} +public class CloseEvent() : BaseEvent("close"); -public class NewMessage(MessageResponse[] messages) : BaseMessage(MessageName) -{ - private const string MessageName = "chat-message"; +public class SwitchChannelEvent(SwitchChannel switchChannel) : BaseEvent("switch-channel", JsonConvert.SerializeObject(switchChannel)); - [JsonProperty("messages")] public MessageResponse[] Messages { get; set; } = messages; -} +public class NewMessageEvent(Messages messages) : BaseEvent("new-message", JsonConvert.SerializeObject(messages)); -public class BaseMessage(string messageType) +public class BaseEvent(string eventType, string? data = null) { - [JsonProperty("messageType")] public string MessageType { get; set; } = messageType; + private string Event = eventType; + private string Data = data ?? "0"; // SSE requires data on each response + + public byte[] Build() + { + // SSE always ends with \n\n + return Encoding.UTF8.GetBytes($"event: {Event}\ndata: {Data}\n\n"); + } } \ No newline at end of file diff --git a/ChatTwo/Http/Processing.cs b/ChatTwo/Http/Processing.cs index c0c48e8..ccc3bdb 100644 --- a/ChatTwo/Http/Processing.cs +++ b/ChatTwo/Http/Processing.cs @@ -17,13 +17,9 @@ public class Processing Plugin = plugin; } - public string ReadChannelName() + public string ReadChannelName(Chunk[] channelName) { - var messages = new List(); - foreach (var chunk in Plugin.ChatLogWindow.ReadChannelName(Plugin.ChatLogWindow.CurrentTab)) - messages.Add(ProcessChunk(chunk, noColor: true)); - - return string.Join("", messages); + return string.Join("", channelName.Select(chunk => ProcessChunk(chunk, noColor: true))); } internal async Task ReadMessageList() diff --git a/ChatTwo/Http/RouteController.cs b/ChatTwo/Http/RouteController.cs index 12f3054..d459e6f 100644 --- a/ChatTwo/Http/RouteController.cs +++ b/ChatTwo/Http/RouteController.cs @@ -35,7 +35,8 @@ public class RouteController Core.HostContext.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/send", ReceiveMessage, ExceptionRoute); Core.HostContext.Routes.PostAuthentication.Parameter.Add(HttpMethod.GET, "/emote/{name}", GetEmote, ExceptionRoute); - Core.HostContext.Routes.PreAuthentication.Parameter.Add(HttpMethod.GET, "/sse", StartServerEvent, ExceptionRoute); + // Server-Sent Events Route + Core.HostContext.Routes.PostAuthentication.Static.Add(HttpMethod.GET, "/sse", NewServerEvent, ExceptionRoute); } private async Task ExceptionRoute(HttpContextBase ctx, Exception _) @@ -153,7 +154,7 @@ public class RouteController await ctx.Response.Send("Message was send to the channel."); } - private async Task StartServerEvent(HttpContextBase ctx) + private async Task NewServerEvent(HttpContextBase ctx) { try { @@ -164,7 +165,9 @@ public class RouteController // TODO Check if reconnect or new connection var messages = await WebserverUtil.FrameworkWrapper(Core.Processing.ReadMessageList); - sse.OutboundStack.Push(new NewMessage(messages.ToArray())); + var channelName = await Plugin.Framework.RunOnTick(() => Core.Processing.ReadChannelName(Plugin.ChatLogWindow.PreviousChannel)); + sse.OutboundQueue.Enqueue(new NewMessageEvent(new Messages(messages))); + sse.OutboundQueue.Enqueue(new SwitchChannelEvent(new SwitchChannel(channelName))); await sse.HandleEventLoop(ctx); diff --git a/ChatTwo/Http/SSEConnection.cs b/ChatTwo/Http/SSEConnection.cs index 046d588..f495c32 100644 --- a/ChatTwo/Http/SSEConnection.cs +++ b/ChatTwo/Http/SSEConnection.cs @@ -1,6 +1,5 @@ using System.Text; using ChatTwo.Http.MessageProtocol; -using Newtonsoft.Json; using WatsonWebserver.Core; namespace ChatTwo.Http; @@ -12,7 +11,7 @@ public class SSEConnection private readonly CancellationToken Token; public bool Done; - public readonly Stack OutboundStack = new(); + public readonly Queue OutboundQueue = new(); public SSEConnection(CancellationToken token) { @@ -34,11 +33,10 @@ public class SSEConnection if (Token.IsCancellationRequested) return; - if (!OutboundStack.TryPop(out var message)) + if (!OutboundQueue.TryDequeue(out var outgoingEvent)) continue; - var data = JsonConvert.SerializeObject(message); - await ctx.Response.SendChunk(Encoding.UTF8.GetBytes($"id: {Index}\ndata: {data}\n\n"), Token); + await ctx.Response.SendChunk(outgoingEvent.Build(), Token); Index++; } } @@ -48,12 +46,12 @@ public class SSEConnection } catch (Exception ex) { - Plugin.Log.Error(ex, "Event queued failed to continue"); + Plugin.Log.Error(ex, "SSE handler failed."); } finally { // "No Content" (204) didn't work for Firefox, so manually closing the connection on client side - await ctx.Response.SendFinalChunk("data: closing\nevent: close\n\n"u8.ToArray()); + await ctx.Response.SendFinalChunk(new CloseEvent().Build()); Done = true; } diff --git a/ChatTwo/Http/ServerCore.cs b/ChatTwo/Http/ServerCore.cs index f8050e8..a8f023a 100644 --- a/ChatTwo/Http/ServerCore.cs +++ b/ChatTwo/Http/ServerCore.cs @@ -45,14 +45,31 @@ public class ServerCore : IAsyncDisposable { Plugin.Framework.RunOnTick(() => { - var bundledMessage = new NewMessage([Processing.ReadMessageContent(message)]); + var bundledResponse = new NewMessageEvent(new Messages([Processing.ReadMessageContent(message)])); foreach (var eventServer in EventConnections) - eventServer.OutboundStack.Push(bundledMessage); + eventServer.OutboundQueue.Enqueue(bundledResponse); }); } catch (Exception ex) { - Plugin.Log.Error(ex, "Send message to websockets failed."); + Plugin.Log.Error(ex, "Sending message over SSE failed."); + } + } + + internal void SendChannelSwitch(Chunk[] channelName) + { + try + { + Plugin.Framework.RunOnTick(() => + { + var bundledResponse = new SwitchChannelEvent(new SwitchChannel(Processing.ReadChannelName(channelName))); + foreach (var eventServer in EventConnections) + eventServer.OutboundQueue.Enqueue(bundledResponse); + }); + } + catch (Exception ex) + { + Plugin.Log.Error(ex, "Sending channel switch over SSE failed."); } } #endregion @@ -101,13 +118,13 @@ public class ServerCore : IAsyncDisposable public async ValueTask DisposeAsync() { await TokenSource.CancelAsync(); + HostContext.Stop(); - foreach (var eventServer in EventConnections) + // We get a copy, so that the original can be cleaned up succesfully + foreach (var eventServer in EventConnections.ToArray()) await eventServer.DisposeAsync(); - HostContext.Stop(); HostContext.Dispose(); - RouteController.Dispose(); } } \ No newline at end of file diff --git a/ChatTwo/Http/static/start.js b/ChatTwo/Http/static/start.js index f645b8a..9183ba6 100644 --- a/ChatTwo/Http/static/start.js +++ b/ChatTwo/Http/static/start.js @@ -3,18 +3,22 @@ class SSEConnection { constructor() { this.socket = new EventSource('/sse', ); - this.socket.addEventListener('close', (e) => { + this.socket.addEventListener('close', (event) => { console.log("Closing SSE connection.") this.socket.close() }); - this.socket.onmessage = (event) => { + this.socket.addEventListener('switch-channel', (event) => { + updateChannelHint(JSON.parse(event.data).channel) + }); + + this.socket.addEventListener('new-message', (event) => { let eventData = JSON.parse(event.data); for (let message of eventData.messages) { addMessage(message); } - }; + }); } send(message) { diff --git a/ChatTwo/Ui/ChatLogWindow.cs b/ChatTwo/Ui/ChatLogWindow.cs index 7826586..2c42d15 100644 --- a/ChatTwo/Ui/ChatLogWindow.cs +++ b/ChatTwo/Ui/ChatLogWindow.cs @@ -68,6 +68,9 @@ public sealed class ChatLogWindow : Window private int AutoCompleteSelection; private bool AutoCompleteShouldScroll; + // Used to detect channel changes for the webinterface + public Chunk[] PreviousChannel = []; + public int CursorPos; public Vector2 LastWindowPos { get; private set; } = Vector2.Zero; @@ -227,6 +230,7 @@ public sealed class ChatLogWindow : Window Plugin.Log.Warning($"Channel was set to an invalid value '{targetChannel}', ignoring"); return; } + if (info.Permanent) SetChannel(targetChannel); else @@ -751,6 +755,14 @@ public sealed class ChatLogWindow : Window private void DrawChannelName(Tab? activeTab) { + var currentChannel = ReadChannelName(activeTab); + + if (!currentChannel.SequenceEqual(PreviousChannel)) + { + PreviousChannel = currentChannel; + Plugin.ServerCore?.SendChannelSwitch(currentChannel); + } + DrawChunks(ReadChannelName(activeTab)); } @@ -766,13 +778,13 @@ public sealed class ChatLogWindow : Window playerName = HashPlayer(TellTarget.Name, TellTarget.World); var world = WorldSheet.GetRow(TellTarget.World)?.Name?.RawString ?? "???"; - channelNameChunks = new Chunk[] - { + channelNameChunks = + [ new TextChunk(ChunkSource.None, null, "Tell "), new TextChunk(ChunkSource.None, null, playerName), new IconChunk(ChunkSource.None, null, BitmapFontIcon.CrossWorld), - new TextChunk(ChunkSource.None, null, world), - }; + new TextChunk(ChunkSource.None, null, world) + ]; } else if (TempChannel != null) { @@ -804,17 +816,11 @@ public sealed class ChatLogWindow : Window // // We don't call channel.ToChatType().Name() as it has the // long name as used in the settings window. - channelNameChunks = new Chunk[] - { - new TextChunk(ChunkSource.None, null, channel.IsExtraChatLinkshell() ? $"ECLS [{channel.LinkshellIndex() + 1}]" : channel.ToChatType().Name()) - }; + channelNameChunks = [new TextChunk(ChunkSource.None, null, channel.IsExtraChatLinkshell() ? $"ECLS [{channel.LinkshellIndex() + 1}]" : channel.ToChatType().Name())]; } else if (Plugin.ExtraChat.ChannelOverride is var (overrideName, _)) { - channelNameChunks = new Chunk[] - { - new TextChunk(ChunkSource.None, null, overrideName) - }; + channelNameChunks = [new TextChunk(ChunkSource.None, null, overrideName)]; } else if (ScreenshotMode && Plugin.Functions.Chat.Channel is (InputChannel.Tell, _, var tellPlayerName, var tellWorldId)) { @@ -825,13 +831,13 @@ public sealed class ChatLogWindow : Window var playerName = HashPlayer(tellPlayerName, tellWorldId); var world = WorldSheet.GetRow(tellWorldId)?.Name?.RawString ?? "???"; - channelNameChunks = new Chunk[] - { + channelNameChunks = + [ new TextChunk(ChunkSource.None, null, "Tell "), new TextChunk(ChunkSource.None, null, playerName), new IconChunk(ChunkSource.None, null, BitmapFontIcon.CrossWorld), - new TextChunk(ChunkSource.None, null, world), - }; + new TextChunk(ChunkSource.None, null, world) + ]; } else {