From c929390a47b193c59ca359059e72723d70f0f86f Mon Sep 17 00:00:00 2001 From: Joseph Schultz Date: Sun, 7 May 2023 18:00:32 -0500 Subject: [PATCH 1/7] Cleanup unused `using` directives --- Examples/PresenceExample/Pages/Index.razor | 3 --- Examples/RealtimeExample/Program.cs | 1 - Realtime/Broadcast/BroadcastOptions.cs | 3 --- Realtime/Channel/ChannelOptions.cs | 1 - Realtime/Channel/Push.cs | 6 +----- Realtime/Client.cs | 4 ---- Realtime/ClientOptions.cs | 1 - Realtime/Constants.cs | 2 +- Realtime/CustomContractResolver.cs | 1 - Realtime/Interfaces/IRealtimePush.cs | 1 - Realtime/Interfaces/IRealtimeSocketResponse.cs | 3 +-- Realtime/Models/BaseBroadcast.cs | 2 -- Realtime/Models/BasePresence.cs | 3 --- Realtime/PostgresChanges/PostgresChangesEventArgs.cs | 5 +---- Realtime/PostgresChanges/PostgresChangesResponse.cs | 3 --- Realtime/Presence/RealtimePresenceDiff.cs | 1 - .../Presence/Responses/PresenceStateSocketResponse.cs | 2 -- Realtime/RealtimeBroadcast.cs | 4 ---- Realtime/RealtimeChannel.cs | 4 ---- Realtime/RealtimeSocket.cs | 2 -- Realtime/Socket/SocketResponse.cs | 3 --- Realtime/Socket/SocketResponsePayload.cs | 3 --- Realtime/Utils.cs | 1 - RealtimeTests/Channel.cs | 4 ---- RealtimeTests/Client.cs | 11 ----------- RealtimeTests/Converters.cs | 3 +-- RealtimeTests/Helpers.cs | 5 +---- RealtimeTests/Models/User.cs | 3 +-- RealtimeTests/SocketResponse.cs | 4 +--- 29 files changed, 8 insertions(+), 81 deletions(-) diff --git a/Examples/PresenceExample/Pages/Index.razor b/Examples/PresenceExample/Pages/Index.razor index 51f1f78..65a366c 100644 --- a/Examples/PresenceExample/Pages/Index.razor +++ b/Examples/PresenceExample/Pages/Index.razor @@ -3,10 +3,7 @@ @using PresenceExample.Components; @using PresenceExample.Models; @using Supabase.Realtime; -@using Supabase.Realtime.Broadcast; @using Supabase.Realtime.Models; -@using Supabase.Realtime.Presence; -@using System.Diagnostics; @using static Supabase.Realtime.Socket.SocketStateChangedEventArgs; @inject IJSRuntime JS; @inject Client realtime; diff --git a/Examples/RealtimeExample/Program.cs b/Examples/RealtimeExample/Program.cs index ce62aa9..d954678 100644 --- a/Examples/RealtimeExample/Program.cs +++ b/Examples/RealtimeExample/Program.cs @@ -1,7 +1,6 @@ using RealtimeExample.Models; using Supabase.Realtime; using Supabase.Realtime.Channel; -using Supabase.Realtime.Socket; using System; using System.Linq; using System.Threading.Tasks; diff --git a/Realtime/Broadcast/BroadcastOptions.cs b/Realtime/Broadcast/BroadcastOptions.cs index df32151..8829aee 100644 --- a/Realtime/Broadcast/BroadcastOptions.cs +++ b/Realtime/Broadcast/BroadcastOptions.cs @@ -1,7 +1,4 @@ using Newtonsoft.Json; -using System; -using System.Collections.Generic; -using System.Text; namespace Supabase.Realtime.Broadcast { diff --git a/Realtime/Channel/ChannelOptions.cs b/Realtime/Channel/ChannelOptions.cs index fcc6897..f436057 100644 --- a/Realtime/Channel/ChannelOptions.cs +++ b/Realtime/Channel/ChannelOptions.cs @@ -1,7 +1,6 @@ using Newtonsoft.Json; using System; using System.Collections.Generic; -using System.Text; namespace Supabase.Realtime.Channel { diff --git a/Realtime/Channel/Push.cs b/Realtime/Channel/Push.cs index 890ad90..88f33f0 100644 --- a/Realtime/Channel/Push.cs +++ b/Realtime/Channel/Push.cs @@ -1,10 +1,6 @@ -using Newtonsoft.Json; -using Supabase.Realtime.Interfaces; +using Supabase.Realtime.Interfaces; using Supabase.Realtime.Socket; -using Supabase.Realtime.Socket.Responses; using System; -using System.Diagnostics; -using System.Threading.Tasks; using System.Timers; namespace Supabase.Realtime.Channel diff --git a/Realtime/Client.cs b/Realtime/Client.cs index eebf261..cec5aeb 100644 --- a/Realtime/Client.cs +++ b/Realtime/Client.cs @@ -1,17 +1,13 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Diagnostics; using System.Net.WebSockets; using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Converters; -using Supabase.Realtime.Broadcast; using Supabase.Realtime.Channel; using Supabase.Realtime.Interfaces; -using Supabase.Realtime.Models; using Supabase.Realtime.PostgresChanges; -using Supabase.Realtime.Presence; using Supabase.Realtime.Socket; namespace Supabase.Realtime diff --git a/Realtime/ClientOptions.cs b/Realtime/ClientOptions.cs index 6cc4205..4733a57 100644 --- a/Realtime/ClientOptions.cs +++ b/Realtime/ClientOptions.cs @@ -3,7 +3,6 @@ using System.Diagnostics; using System.Globalization; using Newtonsoft.Json; -using Newtonsoft.Json.Converters; using Supabase.Realtime.Socket; namespace Supabase.Realtime diff --git a/Realtime/Constants.cs b/Realtime/Constants.cs index 9fc7696..4d5771b 100644 --- a/Realtime/Constants.cs +++ b/Realtime/Constants.cs @@ -1,5 +1,5 @@ using Supabase.Core.Attributes; -using System; + namespace Supabase.Realtime { public static class Constants diff --git a/Realtime/CustomContractResolver.cs b/Realtime/CustomContractResolver.cs index 07d0e38..417193d 100644 --- a/Realtime/CustomContractResolver.cs +++ b/Realtime/CustomContractResolver.cs @@ -6,7 +6,6 @@ using Newtonsoft.Json.Serialization; using Postgrest.Attributes; using Supabase.Realtime.Converters; -using Supabase.Realtime.Models; namespace Supabase.Realtime { diff --git a/Realtime/Interfaces/IRealtimePush.cs b/Realtime/Interfaces/IRealtimePush.cs index 411eda1..0fba5cf 100644 --- a/Realtime/Interfaces/IRealtimePush.cs +++ b/Realtime/Interfaces/IRealtimePush.cs @@ -1,6 +1,5 @@ using Supabase.Realtime.Socket; using System; -using System.Threading.Tasks; namespace Supabase.Realtime.Interfaces { diff --git a/Realtime/Interfaces/IRealtimeSocketResponse.cs b/Realtime/Interfaces/IRealtimeSocketResponse.cs index e428aee..7526a9d 100644 --- a/Realtime/Interfaces/IRealtimeSocketResponse.cs +++ b/Realtime/Interfaces/IRealtimeSocketResponse.cs @@ -1,5 +1,4 @@ -using Postgrest.Models; -using Supabase.Realtime.Socket; +using Supabase.Realtime.Socket; namespace Supabase.Realtime.Interfaces { diff --git a/Realtime/Models/BaseBroadcast.cs b/Realtime/Models/BaseBroadcast.cs index a310865..35f013d 100644 --- a/Realtime/Models/BaseBroadcast.cs +++ b/Realtime/Models/BaseBroadcast.cs @@ -1,7 +1,5 @@ using Newtonsoft.Json; -using System; using System.Collections.Generic; -using System.Text; namespace Supabase.Realtime.Models { diff --git a/Realtime/Models/BasePresence.cs b/Realtime/Models/BasePresence.cs index 04c15d0..c696fba 100644 --- a/Realtime/Models/BasePresence.cs +++ b/Realtime/Models/BasePresence.cs @@ -1,7 +1,4 @@ using Newtonsoft.Json; -using System; -using System.Collections.Generic; -using System.Text; namespace Supabase.Realtime.Models { diff --git a/Realtime/PostgresChanges/PostgresChangesEventArgs.cs b/Realtime/PostgresChanges/PostgresChangesEventArgs.cs index 98ad4fe..da29785 100644 --- a/Realtime/PostgresChanges/PostgresChangesEventArgs.cs +++ b/Realtime/PostgresChanges/PostgresChangesEventArgs.cs @@ -1,7 +1,4 @@ -using Supabase.Realtime.Socket; -using System; -using System.Collections.Generic; -using System.Text; +using System; namespace Supabase.Realtime.PostgresChanges { diff --git a/Realtime/PostgresChanges/PostgresChangesResponse.cs b/Realtime/PostgresChanges/PostgresChangesResponse.cs index c4b554e..cee8c28 100644 --- a/Realtime/PostgresChanges/PostgresChangesResponse.cs +++ b/Realtime/PostgresChanges/PostgresChangesResponse.cs @@ -1,9 +1,6 @@ using Newtonsoft.Json; using Postgrest.Models; using Supabase.Realtime.Socket; -using System; -using System.Collections.Generic; -using System.Text; namespace Supabase.Realtime.PostgresChanges { diff --git a/Realtime/Presence/RealtimePresenceDiff.cs b/Realtime/Presence/RealtimePresenceDiff.cs index 064da1e..99e1eea 100644 --- a/Realtime/Presence/RealtimePresenceDiff.cs +++ b/Realtime/Presence/RealtimePresenceDiff.cs @@ -1,6 +1,5 @@ using Newtonsoft.Json; using Supabase.Realtime.Models; -using Supabase.Realtime.Presence.Responses; using Supabase.Realtime.Socket; using System.Collections.Generic; diff --git a/Realtime/Presence/Responses/PresenceStateSocketResponse.cs b/Realtime/Presence/Responses/PresenceStateSocketResponse.cs index 2e12e12..e0d1f9a 100644 --- a/Realtime/Presence/Responses/PresenceStateSocketResponse.cs +++ b/Realtime/Presence/Responses/PresenceStateSocketResponse.cs @@ -1,9 +1,7 @@ using Newtonsoft.Json; using Supabase.Realtime.Models; using Supabase.Realtime.Socket; -using System; using System.Collections.Generic; -using System.Text; namespace Supabase.Realtime.Presence.Responses { diff --git a/Realtime/RealtimeBroadcast.cs b/Realtime/RealtimeBroadcast.cs index d515d7e..5ee2d3b 100644 --- a/Realtime/RealtimeBroadcast.cs +++ b/Realtime/RealtimeBroadcast.cs @@ -1,13 +1,9 @@ using Newtonsoft.Json; -using Postgrest; using Supabase.Realtime.Broadcast; using Supabase.Realtime.Interfaces; using Supabase.Realtime.Models; -using Supabase.Realtime.Presence; using Supabase.Realtime.Socket; using System; -using System.Collections.Generic; -using System.Text; using System.Threading.Tasks; using static Supabase.Realtime.Constants; diff --git a/Realtime/RealtimeChannel.cs b/Realtime/RealtimeChannel.cs index 1a04c65..e9b6fb3 100644 --- a/Realtime/RealtimeChannel.cs +++ b/Realtime/RealtimeChannel.cs @@ -1,13 +1,9 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Channels; using System.Threading.Tasks; using System.Timers; using Newtonsoft.Json; -using Postgrest.Models; -using Postgrest.Responses; using Supabase.Realtime.Broadcast; using Supabase.Realtime.Channel; using Supabase.Realtime.Interfaces; diff --git a/Realtime/RealtimeSocket.cs b/Realtime/RealtimeSocket.cs index e494fef..88002ed 100644 --- a/Realtime/RealtimeSocket.cs +++ b/Realtime/RealtimeSocket.cs @@ -1,6 +1,4 @@ using Newtonsoft.Json; -using Postgrest.Models; -using Supabase.Realtime.Converters; using Supabase.Realtime.Interfaces; using Supabase.Realtime.Socket; using System; diff --git a/Realtime/Socket/SocketResponse.cs b/Realtime/Socket/SocketResponse.cs index 269e358..af0efe9 100644 --- a/Realtime/Socket/SocketResponse.cs +++ b/Realtime/Socket/SocketResponse.cs @@ -1,8 +1,5 @@ using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -using Postgrest.Models; using Supabase.Realtime.Interfaces; -using Supabase.Realtime.PostgresChanges; using static Supabase.Realtime.Constants; namespace Supabase.Realtime.Socket diff --git a/Realtime/Socket/SocketResponsePayload.cs b/Realtime/Socket/SocketResponsePayload.cs index 7b0d522..e34d493 100644 --- a/Realtime/Socket/SocketResponsePayload.cs +++ b/Realtime/Socket/SocketResponsePayload.cs @@ -1,9 +1,6 @@ using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; -using Postgrest.Models; using System; using System.Collections.Generic; -using System.Drawing; using static Supabase.Realtime.Constants; namespace Supabase.Realtime.Socket diff --git a/Realtime/Utils.cs b/Realtime/Utils.cs index ab4268f..f8c17bf 100644 --- a/Realtime/Utils.cs +++ b/Realtime/Utils.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Reflection; namespace Supabase.Realtime { diff --git a/RealtimeTests/Channel.cs b/RealtimeTests/Channel.cs index 11c720c..92f2fd3 100644 --- a/RealtimeTests/Channel.cs +++ b/RealtimeTests/Channel.cs @@ -1,17 +1,13 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; using Newtonsoft.Json; using Postgrest.Interfaces; using RealtimeTests.Models; -using Supabase.Realtime; using Supabase.Realtime.Channel; using Supabase.Realtime.Models; -using Supabase.Realtime.PostgresChanges; -using Supabase.Realtime.Presence; using static Supabase.Realtime.Constants; namespace RealtimeTests diff --git a/RealtimeTests/Client.cs b/RealtimeTests/Client.cs index a6911a0..82937f9 100644 --- a/RealtimeTests/Client.cs +++ b/RealtimeTests/Client.cs @@ -1,19 +1,8 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Newtonsoft.Json; using Postgrest.Interfaces; -using RealtimeTests.Models; using Supabase.Gotrue; -using Supabase.Realtime; -using Supabase.Realtime.Channel; -using Supabase.Realtime.PostgresChanges; -using Supabase.Realtime.Socket; using static Supabase.Realtime.Constants; -using Constants = Supabase.Realtime.Constants; namespace RealtimeTests { diff --git a/RealtimeTests/Converters.cs b/RealtimeTests/Converters.cs index 401e5f3..4ecaaea 100644 --- a/RealtimeTests/Converters.cs +++ b/RealtimeTests/Converters.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using Microsoft.VisualStudio.TestTools.UnitTesting; using Newtonsoft.Json; using Supabase.Realtime; diff --git a/RealtimeTests/Helpers.cs b/RealtimeTests/Helpers.cs index 87638b3..2b35c5f 100644 --- a/RealtimeTests/Helpers.cs +++ b/RealtimeTests/Helpers.cs @@ -1,12 +1,9 @@ -using Supabase; -using Supabase.Gotrue; +using Supabase.Gotrue; using Supabase.Realtime; using Supabase.Realtime.Socket; using System; using System.Collections.Generic; -using System.Text; using System.Threading.Tasks; -using Client = Supabase.Realtime.Client; namespace RealtimeTests { diff --git a/RealtimeTests/Models/User.cs b/RealtimeTests/Models/User.cs index 603026c..3cac8ed 100644 --- a/RealtimeTests/Models/User.cs +++ b/RealtimeTests/Models/User.cs @@ -1,5 +1,4 @@ -using System; -using Postgrest.Attributes; +using Postgrest.Attributes; using Postgrest.Models; namespace RealtimeTests.Models diff --git a/RealtimeTests/SocketResponse.cs b/RealtimeTests/SocketResponse.cs index c143e05..1420a40 100644 --- a/RealtimeTests/SocketResponse.cs +++ b/RealtimeTests/SocketResponse.cs @@ -1,6 +1,4 @@ -using System; -using System.Threading.Tasks; -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using Newtonsoft.Json; using Supabase.Realtime.Socket; From 0fee331f12690c7c1e7cccdaeed05ca0becda71f Mon Sep 17 00:00:00 2001 From: Joseph Schultz Date: Sun, 7 May 2023 18:16:28 -0500 Subject: [PATCH 2/7] Enable nullability on tests (prior to refactor) --- RealtimeTests/{Channel.cs => ChannelTests.cs} | 127 +++++++++--------- RealtimeTests/{Client.cs => ClientTests.cs} | 58 ++++---- .../{Converters.cs => ConverterTests.cs} | 12 +- RealtimeTests/Helpers.cs | 30 ++--- RealtimeTests/Models/Todo.cs | 8 +- RealtimeTests/Models/User.cs | 4 +- RealtimeTests/RealtimeTests.csproj | 2 + ...cketResponse.cs => SocketResponseTests.cs} | 4 +- 8 files changed, 123 insertions(+), 122 deletions(-) rename RealtimeTests/{Channel.cs => ChannelTests.cs} (64%) rename RealtimeTests/{Client.cs => ClientTests.cs} (57%) rename RealtimeTests/{Converters.cs => ConverterTests.cs} (85%) rename RealtimeTests/{SocketResponse.cs => SocketResponseTests.cs} (90%) diff --git a/RealtimeTests/Channel.cs b/RealtimeTests/ChannelTests.cs similarity index 64% rename from RealtimeTests/Channel.cs rename to RealtimeTests/ChannelTests.cs index 92f2fd3..f612940 100644 --- a/RealtimeTests/Channel.cs +++ b/RealtimeTests/ChannelTests.cs @@ -6,7 +6,8 @@ using Newtonsoft.Json; using Postgrest.Interfaces; using RealtimeTests.Models; -using Supabase.Realtime.Channel; +using Supabase.Realtime; +using Supabase.Realtime.Interfaces; using Supabase.Realtime.Models; using static Supabase.Realtime.Constants; @@ -25,26 +26,26 @@ public class BroadcastExample : BaseBroadcast } [TestClass] - public class Channel + public class ChannelTests { - private IPostgrestClient RestClient; - private Supabase.Realtime.Client SocketClient; + private IPostgrestClient? restClient; + private IRealtimeClient? socketClient; [TestInitialize] public async Task InitializeTest() { var session = await Helpers.GetSession(); - RestClient = Helpers.RestClient(session.AccessToken); - SocketClient = Helpers.SocketClient(); + restClient = Helpers.RestClient(session!.AccessToken!); + socketClient = Helpers.SocketClient(); - await SocketClient.ConnectAsync(); - SocketClient.SetAuth(session.AccessToken); + await socketClient!.ConnectAsync(); + socketClient!.SetAuth(session.AccessToken!); } [TestCleanup] public void CleanupTest() { - SocketClient.Disconnect(); + socketClient!.Disconnect(); } [TestMethod("Channel: Can create presence")] @@ -56,9 +57,9 @@ public async Task ClientCanCreatePresence() var guid1 = Guid.NewGuid().ToString(); var guid2 = Guid.NewGuid().ToString(); - var channel1 = SocketClient.Channel("online-users"); + var channel1 = socketClient!.Channel("online-users"); var presence1 = channel1.Register(guid1); - presence1.OnSync += (sender, args) => + presence1.OnSync += (_, _) => { var state = presence1.CurrentState; if (state.ContainsKey(guid2) && state[guid2].First().Time != null) @@ -71,7 +72,7 @@ public async Task ClientCanCreatePresence() await client2.ConnectAsync(); var channel2 = client2.Channel("online-users"); var presence2 = channel2.Register(guid2); - presence2.OnSync += (sender, args) => + presence2.OnSync += (_, _) => { var state = presence2.CurrentState; if (state.ContainsKey(guid1) && state[guid1].First().Time != null) @@ -98,12 +99,12 @@ public async Task ClientCanListenForBroadcast() var guid1 = Guid.NewGuid().ToString(); var guid2 = Guid.NewGuid().ToString(); - var channel1 = SocketClient.Channel("online-users"); + var channel1 = socketClient!.Channel("online-users"); var broadcast1 = channel1.Register(true, true); - broadcast1.OnBroadcast += (sender, args) => + broadcast1.OnBroadcast += (_, _) => { var broadcast = broadcast1.Current(); - if (broadcast.UserId != guid1 && broadcast.Event == "user") + if (broadcast?.UserId != guid1 && broadcast?.Event == "user") { tsc.TrySetResult(true); } @@ -113,10 +114,10 @@ public async Task ClientCanListenForBroadcast() await client2.ConnectAsync(); var channel2 = client2.Channel("online-users"); var broadcast2 = channel2.Register(true, true); - broadcast2.OnBroadcast += (sender, args) => + broadcast2.OnBroadcast += (_, _) => { var broadcast = broadcast2.Current(); - if (broadcast.UserId != guid2 && broadcast.Event == "user") + if (broadcast?.UserId != guid2 && broadcast?.Event == "user") { tsc2.TrySetResult(true); } @@ -136,17 +137,17 @@ public async Task ChannelPayloadReturnsModel() { var tsc = new TaskCompletionSource(); - var channel = SocketClient.Channel("realtime", "public", "*"); + var channel = socketClient!.Channel("realtime", "public", "*"); - channel.OnInsert += (sender, e) => + channel.OnInsert += (_, e) => { - var model = e.Response.Model(); - tsc.SetResult(model is Todo); + var model = e.Response?.Model(); + tsc.SetResult(model != null); }; await channel.Subscribe(); - await RestClient.Table().Insert(new Todo { UserId = 1, Details = "Client Models a response? ✅" }); + await restClient!.Table().Insert(new Todo { UserId = 1, Details = "Client Models a response? ✅" }); var check = await tsc.Task; Assert.IsTrue(check); @@ -157,8 +158,8 @@ public async Task ChannelCloseEventHandler() { var tsc = new TaskCompletionSource(); - var channel = SocketClient.Channel("realtime", "public", "todos"); - channel.OnClose += (object sender, ChannelStateChangedEventArgs args) => + var channel = socketClient!.Channel("realtime", "public", "todos"); + channel.OnClose += (_, args) => { tsc.SetResult(ChannelState.Closed == args.State); }; @@ -176,12 +177,12 @@ public async Task ChannelReceivesInsertCallback() { var tsc = new TaskCompletionSource(); - var channel = SocketClient.Channel("realtime", "public", "todos"); + var channel = socketClient!.Channel("realtime", "public", "todos"); - channel.OnInsert += (s, args) => tsc.SetResult(true); + channel.OnInsert += (_, _) => tsc.SetResult(true); await channel.Subscribe(); - await RestClient.Table().Insert(new Todo { UserId = 1, Details = "Client receives insert callback? ✅" }); + await restClient!.Table().Insert(new Todo { UserId = 1, Details = "Client receives insert callback? ✅" }); var check = await tsc.Task; Assert.IsTrue(check); @@ -192,31 +193,35 @@ public async Task ChannelReceivesUpdateCallback() { var tsc = new TaskCompletionSource(); - var result = await RestClient.Table().Order(x => x.InsertedAt, Postgrest.Constants.Ordering.Descending).Get(); + var result = await restClient!.Table().Order(x => x.InsertedAt!, Postgrest.Constants.Ordering.Descending).Get(); var model = result.Models.First(); var oldDetails = model.Details; var newDetails = $"I'm an updated item ✏️ - {DateTime.Now}"; - var channel = SocketClient.Channel("realtime", "public", "todos"); + var channel = socketClient!.Channel("realtime", "public", "todos"); - channel.OnUpdate += (s, args) => + channel.OnUpdate += (_, args) => { - var oldModel = args.Response.OldModel(); + var oldModel = args.Response?.OldModel(); - Assert.AreEqual(oldDetails, oldModel.Details); + Assert.AreEqual(oldDetails, oldModel?.Details); - var updated = args.Response.Model(); - Assert.AreEqual(newDetails, updated.Details); - Assert.AreEqual(model.Id, updated.Id); - Assert.AreEqual(model.UserId, updated.UserId); + var updated = args.Response?.Model(); + Assert.AreEqual(newDetails, updated?.Details); + + if (updated != null) + { + Assert.AreEqual(model.Id, updated.Id); + Assert.AreEqual(model.UserId, updated.UserId); + } tsc.SetResult(true); }; await channel.Subscribe(); - await RestClient.Table() - .Set(x => x.Details, newDetails) + await restClient.Table() + .Set(x => x.Details!, newDetails) .Match(model) .Update(); @@ -229,65 +234,65 @@ public async Task ChannelReceivesDeleteCallback() { var tsc = new TaskCompletionSource(); - var channel = SocketClient.Channel("realtime", "public", "todos"); + var channel = socketClient!.Channel("realtime", "public", "todos"); - channel.OnDelete += (s, args) => + channel.OnDelete += (_, _) => { tsc.SetResult(true); }; await channel.Subscribe(); - var result = await RestClient.Table().Get(); + var result = await restClient!.Table().Get(); var model = result.Models.Last(); - await RestClient.Table().Match(model).Delete(); + await restClient.Table().Match(model).Delete(); var check = await tsc.Task; Assert.IsTrue(check); } [TestMethod("Channel: Supports WALRUS Array Changes")] - public async Task ChannelSupportsWALRUSArray() + public async Task ChannelSupportsWalrusArray() { - Todo result = null; + Todo? result = null; var tsc = new TaskCompletionSource(); - var channel = SocketClient.Channel("realtime", "public", "todos"); + var channel = socketClient!.Channel("realtime", "public", "todos"); var numbers = new List { 4, 5, 6 }; await channel.Subscribe(); - channel.OnInsert += (s, args) => + channel.OnInsert += (_, args) => { - result = args.Response.Model(); + result = args.Response?.Model(); tsc.SetResult(true); }; - await RestClient.Table().Insert(new Todo { UserId = 1, Numbers = numbers }); + await restClient!.Table().Insert(new Todo { UserId = 1, Numbers = numbers }); await tsc.Task; - CollectionAssert.AreEqual(numbers, result.Numbers); + CollectionAssert.AreEqual(numbers, result?.Numbers); } [TestMethod("Channel: Sends Join parameters")] public async Task ChannelSendsJoinParameters() { var parameters = new Dictionary { { "key", "value" } }; - var channel = SocketClient.Channel("realtime", "public", "todos", parameters: parameters); + var channel = socketClient!.Channel("realtime", "public", "todos", parameters: parameters); await channel.Subscribe(); - var serialized = JsonConvert.SerializeObject(channel.JoinPush.Payload); + var serialized = JsonConvert.SerializeObject(channel.JoinPush?.Payload); Assert.IsTrue(serialized.Contains("\"key\":\"value\"")); } [TestMethod("Channel: Returns single subscription per unique topic.")] public async Task ChannelJoinsDuplicateSubscription() { - var subscription1 = SocketClient.Channel("realtime", "public", "todos"); - var subscription2 = SocketClient.Channel("realtime", "public", "todos"); - var subscription3 = SocketClient.Channel("realtime", "public", "todos", "user_id", "1"); + var subscription1 = socketClient!.Channel("realtime", "public", "todos"); + var subscription2 = socketClient!.Channel("realtime", "public", "todos"); + var subscription3 = socketClient!.Channel("realtime", "public", "todos", "user_id", "1"); Assert.AreEqual(subscription1.Topic, subscription2.Topic); @@ -296,7 +301,7 @@ public async Task ChannelJoinsDuplicateSubscription() Assert.AreEqual(subscription1.HasJoinedOnce, subscription2.HasJoinedOnce); Assert.AreNotEqual(subscription1.HasJoinedOnce, subscription3.HasJoinedOnce); - var subscription4 = SocketClient.Channel("realtime", "public", "todos"); + var subscription4 = socketClient!.Channel("realtime", "public", "todos"); Assert.AreEqual(subscription1.HasJoinedOnce, subscription4.HasJoinedOnce); } @@ -310,11 +315,11 @@ public async Task ChannelReceivesWildcardCallback() List tasks = new List { insertTsc.Task, updateTsc.Task, deleteTsc.Task }; - var channel = SocketClient.Channel("realtime", "public", "todos"); + var channel = socketClient!.Channel("realtime", "public", "todos"); - channel.OnPostgresChange += (sender, e) => + channel.OnPostgresChange += (_, e) => { - switch (e.Response.Payload.Data.Type) + switch (e.Response?.Payload?.Data?.Type) { case EventType.Insert: insertTsc.SetResult(true); @@ -330,11 +335,11 @@ public async Task ChannelReceivesWildcardCallback() await channel.Subscribe(); - var modeledResponse = await RestClient.Table().Insert(new Todo { UserId = 1, Details = "Client receives wildcard callbacks? ✅" }); + var modeledResponse = await restClient!.Table().Insert(new Todo { UserId = 1, Details = "Client receives wildcard callbacks? ✅" }); var newModel = modeledResponse.Models.First(); - await RestClient.Table().Set(x => x.Details, "And edits.").Match(newModel).Update(); - await RestClient.Table().Match(newModel).Delete(); + await restClient.Table().Set(x => x.Details!, "And edits.").Match(newModel).Update(); + await restClient.Table().Match(newModel).Delete(); await Task.WhenAll(tasks); diff --git a/RealtimeTests/Client.cs b/RealtimeTests/ClientTests.cs similarity index 57% rename from RealtimeTests/Client.cs rename to RealtimeTests/ClientTests.cs index 82937f9..3abdc6f 100644 --- a/RealtimeTests/Client.cs +++ b/RealtimeTests/ClientTests.cs @@ -1,41 +1,37 @@ using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Postgrest.Interfaces; using Supabase.Gotrue; using static Supabase.Realtime.Constants; namespace RealtimeTests { [TestClass] - public class Client + public class ClientTests { - private IPostgrestClient RestClient; - private Supabase.Realtime.Client SocketClient; - private Session Session; - + private Supabase.Realtime.Client? socketClient; + private Session? session; [TestInitialize] public async Task InitializeTest() { - Session = await Helpers.GetSession(); - SocketClient = Helpers.SocketClient(); - RestClient = Helpers.RestClient(Session.AccessToken); + session = await Helpers.GetSession(); + socketClient = Helpers.SocketClient(); - await SocketClient.ConnectAsync(); - SocketClient.SetAuth(Session.AccessToken); + await socketClient!.ConnectAsync(); + socketClient!.SetAuth(session!.AccessToken!); } [TestCleanup] public void CleanupTest() { - SocketClient?.Disconnect(); + socketClient?.Disconnect(); } [TestMethod("Client: Join channels of format: {database}")] public async Task ClientJoinsChannel_DB() { - var channel = SocketClient.Channel("realtime", "*"); + var channel = socketClient!.Channel("realtime", "*"); await channel.Subscribe(); Assert.AreEqual("realtime:*", channel.Topic); @@ -44,7 +40,7 @@ public async Task ClientJoinsChannel_DB() [TestMethod("Client: Join channels of format: {database}:{schema}")] public async Task ClientJoinsChannel_DB_Schema() { - var channel = SocketClient.Channel("realtime", "public"); + var channel = socketClient!.Channel(schema: "public"); await channel.Subscribe(); Assert.AreEqual("realtime:public", channel.Topic); @@ -53,7 +49,7 @@ public async Task ClientJoinsChannel_DB_Schema() [TestMethod("Client: Join channels of format: {database}:{schema}:{table}")] public async Task ClientJoinsChannel_DB_Schema_Table() { - var channel = SocketClient.Channel("realtime", "public", "users"); + var channel = socketClient!.Channel("realtime", "public", "users"); await channel.Subscribe(); Assert.AreEqual("realtime:public:users", channel.Topic); @@ -62,7 +58,7 @@ public async Task ClientJoinsChannel_DB_Schema_Table() [TestMethod("Client: Join channels of format: {database}:{schema}:{table}:{col}=eq.{val}")] public async Task ClientJoinsChannel_DB_Schema_Table_Query() { - var channel = SocketClient.Channel("realtime", "public", "users", "id", "1"); + var channel = socketClient!.Channel("realtime", "public", "users", "id", "1"); await channel.Subscribe(); Assert.AreEqual("realtime:public:users:id=eq.1", channel.Topic); @@ -71,12 +67,12 @@ public async Task ClientJoinsChannel_DB_Schema_Table_Query() [TestMethod("Client: Returns a single instance of a channel based on topic")] public async Task ClientReturnsSingleChannelInstance() { - var channel1 = SocketClient.Channel("realtime", "public", "todos"); + var channel1 = socketClient!.Channel("realtime", "public", "todos"); await channel1.Subscribe(); // Client should return an instance of `realtime:public:todos` that is already joined. - var channel2 = SocketClient.Channel("realtime", "public", "todos"); + var channel2 = socketClient!.Channel("realtime", "public", "todos"); Assert.AreEqual(true, channel2.IsJoined); } @@ -84,28 +80,28 @@ public async Task ClientReturnsSingleChannelInstance() [TestMethod("Client: Removes Channel Subscriptions")] public async Task ClientCanRemoveChannelSubscription() { - var channel1 = SocketClient.Channel("realtime", "public", "todos"); + var channel1 = socketClient!.Channel("realtime", "public", "todos"); await channel1.Subscribe(); // Removing channel should remove the stored instance, so a future instance would need // to resubscribe. - SocketClient.Remove(channel1); + socketClient!.Remove(channel1); - var channel2 = SocketClient.Channel("realtime", "public", "todos"); + var channel2 = socketClient!.Channel("realtime", "public", "todos"); Assert.AreEqual(ChannelState.Closed, channel2.State); } [TestMethod("Client: SetsAuth")] public async Task ClientSetsAuth() { - var channel = SocketClient.Channel("realtime", "public", "todos"); - var channel2 = SocketClient.Channel("realtime", "public", "users"); + var channel = socketClient!.Channel("realtime", "public", "todos"); + var channel2 = socketClient!.Channel("realtime", "public", "users"); - var token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.C8oVtF5DICct_4HcdSKt8pdrxBFMQOAnPpbiiUbaXAY"; + var token = @"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.C8oVtF5DICct_4HcdSKt8pdrxBFMQOAnPpbiiUbaXAY"; // No subscriptions should show a push - SocketClient.SetAuth(token); - foreach (var subscription in SocketClient.Subscriptions.Values) + socketClient!.SetAuth(token); + foreach (var subscription in socketClient!.Subscriptions.Values) { Assert.IsNull(subscription.LastPush); } @@ -113,18 +109,18 @@ public async Task ClientSetsAuth() await channel.Subscribe(); await channel2.Subscribe(); - SocketClient.SetAuth(token); - foreach (var subscription in SocketClient.Subscriptions.Values) + socketClient!.SetAuth(token); + foreach (var subscription in socketClient!.Subscriptions.Values) { - Assert.IsTrue(subscription.LastPush.EventName == CHANNEL_ACCESS_TOKEN); + Assert.IsTrue(subscription?.LastPush?.EventName == CHANNEL_ACCESS_TOKEN); } } [TestMethod("Client: Can reconnect after programmatic disconnect")] public async Task ClientCanReconnectAfterProgrammaticDisconnect() { - SocketClient.Disconnect(); - await SocketClient.ConnectAsync(); + socketClient!.Disconnect(); + await socketClient!.ConnectAsync(); } } } diff --git a/RealtimeTests/Converters.cs b/RealtimeTests/ConverterTests.cs similarity index 85% rename from RealtimeTests/Converters.cs rename to RealtimeTests/ConverterTests.cs index 4ecaaea..3ac2f5e 100644 --- a/RealtimeTests/Converters.cs +++ b/RealtimeTests/ConverterTests.cs @@ -8,15 +8,13 @@ namespace RealtimeTests { internal class TestJson { - [JsonProperty("intArray")] - public List intArray { get; set; } + [JsonProperty("intArray")] public List intArray { get; set; } = new(); - [JsonProperty("stringArray")] - public List stringArray { get; set; } + [JsonProperty("stringArray")] public List stringArray { get; set; } = new(); } [TestClass] - public class Converters + public class ConverterTests { [TestMethod("Support Array Conversions (WALRUS + Backwards Compat.)")] public void SupportArrayConversions() @@ -28,8 +26,8 @@ public void SupportArrayConversions() ContractResolver = new CustomContractResolver() }); - CollectionAssert.AreEqual(new List { 9999, 99, 99999 }, parsed.intArray); - CollectionAssert.AreEqual(new List { "testing", "1", "2" }, parsed.stringArray); + CollectionAssert.AreEqual(new List { 9999, 99, 99999 }, parsed?.intArray); + CollectionAssert.AreEqual(new List { "testing", "1", "2" }, parsed?.stringArray); var intConverter = new IntArrayConverter(); CollectionAssert.AreEqual(new List(), intConverter.Parse("{}")); diff --git a/RealtimeTests/Helpers.cs b/RealtimeTests/Helpers.cs index 2b35c5f..56d155a 100644 --- a/RealtimeTests/Helpers.cs +++ b/RealtimeTests/Helpers.cs @@ -9,40 +9,40 @@ namespace RealtimeTests { internal static class Helpers { - private static string supabasePublicKey => Environment.GetEnvironmentVariable("SUPABASE_PUBLIC_KEY"); - private static string supabaseUrl => Environment.GetEnvironmentVariable("SUPABASE_URL"); + private static string SupabasePublicKey => Environment.GetEnvironmentVariable("SUPABASE_PUBLIC_KEY") ?? string.Empty; + private static string SupabaseUrl => Environment.GetEnvironmentVariable("SUPABASE_URL") ?? "http://localhost:4000"; - private static string supabaseUsername => Environment.GetEnvironmentVariable("SUPABASE_USERNAME"); - private static string supabasePassword => Environment.GetEnvironmentVariable("SUPABASE_PASSWORD"); + private static string SupabaseUsername => Environment.GetEnvironmentVariable("SUPABASE_USERNAME") ?? string.Empty; + private static string SupabasePassword => Environment.GetEnvironmentVariable("SUPABASE_PASSWORD") ?? string.Empty; - private static string socketEndpoint = string.Format("{0}/realtime/v1", supabaseUrl).Replace("https", "wss"); - private static string restEndpoint = string.Format("{0}/rest/v1", supabaseUrl); - private static string authEndpoint = string.Format("{0}/auth/v1", supabaseUrl); + private static readonly string SocketEndpoint = $"{SupabaseUrl}/realtime/v1".Replace("https", "wss"); + private static readonly string RestEndpoint = $"{SupabaseUrl}/rest/v1"; + private static readonly string AuthEndpoint = $"{SupabaseUrl}/auth/v1"; - public static Supabase.Gotrue.Client AuthClient => new Supabase.Gotrue.Client(new Supabase.Gotrue.ClientOptions + private static Supabase.Gotrue.Client AuthClient => new(new ClientOptions { - Url = authEndpoint, - Headers = new Dictionary { { "apiKey", supabasePublicKey } } + Url = AuthEndpoint, + Headers = new Dictionary { { "apiKey", SupabasePublicKey } } }); - public static Postgrest.Client RestClient(string userToken) => new Postgrest.Client(restEndpoint, new Postgrest.ClientOptions + public static Postgrest.Client RestClient(string userToken) => new(RestEndpoint, new Postgrest.ClientOptions { Headers = new Dictionary { { "Authorization", $"Bearer {userToken}" }, - { "apiKey", supabasePublicKey } + { "apiKey", SupabasePublicKey } } }); - public static Task GetSession() => AuthClient.SignInWithPassword(supabaseUsername, supabasePassword); + public static Task GetSession() => AuthClient.SignInWithPassword(SupabaseUsername, SupabasePassword); public static Supabase.Realtime.Client SocketClient() { - var client = new Supabase.Realtime.Client(socketEndpoint, new ClientOptions + var client = new Supabase.Realtime.Client(SocketEndpoint, new ClientOptions { Parameters = new SocketOptionsParameters { - ApiKey = supabasePublicKey + ApiKey = SupabasePublicKey } }); diff --git a/RealtimeTests/Models/Todo.cs b/RealtimeTests/Models/Todo.cs index 623938a..727d255 100644 --- a/RealtimeTests/Models/Todo.cs +++ b/RealtimeTests/Models/Todo.cs @@ -12,15 +12,15 @@ public class Todo : BaseModel public int Id { get; set; } [Column("details")] - public string Details { get; set; } + public string? Details { get; set; } [Column("user_id")] - public int? UserId { get; set; } + public int UserId { get; set; } [Column("numbers")] - public List Numbers { get; set; } + public List? Numbers { get; set; } [Column("inserted_at")] - public DateTime InsertedAt { get; set; } + public DateTime? InsertedAt { get; set; } } } diff --git a/RealtimeTests/Models/User.cs b/RealtimeTests/Models/User.cs index 3cac8ed..77f7ae8 100644 --- a/RealtimeTests/Models/User.cs +++ b/RealtimeTests/Models/User.cs @@ -7,9 +7,9 @@ namespace RealtimeTests.Models public class User : BaseModel { [PrimaryKey("id", false)] - public string Id { get; set; } + public string? Id { get; set; } [Column("name")] - public string Name { get; set; } + public string? Name { get; set; } } } diff --git a/RealtimeTests/RealtimeTests.csproj b/RealtimeTests/RealtimeTests.csproj index 32ee250..eb77e3b 100644 --- a/RealtimeTests/RealtimeTests.csproj +++ b/RealtimeTests/RealtimeTests.csproj @@ -7,7 +7,9 @@ + enable latest + CS8600;CS8602;CS8603 diff --git a/RealtimeTests/SocketResponse.cs b/RealtimeTests/SocketResponseTests.cs similarity index 90% rename from RealtimeTests/SocketResponse.cs rename to RealtimeTests/SocketResponseTests.cs index 1420a40..71caad7 100644 --- a/RealtimeTests/SocketResponse.cs +++ b/RealtimeTests/SocketResponseTests.cs @@ -13,12 +13,12 @@ public void SocketResponseIncludesError() var responseWithError = "{\"columns\":[{\"name\":\"id\",\"type\":\"int8\"},{\"name\":\"details\",\"type\":\"text\"}],\"commit_timestamp\":\"2021-12-28T23:59:38.984538+00:00\",\"schema\":\"public\",\"table\":\"todos\",\"type\":\"UPDATE\",\"old_record\":{\"details\":\"previous test\",\"id\":12,\"user_id\":1},\"record\":{\"details\":\"test...\",\"id\":12,\"user_id\":1},\"errors\":[\"Error 413: Payload Too Large\"]}"; var errorResponse = JsonConvert.DeserializeObject(responseWithError); - CollectionAssert.Contains(errorResponse.Errors, "Error 413: Payload Too Large"); + CollectionAssert.Contains(errorResponse?.Errors, "Error 413: Payload Too Large"); var responseWithoutError = "{\"columns\":[{\"name\":\"id\",\"type\":\"int8\"},{\"name\":\"details\",\"type\":\"text\"}],\"commit_timestamp\":\"2021-12-28T23:59:38.984538+00:00\",\"schema\":\"public\",\"table\":\"todos\",\"type\":\"UPDATE\",\"old_record\":{\"details\":\"previous test\",\"id\":12,\"user_id\":1},\"record\":{\"details\":\"test...\",\"id\":12,\"user_id\":1},\"errors\": null}"; var successResponse = JsonConvert.DeserializeObject(responseWithoutError); - Assert.IsNull(successResponse.Errors); + Assert.IsNull(successResponse?.Errors); } } } From cb0b3040a921255cbc49febc2086de5e5e1da519 Mon Sep 17 00:00:00 2001 From: Joseph Schultz Date: Tue, 9 May 2023 22:59:11 -0500 Subject: [PATCH 3/7] In progress: Continued refactor of events/naming/structure --- Examples/PresenceExample/Pages/Index.razor | 2 +- Examples/RealtimeExample/Program.cs | 13 +- Realtime/Channel/Push.cs | 346 +++-- Realtime/Client.cs | 773 ++++++----- Realtime/ClientOptions.cs | 25 +- Realtime/Constants.cs | 38 +- Realtime/CustomContractResolver.cs | 8 +- Realtime/DebugNotification.cs | 20 + Realtime/Exceptions/FailureHint.cs | 12 + Realtime/Exceptions/RealtimeException.cs | 20 + Realtime/Interfaces/IRealtimeBroadcast.cs | 2 +- Realtime/Interfaces/IRealtimeChannel.cs | 92 +- Realtime/Interfaces/IRealtimeClient.cs | 57 +- Realtime/Interfaces/IRealtimePresence.cs | 2 +- Realtime/Interfaces/IRealtimePush.cs | 7 +- Realtime/Interfaces/IRealtimeSocket.cs | 21 +- .../PostgresChanges/PostgresChangesOptions.cs | 6 +- Realtime/RealtimeBroadcast.cs | 9 +- Realtime/RealtimeChannel.cs | 1194 +++++++++-------- Realtime/RealtimePresence.cs | 2 +- Realtime/RealtimeSocket.cs | 835 +++++++----- RealtimeTests/ClientTests.cs | 2 +- 22 files changed, 1911 insertions(+), 1575 deletions(-) create mode 100644 Realtime/DebugNotification.cs create mode 100644 Realtime/Exceptions/FailureHint.cs create mode 100644 Realtime/Exceptions/RealtimeException.cs diff --git a/Examples/PresenceExample/Pages/Index.razor b/Examples/PresenceExample/Pages/Index.razor index 65a366c..cee2220 100644 --- a/Examples/PresenceExample/Pages/Index.razor +++ b/Examples/PresenceExample/Pages/Index.razor @@ -106,7 +106,7 @@ await JS.InvokeAsync("registerMouseMoveListener", objRef); await JS.InvokeAsync("registerKeydownListener", objRef); - realtime.OnOpen += (sender, args) => isConnected = args.State == ConnectionState.Open; + realtime.AddStateChangedListener((_, state) => isConnected = state != Constants.SocketState.Close); await realtime.ConnectAsync(); diff --git a/Examples/RealtimeExample/Program.cs b/Examples/RealtimeExample/Program.cs index d954678..9d25db9 100644 --- a/Examples/RealtimeExample/Program.cs +++ b/Examples/RealtimeExample/Program.cs @@ -2,8 +2,11 @@ using Supabase.Realtime; using Supabase.Realtime.Channel; using System; +using System.Diagnostics; using System.Linq; using System.Threading.Tasks; +using Supabase.Realtime.Interfaces; +using static Supabase.Realtime.Constants; namespace RealtimeExample { @@ -15,10 +18,7 @@ static async Task Main(string[] args) var postgrestClient = new Postgrest.Client("http://localhost:3000"); var realtimeClient = new Client("ws://localhost:4000/socket"); - //Socket events - realtimeClient.OnOpen += (s, args) => Console.WriteLine("OPEN"); - realtimeClient.OnClose += (s, args) => Console.WriteLine("CLOSED"); - realtimeClient.OnError += (s, args) => Console.WriteLine("ERROR"); + realtimeClient.AddStateChangedListener(SocketEventHandler); await realtimeClient.ConnectAsync(); @@ -50,5 +50,10 @@ static async Task Main(string[] args) Console.ReadKey(); } + + private static void SocketEventHandler(IRealtimeClient sender, SocketState state) + { + Debug.WriteLine($"Socket is ${state.ToString()}"); + } } } diff --git a/Realtime/Channel/Push.cs b/Realtime/Channel/Push.cs index 88f33f0..e57ffec 100644 --- a/Realtime/Channel/Push.cs +++ b/Realtime/Channel/Push.cs @@ -1,156 +1,204 @@ using Supabase.Realtime.Interfaces; using Supabase.Realtime.Socket; using System; +using System.Collections.Generic; using System.Timers; namespace Supabase.Realtime.Channel { - /// - /// Class representation of a single request sent to the Socket server. - /// - /// `Push` also adds additional functionality for retrying, timeouts, and listeners - /// for its associated response from the server. - /// - public class Push : IRealtimePush - { - /// - /// Flag representing the `sent` state of a request. - /// - public bool IsSent { get; private set; } = false; - - /// - /// Invoked when the server has responded to a request. - /// - public event EventHandler? OnMessage; - - /// - /// Invoked when this `Push` has not been responded to within the timeout interval. - /// - public event EventHandler? OnTimeout; - public IRealtimeSocketResponse? Response { get; private set; } - - /// - /// The associated channel. - /// - public RealtimeChannel Channel { get; private set; } - - public string? Type { get; set; } - - /// - /// The event requested. - /// - public string EventName { get; private set; } - - /// - /// Payload of data to be sent. - /// - public object? Payload { get; private set; } - - /// - /// Represents the Pushed (sent) Message - /// - public SocketRequest? Message { get; private set; } - - /// - /// Ref Of this Message - /// - public string? Ref { get; private set; } - - private string? msgRefEvent; - private int timeoutMs; - private Timer timer; - - private IRealtimeSocket socket; - - /// - /// Initilizes a single request that will be `Pushed` to the Socket server. - /// - /// - /// - /// - /// - public Push(IRealtimeSocket socket, RealtimeChannel channel, string eventName, string? type = null, object? payload = null, int timeoutMs = Constants.DEFAULT_TIMEOUT) - { - this.socket = socket; - - Channel = channel; - Type = type; - EventName = eventName; - Payload = payload; - - this.timeoutMs = timeoutMs; - - timer = new Timer(this.timeoutMs); - timer.Elapsed += TimeoutReached; - - socket.OnMessage += HandleSocketMessage; - } - - /// - /// Resends a `Push` request. - /// - /// - public void Resend(int timeoutMs = Constants.DEFAULT_TIMEOUT) - { - this.timeoutMs = timeoutMs; - Ref = null; - msgRefEvent = null; - - IsSent = false; - Send(); - } - - /// - /// Sends a `Push` request and initializes the Timeout. - /// - public void Send() - { - StartTimeout(); - IsSent = true; - - Message = new SocketRequest - { - Topic = Channel.Topic, - Type = Type, - Event = EventName, - Payload = Payload, - Ref = Ref, - JoinRef = EventName == Constants.CHANNEL_EVENT_JOIN ? Ref : null, - }; - socket.Push(Message); - } - - /// - /// Keeps an internal timer for raising an event if this message is not responded to. - /// - internal void StartTimeout() - { - timer.Stop(); - timer.Start(); - Ref = socket.MakeMsgRef(); - msgRefEvent = socket.ReplyEventName(Ref); - } - - /// - /// Verifies that the request `ref` matches the response `ref`. - /// - /// - /// - private void HandleSocketMessage(object sender, SocketResponseEventArgs args) - { - if (args.Response.Ref == Ref) - { - CancelTimeout(); - Response = args.Response; - OnMessage?.Invoke(this, args); - socket.OnMessage -= HandleSocketMessage; - } - } - - private void TimeoutReached(object sender, ElapsedEventArgs e) => OnTimeout?.Invoke(this, null); - - private void CancelTimeout() => timer.Stop(); - } - - public class PushTimeoutException : Exception { } -} + /// + /// Class representation of a single request sent to the Socket server. + /// + /// `Push` also adds additional functionality for retrying, timeouts, and listeners + /// for its associated response from the server. + /// + public class Push : IRealtimePush + { + /// + /// Flag representing the `sent` state of a request. + /// + public bool IsSent { get; private set; } + + /// + /// Invoked when this `Push` has not been responded to within the timeout interval. + /// + public event EventHandler? OnTimeout; + + /// + /// Accessor for the returned Socket Response + /// + public IRealtimeSocketResponse? Response { get; private set; } + + /// + /// The associated channel. + /// + public RealtimeChannel Channel { get; } + + public string? Type { get; } + + /// + /// The event requested. + /// + public string EventName { get; } + + /// + /// Payload of data to be sent. + /// + public object? Payload { get; } + + /// + /// Represents the Pushed (sent) Message + /// + public SocketRequest? Message { get; private set; } + + /// + /// Ref Of this Message + /// + public string? Ref { get; private set; } + + private string? _msgRefEvent; + private int _timeoutMs; + private readonly Timer _timer; + + private readonly IRealtimeSocket _socket; + + /// + /// Handlers for notifications of message events. + /// + private readonly List.MessageEventHandler> _messageEventHandlers = new(); + + /// + /// Initializes a single request that will be `Pushed` to the Socket server. + /// + /// + /// + /// + /// + /// + public Push(IRealtimeSocket socket, RealtimeChannel channel, string eventName, string? type = null, + object? payload = null, int timeoutMs = Constants.DefaultTimeout) + { + _socket = socket; + _timeoutMs = timeoutMs; + _timer = new Timer(_timeoutMs); + _timer.Elapsed += HandleTimeoutElapsed; + + Channel = channel; + Type = type; + EventName = eventName; + Payload = payload; + + socket.AddMessageReceivedListener(HandleSocketMessageReceived); + } + + /// + /// Resends a `Push` request. + /// + /// + public void Resend(int timeoutMs = Constants.DefaultTimeout) + { + this._timeoutMs = timeoutMs; + Ref = null; + _msgRefEvent = null; + + IsSent = false; + Send(); + } + + /// + /// Sends a `Push` request and initializes the Timeout. + /// + public void Send() + { + StartTimeout(); + IsSent = true; + + Message = new SocketRequest + { + Topic = Channel.Topic, + Type = Type, + Event = EventName, + Payload = Payload, + Ref = Ref, + JoinRef = EventName == Constants.ChannelEventJoin ? Ref : null, + }; + + _socket.Push(Message); + } + + /// + /// Keeps an internal timer for raising an event if this message is not responded to. + /// + internal void StartTimeout() + { + _timer.Stop(); + _timer.Start(); + Ref = _socket.MakeMsgRef(); + _msgRefEvent = _socket.ReplyEventName(Ref); + } + + /// + /// Handles when a socket message is received for this push. + /// + /// + /// + private void HandleSocketMessageReceived(IRealtimeSocket sender, SocketResponse message) + { + if (message.Ref != Ref) return; + + CancelTimeout(); + Response = message; + NotifyMessageReceived(message); + + sender.RemoveMessageReceivedListener(HandleSocketMessageReceived); + } + + /// + /// Adds a listener to be notified when a message is received. + /// + /// + public void AddMessageReceivedListener(IRealtimePush.MessageEventHandler messageEventHandler) + { + if (_messageEventHandlers.Contains(messageEventHandler)) + return; + + _messageEventHandlers.Add(messageEventHandler); + } + + /// + /// Removes a specified listener from messages received. + /// + /// + public void RemoveMessageReceivedListener(IRealtimePush.MessageEventHandler messageEventHandler) + { + if (!_messageEventHandlers.Contains(messageEventHandler)) + return; + + _messageEventHandlers.Remove(messageEventHandler); + } + + /// + /// Notifies all listeners that the socket has received a message + /// + /// + private void NotifyMessageReceived(SocketResponse messageResponse) + { + foreach (var handler in _messageEventHandlers) + handler.Invoke(this, messageResponse); + } + + /// + /// Clears all of the listeners from receiving event state changes. + /// + public void ClearMessageReceivedListeners() => + _messageEventHandlers.Clear(); + + private void HandleTimeoutElapsed(object sender, ElapsedEventArgs e) => OnTimeout?.Invoke(this, null); + + private void CancelTimeout() => _timer.Stop(); + } + + public class PushTimeoutException : Exception + { + } +} \ No newline at end of file diff --git a/Realtime/Client.cs b/Realtime/Client.cs index cec5aeb..8d549e5 100644 --- a/Realtime/Client.cs +++ b/Realtime/Client.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; using System.Net.WebSockets; using System.Threading.Tasks; using Newtonsoft.Json; @@ -9,371 +10,413 @@ using Supabase.Realtime.Interfaces; using Supabase.Realtime.PostgresChanges; using Supabase.Realtime.Socket; +using static Supabase.Realtime.Constants; namespace Supabase.Realtime { - /// - /// Singleton that represents a Client connection to a Realtime Server. - /// - /// It maintains a singular Websocket with asynchronous listeners (RealtimeChannels). - /// - /// - /// client = Client.Instance - /// - public class Client : IRealtimeClient - { - /// - /// Contains all Realtime RealtimeChannel Subscriptions - state managed internally. - /// - /// Keys are of encoded value: `{database}{:schema?}{:table?}{:col.eq.:value?}` - /// Values are of type `RealtimeChannel where T : BaseModel, new()`; - /// - private Dictionary subscriptions { get; set; } - - /// - /// Exposes all Realtime RealtimeChannel Subscriptions for R/O public consumption - /// - public ReadOnlyDictionary Subscriptions => new ReadOnlyDictionary(subscriptions); - - /// - /// The backing Socket class. - /// - /// Most methods of the Client act as proxies to the Socket class. - /// - public IRealtimeSocket? Socket { get => socket; } - private IRealtimeSocket? socket; - - /// - /// Client Options - most of which are regarding Socket connection Options - /// - public ClientOptions Options { get; private set; } - - /// - /// Invoked when the socket raises the `open` event. - /// - public event EventHandler? OnOpen; - - /// - /// Invoked when the socket raises the `close` event. - /// - public event EventHandler? OnClose; - - /// - /// Invoked when the socket raises the `reconnected` event. - /// - public event EventHandler? OnReconnect; - - /// - /// Invoked when the socket raises the `error` event. - /// - public event EventHandler? OnError; - - /// - /// Invoked when the socket raises the `message` event. - /// - public event EventHandler? OnMessage; - - /// - /// Custom Serializer resolvers and converters that will be used for encoding and decoding Postgrest JSON responses. - /// - /// By default, Postgrest seems to use a date format that C# and Newtonsoft do not like, so this initial - /// configuration handles that. - /// - public JsonSerializerSettings SerializerSettings - { - get - { - if (Options == null) - Options = new ClientOptions(); - - return new JsonSerializerSettings - { - ContractResolver = new CustomContractResolver(), - Converters = - { - // 2020-08-28T12:01:54.763231 - new IsoDateTimeConverter - { - DateTimeStyles = Options.DateTimeStyles, - DateTimeFormat = Options.DateTimeFormat - } - }, - MissingMemberHandling = MissingMemberHandling.Ignore - }; - } - } - - private string realtimeUrl; - - /// - /// JWT Access token for WALRUS security - /// - internal string? AccessToken { get => accessToken; } - private string? accessToken; - - /// - /// Initializes a Client instance, this method should be called prior to any other method. - /// - /// The connection url (ex: "ws://localhost:4000/socket" - no trailing slash required) - /// - /// Client - public Client(string realtimeUrl, ClientOptions? options = null) - { - this.realtimeUrl = realtimeUrl; - - if (options == null) - options = new ClientOptions(); - - if (options.Encode == null) - options.Encode = (payload, callback) => callback(JsonConvert.SerializeObject(payload, SerializerSettings)); - - if (options.Decode == null) - { - options.Decode = (payload, callback) => - { - var response = new SocketResponse(SerializerSettings); - JsonConvert.PopulateObject(payload, response, SerializerSettings); - callback(response); - }; - } - - Options = options; - subscriptions = new Dictionary(); - } - - /// - /// Attempts to connect to the socket given the params specified in `Initialize` - /// - /// Returns when socket has successfully connected. - /// - /// - public Task> ConnectAsync() - { - var tsc = new TaskCompletionSource>(); - - try - { - Connect(tsc.SetResult); - } - catch (Exception ex) - { - tsc.TrySetException(ex); - } - - return tsc.Task; - } - - /// - /// Attempts to connect to the socket given the params specified in `Initialize` - /// - /// Provides a callback for `Task` driven returns. - /// - /// - /// - public IRealtimeClient Connect(Action>? callback = null) - { - if (socket != null) - { - Options.Logger("error", "Socket already exists.", null); - callback?.Invoke(this); - return this; - } - - EventHandler? cb = null; - - cb = (object sender, SocketStateChangedEventArgs args) => - { - switch (args.State) - { - case SocketStateChangedEventArgs.ConnectionState.Open: - socket!.StateChanged -= cb; - callback?.Invoke(this); - break; - case SocketStateChangedEventArgs.ConnectionState.Close: - case SocketStateChangedEventArgs.ConnectionState.Error: - socket!.StateChanged -= cb; - throw new Exception("Error occurred connecting to Socket. Check logs."); - } - }; - - socket = new RealtimeSocket(realtimeUrl, Options, SerializerSettings); - - socket.StateChanged += HandleSocketStateChanged; - socket.OnMessage += HandleSocketMessage; - socket.OnHeartbeat += HandleSocketHeartbeat; - - socket.StateChanged += cb; - socket.Connect(); - - return this; - } - - /// - /// Sets the current Access Token every heartbeat (see: https://github.com/supabase/realtime-js/blob/59bd47956ebe4e23b3e1a6c07f5fe2cfe943e8ad/src/RealtimeClient.ts#L437) - /// - /// - /// - private void HandleSocketHeartbeat(object sender, SocketResponseEventArgs e) - { - if (!string.IsNullOrEmpty(accessToken)) - SetAuth(accessToken!); - } - - /// - /// Disconnects from the socket server (if connected). - /// - /// Status Code - /// Reason for disconnect - /// - public IRealtimeClient Disconnect(WebSocketCloseStatus code = WebSocketCloseStatus.NormalClosure, string reason = "Programmatic Disconnect") - { - if (socket != null) - { - socket.StateChanged -= HandleSocketStateChanged; - socket.OnMessage -= HandleSocketMessage; - socket.Disconnect(code, reason); - socket = null; - } - return this; - } - - /// - /// Sets the JWT access token used for channel subscription authorization and Realtime RLS. - /// Ref: https://github.com/supabase/realtime-js/pull/117 | https://github.com/supabase/realtime-js/pull/117 - /// - /// - public void SetAuth(string jwt) - { - accessToken = jwt; - - try - { - foreach (var channel in subscriptions.Values) - { - // See: https://github.com/supabase/realtime-js/pull/126 - channel.Options.Parameters!["user_token"] = accessToken; - - if (channel.HasJoinedOnce && channel.IsJoined) - { - channel.Push(Constants.CHANNEL_ACCESS_TOKEN, payload: new Dictionary - { - { "access_token", accessToken } - }); - } - } - } - catch (Exception ex) - { - Options.Logger("exception", "Error in SetAuth()", ex); - } - } - - /// - /// Adds a RealtimeChannel subscription - if a subscription exists with the same signature, the existing subscription will be returned. - /// - /// The name of the Channel to join (totally arbitrary) - /// - /// - public RealtimeChannel Channel(string channelName) - { - var topic = $"realtime:{channelName}"; - - if (subscriptions.ContainsKey(topic)) - return subscriptions[topic]; - - if (socket == null) - throw new Exception("Socket must exist, was `Connect` called?"); - - var subscription = new RealtimeChannel(socket!, topic, new ChannelOptions(Options, () => AccessToken, SerializerSettings)); - subscriptions.Add(topic, subscription); - - return subscription; - } - - /// - /// Adds a RealtimeChannel subscription - if a subscription exists with the same signature, the existing subscription will be returned. - /// - /// Database to connect to, with Supabase this will likely be `realtime`. - /// Postgres schema, for example, `public` - /// Postgres table name - /// Postgres column name - /// Value the specified column should have - /// - public RealtimeChannel Channel(string database = "realtime", string schema = "public", string? table = null, string? column = null, string? value = null, Dictionary? parameters = null) - { - var key = Utils.GenerateChannelTopic(database, schema, table, column, value); - - if (subscriptions.ContainsKey(key)) - return subscriptions[key]; - - if (socket == null) - throw new Exception("Socket must exist, was `Connect` called?"); - - var changesOptions = new PostgresChangesOptions(schema, table, filter: column != null && value != null ? $"{column}=eq.{value}" : null, parameters: parameters); - var options = new ChannelOptions(Options, () => AccessToken, SerializerSettings); - - var subscription = new RealtimeChannel(socket!, key, options); - subscription.Register(changesOptions); - - subscriptions.Add(key, subscription); - - return subscription; - } - - /// - /// Removes a channel subscription. - /// - /// - public void Remove(RealtimeChannel channel) - { - if (subscriptions.ContainsKey(channel.Topic)) - { - if (channel.IsJoined) - channel.Unsubscribe(); - - subscriptions.Remove(channel.Topic); - } - } - - private void HandleSocketMessage(object sender, SocketResponseEventArgs args) - { - if (args.Response.Topic != null && subscriptions.ContainsKey(args.Response.Topic)) - { - subscriptions[args.Response.Topic].HandleSocketMessage(args); - } - } - - private void HandleSocketStateChanged(object sender, SocketStateChangedEventArgs args) - { - if (args.State != SocketStateChangedEventArgs.ConnectionState.Message) - Options.Logger("socket", "state changed", args.State.ToString().ToLower()); - - switch (args.State) - { - case SocketStateChangedEventArgs.ConnectionState.Open: - // Ref: https://github.com/supabase/realtime-js/pull/116/files - if (!string.IsNullOrEmpty(AccessToken)) - SetAuth(AccessToken!); - - OnOpen?.Invoke(this, args); - break; - case SocketStateChangedEventArgs.ConnectionState.Reconnected: - // Ref: https://github.com/supabase/realtime-js/pull/116/files - if (!string.IsNullOrEmpty(AccessToken)) - SetAuth(AccessToken!); - - OnReconnect?.Invoke(this, args); - break; - case SocketStateChangedEventArgs.ConnectionState.Message: - OnMessage?.Invoke(this, args); - break; - case SocketStateChangedEventArgs.ConnectionState.Close: - OnClose?.Invoke(this, args); - break; - case SocketStateChangedEventArgs.ConnectionState.Error: - OnError?.Invoke(this, args); - break; - } - } - } -} + /// + /// Singleton that represents a Client connection to a Realtime Server. + /// + /// It maintains a singular Websocket with asynchronous listeners (RealtimeChannels). + /// + /// + /// client = Client.Instance + /// + [SuppressMessage("ReSharper", "InvalidXmlDocComment")] + public class Client : IRealtimeClient + { + /// + /// Exposes all Realtime RealtimeChannel Subscriptions for R/O public consumption + /// + public ReadOnlyDictionary Subscriptions => new(_subscriptions); + + /// + /// The backing Socket class. + /// + /// Most methods of the Client act as proxies to the Socket class. + /// + public IRealtimeSocket? Socket { get; private set; } + + /// + /// Client Options - most of which are regarding Socket connection Options + /// + public ClientOptions Options { get; } + + /// + /// Custom Serializer resolvers and converters that will be used for encoding and decoding Postgrest JSON responses. + /// + /// By default, Postgrest seems to use a date format that C# and Newtonsoft do not like, so this initial + /// configuration handles that. + /// + public JsonSerializerSettings SerializerSettings => + new() + { + ContractResolver = new CustomContractResolver(), + Converters = + { + // 2020-08-28T12:01:54.763231 + new IsoDateTimeConverter + { + DateTimeStyles = Options.DateTimeStyles, + DateTimeFormat = Options.DateTimeFormat + } + }, + MissingMemberHandling = MissingMemberHandling.Ignore + }; + + + /// + /// JWT Access token for WALRUS security + /// + private string? AccessToken { get; set; } + + /// + /// Gets notifications if there is a failure not visible by exceptions (e.g. background thread refresh failure) + /// + private DebugNotification? _debugNotification; + + private readonly string _realtimeUrl; + + /// + /// Handlers for notifications of state changes. + /// + private readonly List.SocketEventHandler> + _socketEventHandlers = new(); + + /// + /// Contains all Realtime RealtimeChannel Subscriptions - state managed internally. + /// + /// Keys are of encoded value: `{database}{:schema?}{:table?}{:col.eq.:value?}` + /// Values are of type `RealtimeChannel where T : BaseModel, new()`; + /// + private readonly Dictionary _subscriptions; + + /// + /// Initializes a Client instance, this method should be called prior to any other method. + /// + /// The connection url (ex: "ws://localhost:4000/socket" - no trailing slash required) + /// + /// Client + public Client(string realtimeUrl, ClientOptions? options = null) + { + _realtimeUrl = realtimeUrl; + _subscriptions = new Dictionary(); + + options ??= new ClientOptions(); + options.Encode ??= DefaultMessageEncoder; + options.Decode ??= DefaultMessageDecoder; + Options = options; + } + + + /// + /// Attempts to connect to the socket given the params specified in `Initialize` + /// + /// Returns when socket has successfully connected. + /// + /// + public Task> ConnectAsync() + { + var tsc = new TaskCompletionSource>(); + + try + { + Connect(tsc.SetResult); + } + catch (Exception ex) + { + tsc.TrySetException(ex); + } + + return tsc.Task; + } + + /// + /// Attempts to connect to the socket given the params specified in `Initialize` + /// + /// Provides a callback for `Task` driven returns. + /// + /// + /// + public IRealtimeClient Connect( + Action>? callback = null) + { + if (Socket != null) + { + Options.Logger("error", "Socket already exists.", null); + callback?.Invoke(this); + return this; + } + + IRealtimeSocket.StateEventHandler? socketStateHandler = null; + + socketStateHandler = (sender, state) => + { + switch (state) + { + case SocketState.Open: + sender.RemoveStateChangedListener(socketStateHandler!); + callback?.Invoke(this); + break; + case SocketState.Close: + case SocketState.Error: + sender.RemoveStateChangedListener(socketStateHandler!); + throw new Exception("Error occurred connecting to Socket. Check logs."); + } + }; + + Socket = new RealtimeSocket(_realtimeUrl, Options); + Socket.AddMessageReceivedListener(HandleSocketMessageReceived); + Socket.AddStateChangedListener(socketStateHandler); + Socket.AddHeartbeatListener(HandleSocketHeartbeat); + Socket.Connect(); + + return this; + } + + /// + /// Adds a listener to be notified when the socket state changes. + /// + /// + public void AddStateChangedListener( + IRealtimeClient.SocketEventHandler socketEventHandler) + { + if (_socketEventHandlers.Contains(socketEventHandler)) + return; + + _socketEventHandlers.Add(socketEventHandler); + } + + /// + /// Removes a specified listener from socket state changes. + /// + public void RemoveStateChangedListener( + IRealtimeClient.SocketEventHandler socketEventHandler) + { + if (!_socketEventHandlers.Contains(socketEventHandler)) + return; + + _socketEventHandlers.Remove(socketEventHandler); + } + + /// + /// Clears all of the listeners from receiving socket state changes. + /// + public void ClearStateChangedListeners() => + _socketEventHandlers.Clear(); + + /// + /// Notifies all listeners that the current user auth state has changed. + /// + /// This is mainly used internally to fire notifications - most client applications won't need this. + /// + /// + private void NotifySocketStateChange(SocketState stateChanged) + { + foreach (var handler in _socketEventHandlers) + handler.Invoke(this, stateChanged); + } + + /// + /// Add a listener to get errors that occur outside of a typical Exception flow. + /// In particular, this is used to get errors and messages from the background thread + /// that automatically manages refreshing the user's token. + /// + /// + public void AddDebugListener(Action listener) + { + _debugNotification ??= new DebugNotification(); + _debugNotification.AddDebugListener(listener); + } + + /// + /// Sets the current Access Token every heartbeat (see: https://github.com/supabase/realtime-js/blob/59bd47956ebe4e23b3e1a6c07f5fe2cfe943e8ad/src/RealtimeClient.ts#L437) + /// + private void HandleSocketHeartbeat(IRealtimeSocket sender, SocketResponse message) + { + if (!string.IsNullOrEmpty(AccessToken)) + SetAuth(AccessToken!); + } + + /// + /// Disconnects from the socket server (if connected). + /// + /// Status Code + /// Reason for disconnect + /// + public IRealtimeClient Disconnect( + WebSocketCloseStatus code = WebSocketCloseStatus.NormalClosure, string reason = "Programmatic Disconnect") + { + if (Socket != null) + { + Socket.RemoveMessageReceivedListener(HandleSocketMessageReceived); + Socket.RemoveStateChangedListener(HandleSocketStateChanged); + Socket.Disconnect(code, reason); + Socket = null; + } + + return this; + } + + /// + /// Sets the JWT access token used for channel subscription authorization and Realtime RLS. + /// Ref: https://github.com/supabase/realtime-js/pull/117 | https://github.com/supabase/realtime-js/pull/117 + /// + /// + public void SetAuth(string jwt) + { + AccessToken = jwt; + + try + { + foreach (var channel in _subscriptions.Values) + { + // See: https://github.com/supabase/realtime-js/pull/126 + channel.Options.Parameters!["user_token"] = AccessToken; + + if (channel.HasJoinedOnce && channel.IsJoined) + { + channel.Push(Constants.ChannelAccessToken, payload: new Dictionary + { + { "access_token", AccessToken } + }); + } + } + } + catch (Exception ex) + { + Options.Logger("exception", "Error in SetAuth()", ex); + } + } + + /// + /// Adds a RealtimeChannel subscription - if a subscription exists with the same signature, the existing subscription will be returned. + /// + /// The name of the Channel to join (totally arbitrary) + /// + /// + public RealtimeChannel Channel(string channelName) + { + var topic = $"realtime:{channelName}"; + + if (_subscriptions.TryGetValue(topic, out var channel)) + return channel; + + if (Socket == null) + throw new Exception("Socket must exist, was `Connect` called?"); + + var subscription = new RealtimeChannel(Socket!, topic, + new ChannelOptions(Options, () => AccessToken, SerializerSettings)); + _subscriptions.Add(topic, subscription); + + return subscription; + } + + /// + /// Adds a RealtimeChannel subscription - if a subscription exists with the same signature, the existing subscription will be returned. + /// + /// Database to connect to, with Supabase this will likely be `realtime`. + /// Postgres schema, for example, `public` + /// Postgres table name + /// Postgres column name + /// Value the specified column should have + /// + public RealtimeChannel Channel(string database = "realtime", string schema = "public", string? table = null, + string? column = null, string? value = null, Dictionary? parameters = null) + { + var key = Utils.GenerateChannelTopic(database, schema, table, column, value); + + if (_subscriptions.TryGetValue(key, out var channel)) + return channel; + + if (Socket == null) + throw new Exception("Socket must exist, was `Connect` called?"); + + var changesOptions = new PostgresChangesOptions(schema, table, + filter: column != null && value != null ? $"{column}=eq.{value}" : null, parameters: parameters); + var options = new ChannelOptions(Options, () => AccessToken, SerializerSettings); + + var subscription = new RealtimeChannel(Socket!, key, options); + subscription.Register(changesOptions); + + _subscriptions.Add(key, subscription); + + return subscription; + } + + /// + /// Removes a channel subscription. + /// + /// + public void Remove(RealtimeChannel channel) + { + if (_subscriptions.ContainsKey(channel.Topic)) + { + if (channel.IsJoined) + channel.Unsubscribe(); + + _subscriptions.Remove(channel.Topic); + } + } + + /// + /// The default socket message encoder, used to serialize messages to the socket + /// server. + /// + /// It is unlikely that this will be overriden by the developer. + /// + /// + /// + private void DefaultMessageEncoder(object payload, Action callback) + { + callback(JsonConvert.SerializeObject(payload, SerializerSettings)); + } + + /// + /// The default socket message decoder, used to deserialize messages from the socket server. + /// Ref: + /// + /// It is unlikely that this will be overriden by the developer. + /// + /// + /// + private void DefaultMessageDecoder(string payload, Action callback) + { + var response = new SocketResponse(SerializerSettings); + JsonConvert.PopulateObject(payload, response, SerializerSettings); + callback(response); + } + + private void HandleSocketMessageReceived(IRealtimeSocket sender, SocketResponse message) + { + if (message.Topic != null && _subscriptions.TryGetValue(message.Topic, out var subscription)) + { + subscription.HandleSocketMessage(message); + } + } + + private void HandleSocketStateChanged(IRealtimeSocket sender, SocketState state) + { + switch (state) + { + case SocketState.Open: + // Ref: https://github.com/supabase/realtime-js/pull/116/files + if (!string.IsNullOrEmpty(AccessToken)) + SetAuth(AccessToken!); + + NotifySocketStateChange(SocketState.Open); + break; + case SocketState.Reconnect: + // Ref: https://github.com/supabase/realtime-js/pull/116/files + if (!string.IsNullOrEmpty(AccessToken)) + SetAuth(AccessToken!); + + NotifySocketStateChange(SocketState.Reconnect); + break; + default: + NotifySocketStateChange(state); + break; + } + } + } +} \ No newline at end of file diff --git a/Realtime/ClientOptions.cs b/Realtime/ClientOptions.cs index 4733a57..586a621 100644 --- a/Realtime/ClientOptions.cs +++ b/Realtime/ClientOptions.cs @@ -22,18 +22,20 @@ public class ClientOptions /// /// Logging function /// - public Action Logger { get; set; } = (kind, msg, data) => Debug.WriteLine($"{kind}: {msg}, {JsonConvert.SerializeObject(data, Formatting.Indented)}"); + public Action Logger { get; set; } = (kind, msg, data) => + Debug.WriteLine($"{kind}: {msg}, {JsonConvert.SerializeObject(data, Formatting.Indented)}"); /// /// The Websocket Transport, for example WebSocket. /// - public string Transport { get; set; } = Constants.TRANSPORT_WEBSOCKET; + public string Transport { get; set; } = Constants.TransportWebsocket; /// /// The default timeout in milliseconds to trigger push timeouts. /// - public TimeSpan Timeout { get; set; } = TimeSpan.FromMilliseconds(Constants.DEFAULT_TIMEOUT); + public TimeSpan Timeout { get; set; } = TimeSpan.FromMilliseconds(Constants.DefaultTimeout); + public int EventsPerSecond { get; set; } = 10; /// @@ -46,33 +48,28 @@ public class ClientOptions /// public Func ReconnectAfterInterval { get; set; } = (tries) => { - var intervals = new int[] { 1, 2, 5, 10 }; + var intervals = new[] { 1, 2, 5, 10 }; return TimeSpan.FromSeconds(tries < intervals.Length ? tries - 1 : 10); }; - /// - /// The maximum timeout of a long poll AJAX request. - /// - public TimeSpan LongPollerTimeout = TimeSpan.FromSeconds(20); - /// /// Request headers to be appended to the connection string. /// - public Dictionary Headers = new Dictionary(); + public readonly Dictionary Headers = new(); /// /// The optional params to pass when connecting /// - public SocketOptionsParameters Parameters = new SocketOptionsParameters(); + public SocketOptionsParameters Parameters = new(); /// /// Datetime Style for JSON Deserialization of Models /// - public DateTimeStyles DateTimeStyles = DateTimeStyles.AdjustToUniversal; + public readonly DateTimeStyles DateTimeStyles = DateTimeStyles.AdjustToUniversal; /// /// Datetime format for JSON Deserialization of Models (Postgrest style) /// - public string DateTimeFormat = "yyyy'-'MM'-'dd' 'HH':'mm':'ss.FFFFFFK"; + public string DateTimeFormat { get; set; } = @"yyyy'-'MM'-'dd' 'HH':'mm':'ss.FFFFFFK"; } -} +} \ No newline at end of file diff --git a/Realtime/Constants.cs b/Realtime/Constants.cs index 4d5771b..eb1b337 100644 --- a/Realtime/Constants.cs +++ b/Realtime/Constants.cs @@ -4,12 +4,12 @@ namespace Supabase.Realtime { public static class Constants { - public enum SocketStates + public enum SocketState { - connecting = 0, - open = 1, - closing = 2, - closed = 3 + Open, + Close, + Reconnect, + Error } public enum EventType @@ -66,39 +66,39 @@ public enum ChannelState /// /// Timeout interval for requests (used in Socket and Push) /// - public const int DEFAULT_TIMEOUT = 10000; - public const int WS_CLOSE_NORMAL = 1000; + public const int DefaultTimeout = 10000; + public const int WsCloseNormal = 1000; /// - /// Pheonix Socket Server Event: CLOSE + /// Phoenix Socket Server Event: CLOSE /// public static string CHANNEL_EVENT_CLOSE = "phx_close"; /// - /// Pheonix Socket Server Event: ERROR + /// Phoenix Socket Server Event: ERROR /// public static string CHANNEL_EVENT_ERROR = "phx_error"; /// - /// Pheonix Socket Server Event: JOIN + /// Phoenix Socket Server Event: JOIN /// - public static string CHANNEL_EVENT_JOIN = "phx_join"; + public const string ChannelEventJoin = "phx_join"; /// - /// Pheonix Socket Server Event: REPLY + /// Phoenix Socket Server Event: REPLY /// - public static string CHANNEL_EVENT_REPLY = "phx_reply"; + public const string ChannelEventReply = "phx_reply"; /// - /// Pheonix Socket Server Event: LEAVE + /// Phoenix Socket Server Event: LEAVE /// - public static string CHANNEL_EVENT_LEAVE = "phx_leave"; + public const string ChannelEventLeave = "phx_leave"; - public static string PHEONIX_STATUS_OK = "ok"; - public static string PHEONIX_STATUS_ERROR = "error"; + public const string PhoenixStatusOk = "ok"; + public const string PheonixStatusError = "error"; - public static string TRANSPORT_WEBSOCKET = "websocket"; + public const string TransportWebsocket = "websocket"; - public static string CHANNEL_ACCESS_TOKEN = "access_token"; + public const string ChannelAccessToken = "access_token"; } } diff --git a/Realtime/CustomContractResolver.cs b/Realtime/CustomContractResolver.cs index 417193d..4ff84aa 100644 --- a/Realtime/CustomContractResolver.cs +++ b/Realtime/CustomContractResolver.cs @@ -27,17 +27,17 @@ protected override JsonProperty CreateProperty(MemberInfo member, MemberSerializ { prop.Converter = new StringArrayConverter(); } - else if (prop.PropertyType == typeof(DateTime) || Nullable.GetUnderlyingType(prop.PropertyType) == typeof(DateTime)) + else if (prop.PropertyType == typeof(DateTime) || Nullable.GetUnderlyingType(prop.PropertyType!) == typeof(DateTime)) { prop.Converter = new DateTimeConverter(); } - else if (prop.PropertyType == typeof(List) || Nullable.GetUnderlyingType(prop.PropertyType) == typeof(List)) + else if (prop.PropertyType == typeof(List) || Nullable.GetUnderlyingType(prop.PropertyType!) == typeof(List)) { prop.Converter = new DateTimeConverter(); } // Dynamically set the name of the key we are serializing/deserializing from the model. - if (member.CustomAttributes.Count() > 0) + if (member.CustomAttributes.Any()) { ColumnAttribute columnAtt = member.GetCustomAttribute(); @@ -53,7 +53,7 @@ protected override JsonProperty CreateProperty(MemberInfo member, MemberSerializ if (primaryKeyAtt != null) { prop.PropertyName = primaryKeyAtt.ColumnName; - prop.ShouldSerialize = instance => primaryKeyAtt.ShouldInsert; + prop.ShouldSerialize = _ => primaryKeyAtt.ShouldInsert; return prop; } } diff --git a/Realtime/DebugNotification.cs b/Realtime/DebugNotification.cs new file mode 100644 index 0000000..796d3f1 --- /dev/null +++ b/Realtime/DebugNotification.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; + +namespace Supabase.Realtime; + +public class DebugNotification +{ + private readonly List> debugListeners = new(); + + public void AddDebugListener(Action listener) + { + debugListeners.Add(listener); + } + + public void Log(string message, Exception? e = null) + { + foreach (var l in debugListeners) + l.Invoke(message, e); + } +} \ No newline at end of file diff --git a/Realtime/Exceptions/FailureHint.cs b/Realtime/Exceptions/FailureHint.cs new file mode 100644 index 0000000..b642300 --- /dev/null +++ b/Realtime/Exceptions/FailureHint.cs @@ -0,0 +1,12 @@ +namespace Supabase.Realtime.Exceptions; + +public class FailureHint +{ + public enum Reason + { + Unknown, + PushTimeout + } + + //public static Reason DetectReason(Socket gte) {} +} \ No newline at end of file diff --git a/Realtime/Exceptions/RealtimeException.cs b/Realtime/Exceptions/RealtimeException.cs new file mode 100644 index 0000000..8bff06e --- /dev/null +++ b/Realtime/Exceptions/RealtimeException.cs @@ -0,0 +1,20 @@ +using System; + +namespace Supabase.Realtime.Exceptions; + +public class RealtimeException: Exception +{ + + public RealtimeException(string? message) : base(message) { } + public RealtimeException(string? message, Exception? innerException) : base(message, innerException) { } + + public string? Content { get; internal set; } + + public void AddReason() + { + // Reason = FailureHint.DetectReason(this); + //Debug.WriteLine(Content); + } + + public FailureHint.Reason Reason { get; internal set; } +} \ No newline at end of file diff --git a/Realtime/Interfaces/IRealtimeBroadcast.cs b/Realtime/Interfaces/IRealtimeBroadcast.cs index b33843a..ab76ca2 100644 --- a/Realtime/Interfaces/IRealtimeBroadcast.cs +++ b/Realtime/Interfaces/IRealtimeBroadcast.cs @@ -8,7 +8,7 @@ namespace Supabase.Realtime.Interfaces public interface IRealtimeBroadcast { event EventHandler? OnBroadcast; - Task Send(string? broadcastEventName, object payload, int timeoutMs = DEFAULT_TIMEOUT); + Task Send(string? broadcastEventName, object payload, int timeoutMs = DefaultTimeout); void TriggerReceived(SocketResponseEventArgs args); } diff --git a/Realtime/Interfaces/IRealtimeChannel.cs b/Realtime/Interfaces/IRealtimeChannel.cs index f8f80d7..d5bbf66 100644 --- a/Realtime/Interfaces/IRealtimeChannel.cs +++ b/Realtime/Interfaces/IRealtimeChannel.cs @@ -12,39 +12,61 @@ namespace Supabase.Realtime.Interfaces { public interface IRealtimeChannel - { - bool HasJoinedOnce { get; } - bool IsClosed { get; } - bool IsErrored { get; } - bool IsJoined { get; } - bool IsJoining { get; } - bool IsLeaving { get; } - ChannelOptions Options { get; } - BroadcastOptions? BroadcastOptions { get; } - PresenceOptions? PresenceOptions { get; } - List PostgresChangesOptions { get; } - ChannelState State { get; } - string Topic { get; } - - event EventHandler OnMessage; - event EventHandler StateChanged; - event EventHandler OnClose; - event EventHandler OnError; - event EventHandler OnDelete; - event EventHandler OnInsert; - event EventHandler OnUpdate; - event EventHandler OnPostgresChange; - - IRealtimeBroadcast? Broadcast(); - IRealtimePresence? Presence(); - - Push Push(string eventName, string? type = null, object? payload = null, int timeoutMs = DEFAULT_TIMEOUT); - void Rejoin(int timeoutMs = DEFAULT_TIMEOUT); - Task Send(ChannelEventName eventType, string? type, object payload, int timeoutMs = DEFAULT_TIMEOUT); - RealtimeBroadcast Register(bool broadcastSelf = false, bool broadcastAck = false) where TBroadcastResponse : BaseBroadcast; - RealtimePresence Register(string presenceKey) where TPresenceResponse : BasePresence; - IRealtimeChannel Register(PostgresChangesOptions postgresChangesOptions); - Task Subscribe(int timeoutMs = DEFAULT_TIMEOUT); - IRealtimeChannel Unsubscribe(); - } + { + delegate void MessageReceivedHandler(IRealtimeChannel sender, SocketResponse message); + + delegate void StateChangedHandler(IRealtimeChannel sender, ChannelState state); + + delegate void PostgresChangesHandler(IRealtimeChannel sender, PostgresChangesResponse changes); + + bool HasJoinedOnce { get; } + bool IsClosed { get; } + bool IsErrored { get; } + bool IsJoined { get; } + bool IsJoining { get; } + bool IsLeaving { get; } + ChannelOptions Options { get; } + BroadcastOptions? BroadcastOptions { get; } + PresenceOptions? PresenceOptions { get; } + List PostgresChangesOptions { get; } + ChannelState State { get; } + string Topic { get; } + + void AddStateChangedListener(StateChangedHandler stateChangedHandler); + + void RemoveStateChangedListener(StateChangedHandler stateChangedHandler); + + void ClearStateChangedListeners(); + + void AddMessageReceivedHandler(MessageReceivedHandler messageReceivedHandler); + + void RemoveMessageReceivedHandler(MessageReceivedHandler messageReceivedHandler); + + void ClearMessageReceivedListeners(); + + void AddPostgresChangesListener(PostgresChangesOptions.ListenType listenType, + PostgresChangesHandler postgresChangesHandler); + + void RemovePostgresChangesListener(PostgresChangesOptions.ListenType listenType, + IRealtimeChannel.PostgresChangesHandler postgresChangesHandler); + + void ClearPostgresChangesListeners(); + + IRealtimeBroadcast? Broadcast(); + IRealtimePresence? Presence(); + + Push Push(string eventName, string? type = null, object? payload = null, int timeoutMs = DefaultTimeout); + void Rejoin(int timeoutMs = DefaultTimeout); + Task Send(ChannelEventName eventType, string? type, object payload, int timeoutMs = DefaultTimeout); + + RealtimeBroadcast Register(bool broadcastSelf = false, + bool broadcastAck = false) where TBroadcastResponse : BaseBroadcast; + + RealtimePresence Register(string presenceKey) + where TPresenceResponse : BasePresence; + + IRealtimeChannel Register(PostgresChangesOptions postgresChangesOptions); + Task Subscribe(int timeoutMs = DefaultTimeout); + IRealtimeChannel Unsubscribe(); + } } \ No newline at end of file diff --git a/Realtime/Interfaces/IRealtimeClient.cs b/Realtime/Interfaces/IRealtimeClient.cs index 8280d34..a46a095 100644 --- a/Realtime/Interfaces/IRealtimeClient.cs +++ b/Realtime/Interfaces/IRealtimeClient.cs @@ -1,35 +1,42 @@ using Newtonsoft.Json; -using Supabase.Realtime.Socket; using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Net.WebSockets; using System.Threading.Tasks; +using static Supabase.Realtime.Constants; namespace Supabase.Realtime.Interfaces { - public interface IRealtimeClient - where TSocket : IRealtimeSocket - where TChannel : IRealtimeChannel - { - ClientOptions Options { get; } - JsonSerializerSettings SerializerSettings { get; } - IRealtimeSocket? Socket { get; } - ReadOnlyDictionary Subscriptions { get; } - - event EventHandler OnClose; - event EventHandler OnError; - event EventHandler OnMessage; - event EventHandler OnOpen; - event EventHandler OnReconnect; - - TChannel Channel(string channelName); - TChannel Channel(string database = "realtime", string schema = "public", string? table = null, string? column = null, string? value = null, Dictionary? parameters = null); - - IRealtimeClient Connect(Action>? callback = null); - Task> ConnectAsync(); - IRealtimeClient Disconnect(WebSocketCloseStatus code = WebSocketCloseStatus.NormalClosure, string reason = "Programmatic Disconnect"); - void Remove(TChannel channel); - void SetAuth(string jwt); - } + public interface IRealtimeClient + where TSocket : IRealtimeSocket + where TChannel : IRealtimeChannel + { + ClientOptions Options { get; } + JsonSerializerSettings SerializerSettings { get; } + IRealtimeSocket? Socket { get; } + ReadOnlyDictionary Subscriptions { get; } + + delegate void SocketEventHandler(IRealtimeClient sender, SocketState state); + + void AddStateChangedListener(SocketEventHandler socketEventHandler); + + void RemoveStateChangedListener(SocketEventHandler socketEventHandler); + + void ClearStateChangedListeners(); + + TChannel Channel(string channelName); + + TChannel Channel(string database = "realtime", string schema = "public", string? table = null, + string? column = null, string? value = null, Dictionary? parameters = null); + + IRealtimeClient Connect(Action>? callback = null); + Task> ConnectAsync(); + + IRealtimeClient Disconnect(WebSocketCloseStatus code = WebSocketCloseStatus.NormalClosure, + string reason = "Programmatic Disconnect"); + + void Remove(TChannel channel); + void SetAuth(string jwt); + } } \ No newline at end of file diff --git a/Realtime/Interfaces/IRealtimePresence.cs b/Realtime/Interfaces/IRealtimePresence.cs index a19a9a6..fb7f23d 100644 --- a/Realtime/Interfaces/IRealtimePresence.cs +++ b/Realtime/Interfaces/IRealtimePresence.cs @@ -10,7 +10,7 @@ public interface IRealtimePresence event EventHandler? OnLeave; event EventHandler? OnSync; - void Track(object? payload, int timeoutMs = DEFAULT_TIMEOUT); + void Track(object? payload, int timeoutMs = DefaultTimeout); void TriggerSync(SocketResponseEventArgs args); void TriggerDiff(SocketResponseEventArgs args); diff --git a/Realtime/Interfaces/IRealtimePush.cs b/Realtime/Interfaces/IRealtimePush.cs index 0fba5cf..ba2bddc 100644 --- a/Realtime/Interfaces/IRealtimePush.cs +++ b/Realtime/Interfaces/IRealtimePush.cs @@ -7,6 +7,10 @@ public interface IRealtimePush where TChannel : IRealtimeChannel where TSocketResponse : IRealtimeSocketResponse { + delegate void MessageEventHandler(IRealtimePush sender, TSocketResponse message); + void AddMessageReceivedListener(MessageEventHandler messageEventHandler); + void RemoveMessageReceivedListener(MessageEventHandler messageEventHandler); + void ClearMessageReceivedListeners(); TChannel Channel { get; } string EventName { get; } bool IsSent { get; } @@ -14,10 +18,7 @@ public interface IRealtimePush object? Payload { get; } string? Ref { get; } IRealtimeSocketResponse? Response { get; } - - event EventHandler? OnMessage; event EventHandler? OnTimeout; - void Resend(int timeoutMs = 10000); void Send(); } diff --git a/Realtime/Interfaces/IRealtimeSocket.cs b/Realtime/Interfaces/IRealtimeSocket.cs index a308ce8..031c69d 100644 --- a/Realtime/Interfaces/IRealtimeSocket.cs +++ b/Realtime/Interfaces/IRealtimeSocket.cs @@ -2,6 +2,7 @@ using System; using System.Net.WebSockets; using System.Threading.Tasks; +using static Supabase.Realtime.Constants; namespace Supabase.Realtime.Interfaces { @@ -9,9 +10,23 @@ public interface IRealtimeSocket { bool IsConnected { get; } - event EventHandler OnHeartbeat; - event EventHandler OnMessage; - event EventHandler StateChanged; + delegate void StateEventHandler(IRealtimeSocket sender, SocketState state); + + delegate void MessageEventHandler(IRealtimeSocket sender, SocketResponse message); + + delegate void HeartbeatEventHandler(IRealtimeSocket sender, SocketResponse heartbeat); + + void AddStateChangedListener(StateEventHandler stateEventHandler); + void RemoveStateChangedListener(StateEventHandler stateEventHandler); + void ClearStateChangedListeners(); + + void AddMessageReceivedListener(MessageEventHandler messageEventHandler); + void RemoveMessageReceivedListener(MessageEventHandler heartbeatHandler); + void ClearMessageReceivedListeners(); + + void AddHeartbeatListener(HeartbeatEventHandler messageEventHandler); + void RemoveHeartbeatListener(HeartbeatEventHandler messageEventHandler); + void ClearHeartbeatListeners(); Task GetLatency(); Task Connect(); diff --git a/Realtime/PostgresChanges/PostgresChangesOptions.cs b/Realtime/PostgresChanges/PostgresChangesOptions.cs index fecfed9..59daade 100644 --- a/Realtime/PostgresChanges/PostgresChangesOptions.cs +++ b/Realtime/PostgresChanges/PostgresChangesOptions.cs @@ -47,13 +47,13 @@ public enum ListenType public Dictionary? Parameters { get; set; } [JsonProperty("event")] - public string Event => Core.Helpers.GetMappedToAttr(listenType).Mapping!; + public string Event => Core.Helpers.GetMappedToAttr(_listenType).Mapping!; - private ListenType listenType = ListenType.All; + private readonly ListenType _listenType; public PostgresChangesOptions(string schema, string? table = null, ListenType eventType = ListenType.All, string? filter = null, Dictionary? parameters = null) { - listenType = eventType; + _listenType = eventType; Schema = schema; Table = table; Filter = filter; diff --git a/Realtime/RealtimeBroadcast.cs b/Realtime/RealtimeBroadcast.cs index 5ee2d3b..01a291c 100644 --- a/Realtime/RealtimeBroadcast.cs +++ b/Realtime/RealtimeBroadcast.cs @@ -24,9 +24,8 @@ public class RealtimeBroadcast : IRealtimeBroadcast where TBroa { public event EventHandler? OnBroadcast; - private RealtimeChannel channel; - private BroadcastOptions options; - private JsonSerializerSettings serializerSettings; + private readonly RealtimeChannel channel; + private readonly JsonSerializerSettings serializerSettings; private SocketResponse? lastSocketResponse; @@ -47,7 +46,6 @@ public class RealtimeBroadcast : IRealtimeBroadcast where TBroa public RealtimeBroadcast(RealtimeChannel channel, BroadcastOptions options, JsonSerializerSettings serializerSettings) { this.channel = channel; - this.options = options; this.serializerSettings = serializerSettings; } @@ -59,7 +57,8 @@ public RealtimeBroadcast(RealtimeChannel channel, BroadcastOptions options, Json public void TriggerReceived(SocketResponseEventArgs args) { if (args.Response == null || args.Response.Json == null) - throw new ArgumentException(string.Format("Expected parsable JSON response, instead recieved: `{0}`", JsonConvert.SerializeObject(args.Response))); + throw new ArgumentException( + $"Expected parsable JSON response, instead received: `{JsonConvert.SerializeObject(args.Response)}`"); lastSocketResponse = args.Response; OnBroadcast?.Invoke(this, null); diff --git a/Realtime/RealtimeChannel.cs b/Realtime/RealtimeChannel.cs index e9b6fb3..42399dc 100644 --- a/Realtime/RealtimeChannel.cs +++ b/Realtime/RealtimeChannel.cs @@ -6,6 +6,7 @@ using Newtonsoft.Json; using Supabase.Realtime.Broadcast; using Supabase.Realtime.Channel; +using Supabase.Realtime.Exceptions; using Supabase.Realtime.Interfaces; using Supabase.Realtime.Models; using Supabase.Realtime.PostgresChanges; @@ -13,592 +14,615 @@ using Supabase.Realtime.Socket; using Supabase.Realtime.Socket.Responses; using static Supabase.Realtime.Constants; +using static Supabase.Realtime.PostgresChanges.PostgresChangesOptions; using Timer = System.Timers.Timer; +// ReSharper disable InvalidXmlDocComment + [assembly: InternalsVisibleTo("RealtimeTests")] -namespace Supabase.Realtime + +namespace Supabase.Realtime; + +/// +/// Class representation of a channel subscription +/// +public class RealtimeChannel : IRealtimeChannel { - /// - /// Class representation of a channel subscription - /// - public class RealtimeChannel : IRealtimeChannel - { - /// - /// Invoked when the `INSERT` event is raised. - /// - public event EventHandler? OnInsert; - - /// - /// Invoked when the `UPDATE` event is raised. - /// - public event EventHandler? OnUpdate; - - /// - /// Invoked when the `DELETE` event is raised. - /// - public event EventHandler? OnDelete; - - /// - /// Invoked when an Postgres Change event is raised. - /// - public event EventHandler? OnPostgresChange; - - /// - /// Invoked anytime a message is decoded within this topic. - /// - public event EventHandler? OnMessage; - - /// - /// Invoked when this channel listener is closed - /// - public event EventHandler? StateChanged; - - /// - /// Invoked when the socket drops or crashes. - /// - public event EventHandler? OnError; - - /// - /// Invoked when the channel is explicitly closed by the client. - /// - public event EventHandler? OnClose; - - public bool IsClosed => State == ChannelState.Closed; - public bool IsErrored => State == ChannelState.Errored; - public bool IsJoined => State == ChannelState.Joined; - public bool IsJoining => State == ChannelState.Joining; - public bool IsLeaving => State == ChannelState.Leaving; - - /// - /// The channel's topic (identifier) - /// - public string Topic { get; private set; } - - /// - /// The Channel's current state. - /// - public ChannelState State { get; private set; } = ChannelState.Closed; - - /// - /// Options passed to this channel instance. - /// - public Channel.ChannelOptions Options { get; private set; } - - /// - /// The saved Broadcast Options, set in - /// - public BroadcastOptions? BroadcastOptions { get; protected set; } = new BroadcastOptions(false, false); - - /// - /// The saved Presence Options, set in - /// - public PresenceOptions? PresenceOptions { get; protected set; } = new PresenceOptions(string.Empty); - - /// - /// The saved Postgres Changes Options, set in - /// - public List PostgresChangesOptions { get; private set; } = new List(); - - /// - /// Flag stating whether a channel has been joined once or not. - /// - public bool HasJoinedOnce { get; private set; } - - /// - /// Flag stating if a channel is currently subscribed. - /// - public bool IsSubscribed = false; - - /// - /// Returns the instance. - /// - /// - public IRealtimeBroadcast? Broadcast() => broadcast; - - /// - /// Returns a typed instance. - /// - /// - /// - public RealtimeBroadcast? Broadcast() where TBroadcastModel : BaseBroadcast => broadcast != null ? (RealtimeBroadcast)broadcast : default; - - /// - /// Returns the instance. - /// - /// - public IRealtimePresence? Presence() => presence; - - /// - /// Returns a typed instance. - /// - /// Model representing a Presence payload - /// - public RealtimePresence? Presence() where TPresenceModel : BasePresence => presence != null ? (RealtimePresence)presence : default; - - /// - /// The initial request to join a channel (repeated on channel disconnect) - /// - internal Push? JoinPush; - internal Push? LastPush; - - // Event handlers that pass events to typed instances for broadcast and presence. - internal event EventHandler? OnBroadcast; - internal event EventHandler? OnPresenceDiff; - internal event EventHandler? OnPresenceSync; - - /// - /// Buffer of Pushes held because of Socket availablity - /// - internal List buffer = new List(); - - private IRealtimeSocket socket; - private IRealtimePresence? presence; - private IRealtimeBroadcast? broadcast; - private bool canPush => IsJoined && socket.IsConnected; - private bool hasJoinedOnce = false; - private Timer rejoinTimer; - private bool isRejoining = false; - - /// - /// Initializes a Channel - must call `Subscribe()` to receive events. - /// - /// - /// - /// - /// - /// - public RealtimeChannel(IRealtimeSocket socket, string channelName, Channel.ChannelOptions options) - { - Topic = channelName; - - this.socket = socket; - - if (options.Parameters == null) - options.Parameters = new Dictionary(); - - Options = options; - - socket.StateChanged += (sender, args) => - { - if (args.State == SocketStateChangedEventArgs.ConnectionState.Reconnected && IsSubscribed) - { - IsSubscribed = false; - Rejoin(DEFAULT_TIMEOUT); - } - }; - - rejoinTimer = new Timer(options.ClientOptions.Timeout.TotalMilliseconds); - rejoinTimer.Elapsed += HandleRejoinTimerElapsed; - rejoinTimer.AutoReset = true; - } - - /// - /// Registers a instance - allowing broadcast responses to be parsed. - /// - /// - /// enables client to receive message it broadcasted - /// instructs server to acknowledge that broadcast message was received - /// - /// - public RealtimeBroadcast Register(bool broadcastSelf = false, bool broadcastAck = false) where TBroadcastResponse : BaseBroadcast - { - if (broadcast != null) - throw new InvalidOperationException("Register can only be called with broadcast options for a channel once."); - - BroadcastOptions = new BroadcastOptions(broadcastSelf, broadcastAck); - - var instance = new RealtimeBroadcast(this, BroadcastOptions, Options.SerializerSettings); - broadcast = instance; - - OnBroadcast += (sender, args) => broadcast.TriggerReceived(args); - - return instance; - } - - /// - /// Registers a instance - allowing presence responses to be parsed and state to be tracked. - /// - /// The model representing a presence payload. - /// used to track presence payload across clients - /// - /// Thrown if called multiple times. - public RealtimePresence Register(string presenceKey) where TPresenceResponse : BasePresence - { - if (presence != null) - throw new InvalidOperationException("Register can only be called with presence options for a channel once."); - - PresenceOptions = new PresenceOptions(presenceKey); - var instance = new RealtimePresence(this, PresenceOptions, Options.SerializerSettings); - presence = instance; - - OnPresenceSync += (sender, args) => presence.TriggerSync(args); - OnPresenceDiff += (sender, args) => presence.TriggerDiff(args); - - return instance; - } - - /// - /// Registers postgres_changes options, can be called multiple times. - /// - /// - /// - public IRealtimeChannel Register(PostgresChangesOptions postgresChangesOptions) - { - PostgresChangesOptions.Add(postgresChangesOptions); - return this; - } - - /// - /// Subscribes to the channel given supplied Options/params. - /// - /// - public Task Subscribe(int timeoutMs = DEFAULT_TIMEOUT) - { - var tsc = new TaskCompletionSource(); - - if (IsSubscribed) - { - return Task.FromResult(this as IRealtimeChannel); - } - - JoinPush = GenerateJoinPush(); - EventHandler? channelCallback = null; - EventHandler? joinPushTimeoutCallback = null; - - channelCallback = (object sender, ChannelStateChangedEventArgs e) => - { - switch (e.State) - { - // Success! - case ChannelState.Joined: - HasJoinedOnce = true; - IsSubscribed = true; - - StateChanged -= channelCallback; - JoinPush.OnTimeout -= joinPushTimeoutCallback; - - // Clear buffer - foreach (var item in buffer) - item.Send(); - buffer.Clear(); - - tsc.TrySetResult(this); - break; - // Failure - case ChannelState.Closed: - case ChannelState.Errored: - IsSubscribed = false; - StateChanged -= channelCallback; - JoinPush.OnTimeout -= joinPushTimeoutCallback; - - tsc.TrySetException(new Exception("Error occurred connecting to channel. Check logs.")); - break; - } - }; - - // Throw an exception if there is a problem receiving a join response - joinPushTimeoutCallback = (object sender, EventArgs e) => - { - StateChanged -= channelCallback; - JoinPush.OnTimeout -= joinPushTimeoutCallback; - - tsc.TrySetException(new PushTimeoutException()); - }; - - StateChanged += channelCallback; - - // Set a flag to prevent multiple join attempts. - hasJoinedOnce = true; - - // Init and send join. - Rejoin(timeoutMs); - JoinPush.OnTimeout += joinPushTimeoutCallback; - - return tsc.Task; - } - - /// - /// Unsubscribes from the channel. - /// - public IRealtimeChannel Unsubscribe() - { - IsSubscribed = false; - SetState(ChannelState.Leaving); - - var leavePush = new Push(socket, this, CHANNEL_EVENT_LEAVE); - leavePush.Send(); - - TriggerChannelStateEvent(new ChannelStateChangedEventArgs(ChannelState.Closed), false); - - return this; - } - - /// - /// Sends a `Push` request under this channel. - /// - /// Maintains a buffer in the event push is called prior to the channel being joined. - /// - /// - /// - /// - public Push Push(string eventName, string? type = null, object? payload = null, int timeoutMs = DEFAULT_TIMEOUT) - { - if (!hasJoinedOnce) - throw new Exception($"Tried to push '{eventName}' to '{Topic}' before joining. Use `Channel.Subscribe()` before pushing events"); - - var push = new Push(socket, this, eventName, type, payload, timeoutMs); - Enqueue(push); - - return push; - } - - /// - /// Sends an arbitrary payload with a given payload type () - /// - /// - /// - /// - public Task Send(ChannelEventName eventName, string? type, object payload, int timeoutMs = DEFAULT_TIMEOUT) - { - var tsc = new TaskCompletionSource(); - - var ev = Core.Helpers.GetMappedToAttr(eventName).Mapping; - var push = Push(ev, type, payload, timeoutMs); - - EventHandler? messageCallback = null; - - messageCallback = (object sender, SocketResponseEventArgs args) => - { - tsc.SetResult(args.Response?.Event != EventType.Unknown); - push.OnMessage -= messageCallback; - }; - - push.OnMessage += messageCallback; - - return tsc.Task; - } - - /// - /// Rejoins the channel. - /// - /// - public void Rejoin(int timeoutMs = DEFAULT_TIMEOUT) - { - if (IsLeaving) return; - SendJoin(timeoutMs); - } - - /// - /// Enqueues a message. - /// - /// - private void Enqueue(Push push) - { - LastPush = push; - - if (canPush) - { - LastPush.Send(); - } - else - { - LastPush.StartTimeout(); - buffer.Add(LastPush); - } - } - - /// - /// Generates the Join Push message by merging broadcast, presence, and postgres_changes options. - /// - /// - private Push GenerateJoinPush() => new Push(socket, this, CHANNEL_EVENT_JOIN, payload: new JoinPush(BroadcastOptions, PresenceOptions, PostgresChangesOptions)); - - /// - /// Generates an auth push. - /// - /// - private Push? GenerateAuthPush() - { - var accessToken = Options.RetrieveAccessToken(); - - if (!string.IsNullOrEmpty(accessToken)) - { - return new Push(socket, this, CHANNEL_ACCESS_TOKEN, payload: new Dictionary - { - { "access_token", accessToken!} - }); - } - else - { - return null; - } - } - - /// - /// If the channel errors internally (pheonix error, not transport) attempt rejoining. - /// - /// - /// - private void HandleRejoinTimerElapsed(object sender, ElapsedEventArgs e) - { - if (isRejoining) return; - isRejoining = true; - - if (State != ChannelState.Closed && State != ChannelState.Errored) - return; - - Options.ClientOptions.Logger?.Invoke(Topic, "attempting to rejoin", null); - - // Reset join push instance - JoinPush = GenerateJoinPush(); - - Rejoin(); - } - - /// - /// Sends the pheonix server a join message. - /// - /// - private void SendJoin(int timeoutMs = DEFAULT_TIMEOUT) - { - SetState(ChannelState.Joining); - - // Remove handler if exists - if (JoinPush != null) - JoinPush.OnMessage -= HandleJoinResponse; - - JoinPush = GenerateJoinPush(); - JoinPush.OnMessage += HandleJoinResponse; - JoinPush.Resend(timeoutMs); - } - - /// - /// Handles a recieved join response (received after sending on subscribe/reconnection) - /// - /// - /// - private void HandleJoinResponse(object sender, SocketResponseEventArgs args) - { - if (args.Response._event == CHANNEL_EVENT_REPLY) - { - var obj = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(args.Response.Payload, Options.SerializerSettings), Options.SerializerSettings); - - if (obj == null) return; - - if (obj.Status == PHEONIX_STATUS_OK) - { - // Disable Rejoin Timeout - rejoinTimer?.Stop(); - isRejoining = false; - - var authPush = GenerateAuthPush(); - - if (authPush != null) - authPush.Send(); - - SetState(ChannelState.Joined); - } - else if (obj.Status == PHEONIX_STATUS_ERROR) - { - rejoinTimer.Stop(); - isRejoining = false; - SetState(ChannelState.Errored); - } - } - } - - /// - /// Sets the instance's current state. - /// - /// - private void SetState(ChannelState state) - { - State = state; - StateChanged?.Invoke(this, new ChannelStateChangedEventArgs(state)); - } - - /// - /// Called when a socket message is recieved, parses the correct event handler to pass to. - /// - /// - internal void HandleSocketMessage(SocketResponseEventArgs args) - { - if (args.Response.Ref == JoinPush?.Ref) return; - - // If we don't ignore this event we'll end up with double callbacks. - if (args.Response._event == "*") return; - - OnMessage?.Invoke(this, args); - - switch (args.Response.Event) - { - case EventType.PostgresChanges: - var deserialize = JsonConvert.DeserializeObject(args.Response.Json!, Options.SerializerSettings); - - if (deserialize?.Payload?.Data == null) return; - - deserialize!.Json = args.Response.Json; - deserialize.serializerSettings = Options.SerializerSettings; - - var newArgs = new PostgresChangesEventArgs(deserialize!); - - // Invoke '*' listener - OnPostgresChange?.Invoke(this, newArgs); - - - switch (deserialize!.Payload!.Data!.Type) - { - case EventType.Insert: - OnInsert?.Invoke(this, newArgs); - break; - case EventType.Update: - OnUpdate?.Invoke(this, newArgs); - break; - case EventType.Delete: - OnDelete?.Invoke(this, newArgs); - break; - } - break; - case EventType.Broadcast: - OnBroadcast?.Invoke(this, args); - break; - case EventType.PresenceState: - OnPresenceSync?.Invoke(this, args); - break; - case EventType.PresenceDiff: - OnPresenceDiff?.Invoke(this, args); - break; - } - } - - /// - /// Triggers events for a channel's state changing. - /// - /// - /// - private void TriggerChannelStateEvent(ChannelStateChangedEventArgs args, bool shouldRejoin = true) - { - SetState(args.State); - - if (shouldRejoin) - { - isRejoining = false; - rejoinTimer.Start(); - } - else rejoinTimer.Stop(); - - switch (args.State) - { - case ChannelState.Closed: - OnClose?.Invoke(this, args); - break; - case ChannelState.Errored: - OnError?.Invoke(this, args); - break; - default: - break; - } - } - - } -} + public bool IsClosed => State == ChannelState.Closed; + public bool IsErrored => State == ChannelState.Errored; + public bool IsJoined => State == ChannelState.Joined; + public bool IsJoining => State == ChannelState.Joining; + public bool IsLeaving => State == ChannelState.Leaving; + + private readonly List _stateChangedHandlers = new(); + private readonly List _messageReceivedHandlers = new(); + + private readonly Dictionary> + _postgresChangesHandlers = new(); + + /// + /// The channel's topic (identifier) + /// + public string Topic { get; } + + /// + /// The Channel's current state. + /// + public ChannelState State { get; private set; } = ChannelState.Closed; + + /// + /// Options passed to this channel instance. + /// + public ChannelOptions Options { get; } + + /// + /// The saved Broadcast Options, set in + /// + public BroadcastOptions? BroadcastOptions { get; private set; } = new(); + + /// + /// The saved Presence Options, set in + /// + public PresenceOptions? PresenceOptions { get; private set; } = new(string.Empty); + + /// + /// The saved Postgres Changes Options, set in + /// + public List PostgresChangesOptions { get; } = new(); + + /// + /// Flag stating whether a channel has been joined once or not. + /// + public bool HasJoinedOnce { get; private set; } + + /// + /// Flag stating if a channel is currently subscribed. + /// + public bool IsSubscribed; + + /// + /// Returns the instance. + /// + /// + public IRealtimeBroadcast? Broadcast() => _broadcast; + + /// + /// Returns a typed instance. + /// + /// + /// + public RealtimeBroadcast? Broadcast() where TBroadcastModel : BaseBroadcast => + _broadcast != null ? (RealtimeBroadcast)_broadcast : default; + + /// + /// Returns the instance. + /// + /// + public IRealtimePresence? Presence() => _presence; + + /// + /// Returns a typed instance. + /// + /// Model representing a Presence payload + /// + public RealtimePresence? Presence() where TPresenceModel : BasePresence => + _presence != null ? (RealtimePresence)_presence : default; + + /// + /// The initial request to join a channel (repeated on channel disconnect) + /// + internal Push? JoinPush; + + internal Push? LastPush; + + // Event handlers that pass events to typed instances for broadcast and presence. + internal event EventHandler? OnBroadcast; + internal event EventHandler? OnPresenceDiff; + internal event EventHandler? OnPresenceSync; + + /// + /// Buffer of Pushes held because of Socket availability + /// + private readonly List _buffer = new(); + + private readonly IRealtimeSocket _socket; + private IRealtimePresence? _presence; + private IRealtimeBroadcast? _broadcast; + private bool CanPush => IsJoined && _socket.IsConnected; + private bool _hasJoinedOnce; + private readonly Timer _rejoinTimer; + private bool _isRejoining; + + /// + /// Initializes a Channel - must call `Subscribe()` to receive events. + /// + public RealtimeChannel(IRealtimeSocket socket, string channelName, ChannelOptions options) + { + Topic = channelName; + Options = options; + Options.Parameters ??= new Dictionary(); + + _socket = socket; + _socket.AddStateChangedListener(HandleSocketStateChanged); + + _rejoinTimer = new Timer(options.ClientOptions.Timeout.TotalMilliseconds); + _rejoinTimer.Elapsed += HandleRejoinTimerElapsed; + _rejoinTimer.AutoReset = true; + } + + /// + /// Handles socket state changes, specifically when a socket reconnects this channel (if subscribed) should also + /// rejoin. + /// + /// + /// + private void HandleSocketStateChanged(IRealtimeSocket _, SocketState state) + { + if (state != SocketState.Reconnect || !IsSubscribed) return; + + IsSubscribed = false; + Rejoin(); + } + + /// + /// Registers a instance - allowing broadcast responses to be parsed. + /// + /// + /// enables client to receive message it has broadcast + /// instructs server to acknowledge that broadcast message was received + /// + /// + public RealtimeBroadcast Register(bool broadcastSelf = false, + bool broadcastAck = false) where TBroadcastResponse : BaseBroadcast + { + if (_broadcast != null) + throw new InvalidOperationException( + "Register can only be called with broadcast options for a channel once."); + + BroadcastOptions = new BroadcastOptions(broadcastSelf, broadcastAck); + + var instance = + new RealtimeBroadcast(this, BroadcastOptions, Options.SerializerSettings); + _broadcast = instance; + + OnBroadcast += (_, args) => _broadcast.TriggerReceived(args); + + return instance; + } + + /// + /// Registers a instance - allowing presence responses to be parsed and state to be tracked. + /// + /// The model representing a presence payload. + /// used to track presence payload across clients + /// + /// Thrown if called multiple times. + public RealtimePresence Register(string presenceKey) + where TPresenceResponse : BasePresence + { + if (_presence != null) + throw new InvalidOperationException( + "Register can only be called with presence options for a channel once."); + + PresenceOptions = new PresenceOptions(presenceKey); + var instance = new RealtimePresence(this, PresenceOptions, Options.SerializerSettings); + _presence = instance; + + OnPresenceSync += (_, args) => _presence.TriggerSync(args); + OnPresenceDiff += (_, args) => _presence.TriggerDiff(args); + + return instance; + } + + public void AddStateChangedListener(IRealtimeChannel.StateChangedHandler stateChangedHandler) + { + if (!_stateChangedHandlers.Contains(stateChangedHandler)) + _stateChangedHandlers.Add(stateChangedHandler); + } + + public void RemoveStateChangedListener(IRealtimeChannel.StateChangedHandler stateChangedHandler) + { + if (_stateChangedHandlers.Contains(stateChangedHandler)) + _stateChangedHandlers.Remove(stateChangedHandler); + } + + public void ClearStateChangedListeners() => + _stateChangedHandlers.Clear(); + + private void NotifyStateChanged(ChannelState state, bool shouldRejoin = true) + { + State = state; + + _isRejoining = shouldRejoin; + if (shouldRejoin) + _rejoinTimer.Start(); + else + _rejoinTimer.Stop(); + + foreach (var handler in _stateChangedHandlers) + handler.Invoke(this, state); + } + + public void AddMessageReceivedHandler(IRealtimeChannel.MessageReceivedHandler messageReceivedHandler) + { + if (!_messageReceivedHandlers.Contains(messageReceivedHandler)) + _messageReceivedHandlers.Add(messageReceivedHandler); + } + + public void RemoveMessageReceivedHandler(IRealtimeChannel.MessageReceivedHandler messageReceivedHandler) + { + if (_messageReceivedHandlers.Contains(messageReceivedHandler)) + _messageReceivedHandlers.Remove(messageReceivedHandler); + } + + public void ClearMessageReceivedListeners() => + _messageReceivedHandlers.Clear(); + + private void NotifyMessageReceived(SocketResponse message) + { + foreach (var handler in _messageReceivedHandlers) + handler.Invoke(this, message); + } + + public void AddPostgresChangesListener(PostgresChangesOptions.ListenType listenType, + IRealtimeChannel.PostgresChangesHandler postgresChangesHandler) + { + if (_postgresChangesHandlers[listenType] == null) + _postgresChangesHandlers[listenType] = new List(); + + if (!_postgresChangesHandlers[listenType].Contains(postgresChangesHandler)) + _postgresChangesHandlers[listenType].Add(postgresChangesHandler); + } + + public void RemovePostgresChangesListener(PostgresChangesOptions.ListenType listenType, + IRealtimeChannel.PostgresChangesHandler postgresChangesHandler) + { + if (_postgresChangesHandlers.ContainsKey(listenType) && + _postgresChangesHandlers[listenType].Contains(postgresChangesHandler)) + _postgresChangesHandlers[listenType].Remove(postgresChangesHandler); + } + + public void ClearPostgresChangesListeners() => + _postgresChangesHandlers.Clear(); + + private void NotifyPostgresChanges(EventType eventType, PostgresChangesResponse response) + { + var listenType = ListenType.All; + + switch (eventType) + { + case EventType.Insert: + listenType = ListenType.Inserts; + break; + case EventType.Delete: + listenType = ListenType.Deletes; + break; + case EventType.Update: + listenType = ListenType.Updates; + break; + } + + // Invoke the wildcard listener (but only once) + if (listenType != ListenType.All) + foreach (var handler in _postgresChangesHandlers[ListenType.All]) + handler.Invoke(this, response); + + foreach (var handler in _postgresChangesHandlers[listenType]) + handler.Invoke(this, response); + } + + + /// + /// Registers postgres_changes options, can be called multiple times. + /// + /// + /// + public IRealtimeChannel Register(PostgresChangesOptions postgresChangesOptions) + { + PostgresChangesOptions.Add(postgresChangesOptions); + return this; + } + + /// + /// Subscribes to the channel given supplied Options/params. + /// + /// + public Task Subscribe(int timeoutMs = DefaultTimeout) + { + var tsc = new TaskCompletionSource(); + + if (IsSubscribed) + return Task.FromResult(this as IRealtimeChannel); + + JoinPush = GenerateJoinPush(); + IRealtimeChannel.StateChangedHandler? channelCallback = null; + EventHandler? joinPushTimeoutCallback = null; + + channelCallback = (sender, state) => + { + switch (state) + { + // Success! + case ChannelState.Joined: + HasJoinedOnce = true; + IsSubscribed = true; + + sender.RemoveStateChangedListener(channelCallback!); + JoinPush.OnTimeout -= joinPushTimeoutCallback; + + // Clear buffer + foreach (var item in _buffer) + item.Send(); + _buffer.Clear(); + + tsc.TrySetResult(this); + break; + // Failure + case ChannelState.Closed: + case ChannelState.Errored: + IsSubscribed = false; + sender.RemoveStateChangedListener(channelCallback!); + JoinPush.OnTimeout -= joinPushTimeoutCallback; + + tsc.TrySetException(new Exception("Error occurred connecting to channel. Check logs.")); + break; + } + }; + + // Throw an exception if there is a problem receiving a join response + joinPushTimeoutCallback = (_, _) => + { + RemoveStateChangedListener(channelCallback); + JoinPush.OnTimeout -= joinPushTimeoutCallback; + + tsc.TrySetException(new RealtimeException("Push Timeout") + { + Reason = FailureHint.Reason.PushTimeout + }); + }; + + AddStateChangedListener(channelCallback); + + // Set a flag to prevent multiple join attempts. + _hasJoinedOnce = true; + + // Init and send join. + Rejoin(timeoutMs); + JoinPush.OnTimeout += joinPushTimeoutCallback; + + return tsc.Task; + } + + /// + /// Unsubscribes from the channel. + /// + public IRealtimeChannel Unsubscribe() + { + IsSubscribed = false; + NotifyStateChanged(ChannelState.Leaving); + + var leavePush = new Push(_socket, this, ChannelEventLeave); + leavePush.Send(); + + NotifyStateChanged(ChannelState.Closed, false); + + return this; + } + + /// + /// Sends a `Push` request under this channel. + /// + /// Maintains a buffer in the event push is called prior to the channel being joined. + /// + /// + /// + /// + public Push Push(string eventName, string? type = null, object? payload = null, int timeoutMs = DefaultTimeout) + { + if (!_hasJoinedOnce) + throw new Exception( + $"Tried to push '{eventName}' to '{Topic}' before joining. Use `Channel.Subscribe()` before pushing events"); + + var push = new Push(_socket, this, eventName, type, payload, timeoutMs); + Enqueue(push); + + return push; + } + + /// + /// Sends an arbitrary payload with a given payload type () + /// + /// + /// + /// + public Task Send(ChannelEventName eventName, string? type, object payload, int timeoutMs = DefaultTimeout) + { + var tsc = new TaskCompletionSource(); + + var ev = Core.Helpers.GetMappedToAttr(eventName).Mapping; + var push = Push(ev, type, payload, timeoutMs); + + IRealtimePush.MessageEventHandler? messageCallback = null; + + messageCallback = (_, message) => + { + tsc.SetResult(message.Event != EventType.Unknown); + push.RemoveMessageReceivedListener(messageCallback!); + }; + + push.AddMessageReceivedListener(messageCallback); + return tsc.Task; + } + + /// + /// Rejoins the channel. + /// + /// + public void Rejoin(int timeoutMs = DefaultTimeout) + { + if (IsLeaving) return; + SendJoin(timeoutMs); + } + + /// + /// Enqueues a message. + /// + /// + private void Enqueue(Push push) + { + LastPush = push; + + if (CanPush) + { + LastPush.Send(); + } + else + { + LastPush.StartTimeout(); + _buffer.Add(LastPush); + } + } + + /// + /// Generates the Join Push message by merging broadcast, presence, and postgres_changes options. + /// + /// + private Push GenerateJoinPush() => new(_socket, this, ChannelEventJoin, + payload: new JoinPush(BroadcastOptions, PresenceOptions, PostgresChangesOptions)); + + /// + /// Generates an auth push. + /// + /// + private Push? GenerateAuthPush() + { + var accessToken = Options.RetrieveAccessToken(); + + if (!string.IsNullOrEmpty(accessToken)) + { + return new Push(_socket, this, ChannelAccessToken, payload: new Dictionary + { + { "access_token", accessToken! } + }); + } + + return null; + } + + /// + /// If the channel errors internally (phoenix error, not transport) attempt rejoining. + /// + /// + /// + private void HandleRejoinTimerElapsed(object sender, ElapsedEventArgs e) + { + if (_isRejoining) return; + _isRejoining = true; + + if (State != ChannelState.Closed && State != ChannelState.Errored) + return; + + Options.ClientOptions.Logger(Topic, "attempting to rejoin", null); + + // Reset join push instance + JoinPush = GenerateJoinPush(); + + Rejoin(); + } + + /// + /// Sends the phoenix server a join message. + /// + /// + private void SendJoin(int timeoutMs = DefaultTimeout) + { + NotifyStateChanged(ChannelState.Joining); + + // Remove handler if exists + if (JoinPush != null) + JoinPush.RemoveMessageReceivedListener(HandleJoinResponse); + + JoinPush = GenerateJoinPush(); + JoinPush.AddMessageReceivedListener(HandleJoinResponse); + JoinPush.Resend(timeoutMs); + } + + /// + /// Handles a received join response (received after sending on subscribe/reconnection) + /// + /// + /// + private void HandleJoinResponse(IRealtimePush sender, SocketResponse message) + { + if (message._event != ChannelEventReply) return; + + var obj = JsonConvert.DeserializeObject( + JsonConvert.SerializeObject(message.Payload, Options.SerializerSettings), + Options.SerializerSettings); + + if (obj == null) return; + + switch (obj.Status) + { + case PhoenixStatusOk: + // Disable Rejoin Timeout + _rejoinTimer.Stop(); + _isRejoining = false; + + var authPush = GenerateAuthPush(); + authPush?.Send(); + + NotifyStateChanged(ChannelState.Joined); + break; + case PheonixStatusError: + _rejoinTimer.Stop(); + _isRejoining = false; + + NotifyStateChanged(ChannelState.Errored); + break; + } + } + + /// + /// Called when a socket message is received, parses the correct event handler to pass to. + /// + /// + internal void HandleSocketMessage(SocketResponse message) + { + if (message.Ref == JoinPush?.Ref) return; + + // If we don't ignore this event we'll end up with double callbacks. + if (message._event == "*") return; + + NotifyMessageReceived(message); + + switch (message.Event) + { + case EventType.PostgresChanges: + var deserialized = + JsonConvert.DeserializeObject(message.Json!, + Options.SerializerSettings); + + if (deserialized?.Payload?.Data == null) return; + + deserialized.Json = message.Json; + deserialized.serializerSettings = Options.SerializerSettings; + + var newArgs = new PostgresChangesEventArgs(deserialized); + + // Invoke '*' listener + NotifyPostgresChanges(deserialized.Payload!.Data!.Type, deserialized); + + break; + case EventType.Broadcast: + //OnBroadcast?.Invoke(this, message); + break; + case EventType.PresenceState: + //OnPresenceSync?.Invoke(this, message); + break; + case EventType.PresenceDiff: + //OnPresenceDiff?.Invoke(this, message); + break; + } + } +} \ No newline at end of file diff --git a/Realtime/RealtimePresence.cs b/Realtime/RealtimePresence.cs index abdb2bd..57f8a40 100644 --- a/Realtime/RealtimePresence.cs +++ b/Realtime/RealtimePresence.cs @@ -102,7 +102,7 @@ public void TriggerDiff(SocketResponseEventArgs args) /// /// /// - public void Track(object? payload, int timeoutMs = DEFAULT_TIMEOUT) + public void Track(object? payload, int timeoutMs = DefaultTimeout) { var eventName = Core.Helpers.GetMappedToAttr(ChannelEventName.Presence).Mapping; channel.Push(eventName, "track", new Dictionary { { "event", "track" }, { "payload", payload } }, timeoutMs); diff --git a/Realtime/RealtimeSocket.cs b/Realtime/RealtimeSocket.cs index 88002ed..4e5b958 100644 --- a/Realtime/RealtimeSocket.cs +++ b/Realtime/RealtimeSocket.cs @@ -1,5 +1,4 @@ -using Newtonsoft.Json; -using Supabase.Realtime.Interfaces; +using Supabase.Realtime.Interfaces; using Supabase.Realtime.Socket; using System; using System.Collections.Generic; @@ -8,360 +7,484 @@ using System.Threading; using System.Threading.Tasks; using Websocket.Client; -using static Supabase.Realtime.Socket.SocketStateChangedEventArgs; +using static Supabase.Realtime.Constants; namespace Supabase.Realtime { - /// - /// Socket connection handler. - /// - public class RealtimeSocket : IDisposable, IRealtimeSocket - { - /// - /// Returns whether or not the connection is alive. - /// - public bool IsConnected => connection.IsRunning; - - /// - /// Invoked when the socket state changes. - /// - public event EventHandler? StateChanged; - - /// - /// Invoked when a message has been recieved and decoded. - /// - public event EventHandler? OnMessage; - - public event EventHandler? OnHeartbeat; - - private string endpoint; - private ClientOptions options; - private WebsocketClient connection; - - private Task? heartbeatTask; - private CancellationTokenSource? heartbeatTokenSource; - - private bool hasPendingHeartbeat = false; - private string? pendingHeartbeatRef = null; - - private Task? reconnectTask; - private CancellationTokenSource? reconnectTokenSource; - - private List buffer = new List(); - private bool isReconnecting = false; - private bool hasConnectBeenCalled = false; - - private JsonSerializerSettings serializerSettings; - - private string endpointUrl - { - get - { - var parameters = new Dictionary { - { "token", options.Parameters.Token }, - { "apikey", options.Parameters.ApiKey }, - { "vsn", "1.0.0" } - }; - - return string.Format($"{endpoint}?{Utils.QueryString(parameters)}"); - } - } - - /// - /// Initializes this Socket instance. - /// - /// - /// - public RealtimeSocket(string endpoint, ClientOptions options, JsonSerializerSettings serializerSettings) - { - this.serializerSettings = serializerSettings; - this.endpoint = $"{endpoint}/{Constants.TRANSPORT_WEBSOCKET}"; - this.options = options; - - if (!options.Headers.ContainsKey("X-Client-Info")) - options.Headers.Add("X-Client-Info", Core.Util.GetAssemblyVersion(typeof(Client))); - - connection = new WebsocketClient(new Uri(endpointUrl)); - } - - void IDisposable.Dispose() - { - DisposeConnection(); - } - - /// - /// Dispose of the web socket connection. - /// - private async void DisposeConnection() - { - if (connection == null) return; - - await connection.Stop(WebSocketCloseStatus.NormalClosure, string.Empty); - connection.Dispose(); - } - - /// - /// Connects to a socket server and registers event listeners. - /// - public async Task Connect() - { - // Ignore calling connect multiple times. - if (connection.IsRunning || hasConnectBeenCalled) return; - - connection.ReconnectTimeout = TimeSpan.FromSeconds(120); - connection.ErrorReconnectTimeout = TimeSpan.FromSeconds(30); - - connection.ReconnectionHappened.Subscribe(reconnectionInfo => - { - if (reconnectionInfo.Type != ReconnectionType.Initial) - isReconnecting = true; - - OnConnectionOpened(this, new EventArgs { }); - }); - - connection.DisconnectionHappened.Subscribe(disconnectionInfo => - { - if (disconnectionInfo.Exception != null) - OnConnectionError(this, disconnectionInfo); - else - OnConnectionClosed(this, disconnectionInfo); - }); - - connection.MessageReceived.Subscribe(msg => OnConnectionMessage(this, msg)); - - hasConnectBeenCalled = true; - - await connection.StartOrFail(); - } - - /// - /// Disconnects from the socket server. - /// - /// - /// - public void Disconnect(WebSocketCloseStatus code = WebSocketCloseStatus.NormalClosure, string reason = "") => connection?.Stop(code, reason); - - /// - /// Pushes formatted data to the socket server. - /// - /// If the connection is not alive, the data will be placed into a buffer to be sent when reconnected. - /// - /// - public void Push(SocketRequest data) - { - options.Logger("push", $"{data.Topic} {data.Event} ({data.Ref})", data.Payload); - - var task = new Task(() => options.Encode!(data, encoded => connection.Send(encoded))); - - if (connection.IsRunning) - task.Start(); - else - buffer.Add(task); - } - - /// - /// Returns the latency (in millis) of roundtrip time from socket to server and back. - /// - /// - public Task GetLatency() - { - var tsc = new TaskCompletionSource(); - - var start = DateTime.Now; - var pingRef = Guid.NewGuid().ToString(); - - EventHandler? handler = null; - - handler = (sender, args) => - { - if (args.Response.Ref == pingRef) - { - OnMessage -= handler; - tsc.SetResult((DateTime.Now - start).TotalMilliseconds); - } - }; - - OnMessage += handler; - - Push(new SocketRequest { Topic = "phoenix", Event = "heartbeat", Ref = pingRef }); - - return tsc.Task; - } - - /// - /// Maintains a heartbeat connection with the socket server to prevent disconnection. - /// - private void SendHeartbeat() - { - if (!connection.IsRunning) return; - - if (hasPendingHeartbeat) - { - hasPendingHeartbeat = false; - options.Logger("transport", "heartbeat timeout. Attempting to re-establish connection.", null); - connection.Stop(WebSocketCloseStatus.NormalClosure, "heartbeat timeout"); - return; - } - - pendingHeartbeatRef = MakeMsgRef(); - - Push(new SocketRequest { Topic = "phoenix", Event = "heartbeat", Ref = pendingHeartbeatRef.ToString(), Payload = new Dictionary() }); - } - - /// - /// Called when the socket opens, registers the heartbeat thread and cancels the reconnection timer. - /// - /// - /// - private void OnConnectionOpened(object sender, EventArgs args) - { - // Was a reconnection attempt - if (isReconnecting == true) - StateChanged?.Invoke(sender, new SocketStateChangedEventArgs(ConnectionState.Reconnected, args)); - - // Reset flag for reconnections - isReconnecting = false; - - options.Logger("transport", $"connected to ${endpointUrl}", null); - - if (reconnectTokenSource != null) - reconnectTokenSource.Cancel(); - - if (heartbeatTokenSource != null) - heartbeatTokenSource.Cancel(); - - hasPendingHeartbeat = false; - heartbeatTokenSource = new CancellationTokenSource(); - heartbeatTask = Task.Run(async () => - { - while (!heartbeatTokenSource.IsCancellationRequested) - { - SendHeartbeat(); - await Task.Delay(options.HeartbeatInterval, heartbeatTokenSource.Token); - } - }, heartbeatTokenSource.Token); - - // Send any pending `Push` messages that were queued while socket was disconnected. - FlushBuffer(); - - StateChanged?.Invoke(sender, new SocketStateChangedEventArgs(ConnectionState.Open, args)); - } - - /// - /// Parses a recieved socket message into a non-generic type. - /// - /// - /// - private void OnConnectionMessage(object sender, ResponseMessage args) - { - Task.Run(() => - { - options.Decode!(args.Text, decoded => - { - try - { - options.Logger("receive", args.Text, null); - - // Send Separate heartbeat event - if (decoded!.Ref == pendingHeartbeatRef) - { - OnHeartbeat?.Invoke(sender, new SocketResponseEventArgs(decoded)); - return; - } - - if (decoded.Event != Constants.EventType.System) - { - decoded!.Json = args.Text; - - StateChanged?.Invoke(sender, new SocketStateChangedEventArgs(ConnectionState.Message, new EventArgs())); - OnMessage?.Invoke(sender, new SocketResponseEventArgs(decoded)); - } - } - catch (Exception ex) - { - Debug.WriteLine($"{ex.Message}"); - } - }); - }); - } - - private void OnConnectionError(object sender, DisconnectionInfo args) - { - AttemptReconnection(); - StateChanged?.Invoke(sender, new SocketStateChangedEventArgs(ConnectionState.Error, new EventArgs())); - } - - /// - /// Begins the reconnection thread with a progressively increasing interval. - /// - /// - /// - private void OnConnectionClosed(object sender, DisconnectionInfo args) - { - options.Logger("transport", "close", args); - - if (args.Type != DisconnectionType.ByUser) - AttemptReconnection(); - - StateChanged?.Invoke(sender, new SocketStateChangedEventArgs(ConnectionState.Close, new EventArgs())); - } - - private void AttemptReconnection() - { - // Make sure that the connection closed handler doesn't get called repeatedly. - if (isReconnecting) return; - - if (reconnectTokenSource != null) - reconnectTokenSource.Cancel(); - - reconnectTokenSource = new CancellationTokenSource(); - reconnectTask = Task.Run(async () => - { - isReconnecting = true; - - var tries = 1; - while (!reconnectTokenSource.IsCancellationRequested) - { - // Delay reconnection for a set interval, by default it increases the - // time between executions. - var delay = options.ReconnectAfterInterval(tries++); - options.Logger("transport", "reconnection:attempt", $"Tries: {tries}, Delay: {delay.Seconds}s, Started: {DateTime.Now.ToShortTimeString()}"); - - await connection.Stop(WebSocketCloseStatus.EndpointUnavailable, "Closed"); - - await Task.Delay(delay, reconnectTokenSource.Token); - - await Connect(); - } - }, reconnectTokenSource.Token); - } - - /// - /// Generates an incrementing identifier for message references - this reference is used - /// to coordinate requests with their responses. - /// - /// - public string MakeMsgRef() => Guid.NewGuid().ToString(); - - /// - /// Returns the expected reply event name based off a generated message ref. - /// - /// - /// - public string ReplyEventName(string msgRef) => $"chan_reply_{msgRef}"; - - /// - /// Flushes `Push` requests added while a socket was disconnected. - /// - private void FlushBuffer() - { - if (connection.IsRunning) - { - foreach (var item in buffer) - item.Start(); - - buffer.Clear(); - } - } - } -} + /// + /// Socket connection handler. + /// + public class RealtimeSocket : IDisposable, IRealtimeSocket + { + /// + /// Returns whether or not the connection is alive. + /// + public bool IsConnected => _connection.IsRunning; + + private string EndpointUrl + { + get + { + var parameters = new Dictionary + { + { "token", _options.Parameters.Token }, + { "apikey", _options.Parameters.ApiKey }, + { "vsn", "1.0.0" } + }; + + return string.Format($"{_endpoint}?{Utils.QueryString(parameters)}"); + } + } + + /// + /// Handlers for notifications of state changes. + /// + private readonly List _socketEventHandlers = new(); + + /// + /// Handlers for notifications of message events. + /// + private readonly List _messageEventHandlers = new(); + + /// + /// Handlers for notifications of heartbeat events. + /// + private readonly List _heartbeatEventHandlers = new(); + + private readonly string _endpoint; + private readonly ClientOptions _options; + private readonly WebsocketClient _connection; + + private Task? _heartbeatTask; + private CancellationTokenSource? _heartbeatTokenSource; + + private bool _hasPendingHeartbeat; + private string? _pendingHeartbeatRef; + + private Task? _reconnectTask; + private CancellationTokenSource? _reconnectTokenSource; + + private readonly List _buffer = new(); + private bool _isReconnecting; + private bool _hasConnectBeenCalled; + + /// + /// Initializes this Socket instance. + /// + /// + /// + public RealtimeSocket(string endpoint, ClientOptions options) + { + _endpoint = $"{endpoint}/{TransportWebsocket}"; + _options = options; + + if (!options.Headers.ContainsKey("X-Client-Info")) + options.Headers.Add("X-Client-Info", Core.Util.GetAssemblyVersion(typeof(Client))); + + _connection = new WebsocketClient(new Uri(EndpointUrl)); + + AddStateChangedListener(HandleSocketStateChanged); + } + + private void HandleSocketStateChanged(IRealtimeSocket sender, SocketState state) + { + switch (state) + { + case SocketState.Open: + HandleSocketOpened(); + break; + case SocketState.Close: + HandleSocketClosed(); + break; + case SocketState.Error: + HandleSocketError(); + break; + } + } + + void IDisposable.Dispose() => + DisposeConnection(); + + /// + /// Connects to a socket server and registers event listeners. + /// + public async Task Connect() + { + // Ignore calling connect multiple times. + if (_connection.IsRunning || _hasConnectBeenCalled) return; + + _connection.ReconnectTimeout = TimeSpan.FromSeconds(120); + _connection.ErrorReconnectTimeout = TimeSpan.FromSeconds(30); + + _connection.ReconnectionHappened.Subscribe(reconnectionInfo => + { + if (reconnectionInfo.Type != ReconnectionType.Initial) + _isReconnecting = true; + + NotifySocketStateChange(SocketState.Open); + }); + + _connection.DisconnectionHappened.Subscribe(disconnectionInfo => + { + if (disconnectionInfo.Exception != null) + HandleSocketError(disconnectionInfo); + else + HandleSocketClosed(disconnectionInfo); + }); + + _connection.MessageReceived.Subscribe(msg => OnConnectionMessage(this, msg)); + + _hasConnectBeenCalled = true; + + await _connection.StartOrFail(); + } + + /// + /// Disconnects from the socket server. + /// + /// + /// + public void Disconnect(WebSocketCloseStatus code = WebSocketCloseStatus.NormalClosure, string reason = "") => + _connection?.Stop(code, reason); + + /// + /// Adds a listener to be notified when the socket state changes. + /// + /// + public void AddStateChangedListener(IRealtimeSocket.StateEventHandler stateEventHandler) + { + if (!_socketEventHandlers.Contains(stateEventHandler)) + _socketEventHandlers.Add(stateEventHandler); + } + + /// + /// Removes a specified listener from socket state changes. + /// + /// + public void RemoveStateChangedListener(IRealtimeSocket.StateEventHandler stateEventHandler) + { + if (_socketEventHandlers.Contains(stateEventHandler)) + _socketEventHandlers.Remove(stateEventHandler); + } + + /// + /// Notifies all listeners that the socket state has changed. + /// + /// + private void NotifySocketStateChange(SocketState newState) + { + foreach (var handler in _socketEventHandlers) + handler.Invoke(this, newState); + } + + /// + /// Clears all of the listeners from receiving event state changes. + /// + public void ClearStateChangedListeners() => + _socketEventHandlers.Clear(); + + /// + /// Adds a listener to be notified when a message is received. + /// + /// + public void AddMessageReceivedListener(IRealtimeSocket.MessageEventHandler messageEventHandler) + { + if (_messageEventHandlers.Contains(messageEventHandler)) + return; + + _messageEventHandlers.Add(messageEventHandler); + } + + /// + /// Removes a specified listener from messages received. + /// + /// + public void RemoveMessageReceivedListener(IRealtimeSocket.MessageEventHandler heartbeatHandler) + { + if (!_messageEventHandlers.Contains(heartbeatHandler)) + return; + + _messageEventHandlers.Remove(heartbeatHandler); + } + + /// + /// Notifies all listeners that the socket has received a message + /// + /// + private void NotifyMessageReceived(SocketResponse heartbeat) + { + foreach (var handler in _messageEventHandlers) + handler.Invoke(this, heartbeat); + } + + /// + /// Clears all of the listeners from receiving event state changes. + /// + public void ClearMessageReceivedListeners() => + _messageEventHandlers.Clear(); + + /// + /// Adds a listener to be notified when a message is received. + /// + /// + public void AddHeartbeatListener(IRealtimeSocket.HeartbeatEventHandler heartbeatHandler) + { + if (!_heartbeatEventHandlers.Contains(heartbeatHandler)) + _heartbeatEventHandlers.Add(heartbeatHandler); + } + + /// + /// Removes a specified listener from messages received. + /// + /// + public void RemoveHeartbeatListener(IRealtimeSocket.HeartbeatEventHandler heartbeatHandler) + { + if (_heartbeatEventHandlers.Contains(heartbeatHandler)) + _heartbeatEventHandlers.Remove(heartbeatHandler); + } + + /// + /// Notifies all listeners that the socket has received a heartbeat + /// + /// + private void NotifyHeartbeatReceived(SocketResponse heartbeat) + { + foreach (var handler in _heartbeatEventHandlers) + handler.Invoke(this, heartbeat); + } + + /// + /// Clears all of the listeners from receiving event state changes. + /// + public void ClearHeartbeatListeners() => + _heartbeatEventHandlers.Clear(); + + + /// + /// Pushes formatted data to the socket server. + /// + /// If the connection is not alive, the data will be placed into a buffer to be sent when reconnected. + /// + /// + public void Push(SocketRequest data) + { + _options.Logger("push", $"{data.Topic} {data.Event} ({data.Ref})", data.Payload); + + var task = new Task(() => _options.Encode!(data, encoded => _connection.Send(encoded))); + + if (_connection.IsRunning) + task.Start(); + else + _buffer.Add(task); + } + + /// + /// Returns the latency (in millis) of roundtrip time from socket to server and back. + /// + /// + public Task GetLatency() + { + var tsc = new TaskCompletionSource(); + var start = DateTime.Now; + var pingRef = Guid.NewGuid().ToString(); + + // ReSharper disable once ConvertToLocalFunction + IRealtimeSocket.MessageEventHandler? messageHandler = null; + messageHandler = (_, messageResponse) => + { + if (messageResponse.Ref == pingRef) + { + RemoveMessageReceivedListener(messageHandler!); + tsc.SetResult((DateTime.Now - start).TotalMilliseconds); + } + }; + AddMessageReceivedListener(messageHandler); + + Push(new SocketRequest { Topic = "phoenix", Event = "heartbeat", Ref = pingRef }); + + return tsc.Task; + } + + /// + /// Maintains a heartbeat connection with the socket server to prevent disconnection. + /// + private void SendHeartbeat() + { + if (!_connection.IsRunning) return; + + if (_hasPendingHeartbeat) + { + _hasPendingHeartbeat = false; + _options.Logger("transport", "heartbeat timeout. Attempting to re-establish connection.", null); + _connection.Stop(WebSocketCloseStatus.NormalClosure, "heartbeat timeout"); + return; + } + + _pendingHeartbeatRef = MakeMsgRef(); + + Push(new SocketRequest + { + Topic = "phoenix", Event = "heartbeat", Ref = _pendingHeartbeatRef, + Payload = new Dictionary() + }); + } + + /// + /// Called when the socket opens, registers the heartbeat thread and cancels the reconnection timer. + /// + private void HandleSocketOpened() + { + // Was a reconnection attempt + if (_isReconnecting == true) + NotifySocketStateChange(SocketState.Reconnect); + + // Reset flag for reconnections + _isReconnecting = false; + + _options.Logger("transport", $"connected to ${EndpointUrl}", null); + + if (_reconnectTokenSource != null) + _reconnectTokenSource.Cancel(); + + if (_heartbeatTokenSource != null) + _heartbeatTokenSource.Cancel(); + + _hasPendingHeartbeat = false; + _heartbeatTokenSource = new CancellationTokenSource(); + _heartbeatTask = Task.Run(async () => + { + while (!_heartbeatTokenSource.IsCancellationRequested) + { + SendHeartbeat(); + await Task.Delay(_options.HeartbeatInterval, _heartbeatTokenSource.Token); + } + }, _heartbeatTokenSource.Token); + + // Send any pending `Push` messages that were queued while socket was disconnected. + FlushBuffer(); + + NotifySocketStateChange(SocketState.Open); + } + + /// + /// Parses a recieved socket message into a non-generic type. + /// + /// + /// + private void OnConnectionMessage(object sender, ResponseMessage args) + { + Task.Run(() => + { + _options.Decode!(args.Text, decoded => + { + try + { + _options.Logger("receive", args.Text, null); + + // Send Separate heartbeat event + if (decoded!.Ref == _pendingHeartbeatRef) + { + NotifyHeartbeatReceived(decoded); + return; + } + + if (decoded.Event != EventType.System) + { + decoded!.Json = args.Text; + NotifyMessageReceived(decoded); + } + } + catch (Exception ex) + { + Debug.WriteLine($"{ex.Message}"); + } + }); + }); + } + + private void HandleSocketError(DisconnectionInfo? disconnectionInfo = null) + { + AttemptReconnection(); + NotifySocketStateChange(SocketState.Error); + } + + /// + /// Begins the reconnection thread with a progressively increasing interval. + /// + private void HandleSocketClosed(DisconnectionInfo? disconnectionInfo = null) + { + _options.Logger("transport", "close", disconnectionInfo); + + if (disconnectionInfo?.Type != DisconnectionType.ByUser) + AttemptReconnection(); + + NotifySocketStateChange(SocketState.Close); + } + + private void AttemptReconnection() + { + // Make sure that the connection closed handler doesn't get called repeatedly. + if (_isReconnecting) return; + + var tries = 1; + _reconnectTokenSource?.Cancel(); + _reconnectTokenSource = new CancellationTokenSource(); + _reconnectTask = Task.Run(async () => + { + _isReconnecting = true; + + while (!_reconnectTokenSource.IsCancellationRequested) + { + // Delay reconnection for a set interval, by default it increases the + // time between executions. + var delay = _options.ReconnectAfterInterval(tries++); + _options.Logger("transport", "reconnection:attempt", + $"Tries: {tries}, Delay: {delay.Seconds}s, Started: {DateTime.Now.ToShortTimeString()}"); + + await _connection.Stop(WebSocketCloseStatus.EndpointUnavailable, "Closed"); + + await Task.Delay(delay, _reconnectTokenSource.Token); + + await Connect(); + } + }, _reconnectTokenSource.Token); + } + + /// + /// Generates an incrementing identifier for message references - this reference is used + /// to coordinate requests with their responses. + /// + /// + public string MakeMsgRef() => Guid.NewGuid().ToString(); + + /// + /// Returns the expected reply event name based off a generated message ref. + /// + /// + /// + public string ReplyEventName(string msgRef) => $"chan_reply_{msgRef}"; + + /// + /// Dispose of the web socket connection. + /// + private async void DisposeConnection() + { + await _connection.Stop(WebSocketCloseStatus.NormalClosure, string.Empty); + _connection.Dispose(); + } + + /// + /// Flushes `Push` requests added while a socket was disconnected. + /// + private void FlushBuffer() + { + if (_connection.IsRunning) + { + foreach (var item in _buffer) + item.Start(); + + _buffer.Clear(); + } + } + } +} \ No newline at end of file diff --git a/RealtimeTests/ClientTests.cs b/RealtimeTests/ClientTests.cs index 3abdc6f..4be4776 100644 --- a/RealtimeTests/ClientTests.cs +++ b/RealtimeTests/ClientTests.cs @@ -112,7 +112,7 @@ public async Task ClientSetsAuth() socketClient!.SetAuth(token); foreach (var subscription in socketClient!.Subscriptions.Values) { - Assert.IsTrue(subscription?.LastPush?.EventName == CHANNEL_ACCESS_TOKEN); + Assert.IsTrue(subscription?.LastPush?.EventName == ChannelAccessToken); } } From 0c93a0f97b078d14c2c92b9770b2ad60fcc48bbd Mon Sep 17 00:00:00 2001 From: Joseph Schultz Date: Wed, 10 May 2023 07:18:40 -0500 Subject: [PATCH 4/7] Update codebase changes on test and example projects so that solution builds --- Examples/RealtimeExample/Program.cs | 21 +- Realtime/Interfaces/IRealtimeChannel.cs | 11 +- Realtime/RealtimeChannel.cs | 18 +- RealtimeTests/ChannelTests.cs | 592 ++++++++++++------------ 4 files changed, 326 insertions(+), 316 deletions(-) diff --git a/Examples/RealtimeExample/Program.cs b/Examples/RealtimeExample/Program.cs index 9d25db9..97f7dcb 100644 --- a/Examples/RealtimeExample/Program.cs +++ b/Examples/RealtimeExample/Program.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Supabase.Realtime.Interfaces; using static Supabase.Realtime.Constants; +using static Supabase.Realtime.PostgresChanges.PostgresChangesOptions; namespace RealtimeExample { @@ -24,16 +25,23 @@ static async Task Main(string[] args) // Subscribe to a channel and events var channelUsers = realtimeClient.Channel("realtime", "public", "users"); - channelUsers.OnInsert += (s, args) => Console.WriteLine("New item inserted: " + args.Response.Payload.Data.Record); - channelUsers.OnUpdate += (s, args) => Console.WriteLine("Item updated: " + args.Response.Payload.Data.Record); - channelUsers.OnDelete += (s, args) => Console.WriteLine("Item deleted"); + channelUsers.AddPostgresChangeListener(ListenType.Inserts, + (_, change) => { Console.WriteLine($"New item inserted: {change.Model()}"); }); + channelUsers.AddPostgresChangeListener(ListenType.Updates, + (_, change) => { Console.WriteLine($"Item Updated: {change.Model()}"); }); + channelUsers.AddPostgresChangeListener(ListenType.Deletes, + (_, change) => { Console.WriteLine($"Item Deleted"); }); Console.WriteLine("Subscribing to users channel"); await channelUsers.Subscribe(); //Subscribing to another channel var channelTodos = realtimeClient.Channel("realtime", "public", "todos"); - channelTodos.OnClose += (object sender, ChannelStateChangedEventArgs args) => Console.WriteLine($"Channel todos { args.State}!!"); + + channelTodos.AddStateChangedListener((_, state) => + { + Console.WriteLine($"Channel todos {state}!!"); + }); Console.WriteLine("Subscribing to todos channel"); await channelTodos.Subscribe(); @@ -51,9 +59,10 @@ static async Task Main(string[] args) Console.ReadKey(); } - private static void SocketEventHandler(IRealtimeClient sender, SocketState state) + private static void SocketEventHandler(IRealtimeClient sender, + SocketState state) { Debug.WriteLine($"Socket is ${state.ToString()}"); } } -} +} \ No newline at end of file diff --git a/Realtime/Interfaces/IRealtimeChannel.cs b/Realtime/Interfaces/IRealtimeChannel.cs index d5bbf66..99739c4 100644 --- a/Realtime/Interfaces/IRealtimeChannel.cs +++ b/Realtime/Interfaces/IRealtimeChannel.cs @@ -8,6 +8,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using static Supabase.Realtime.Constants; +using static Supabase.Realtime.PostgresChanges.PostgresChangesOptions; namespace Supabase.Realtime.Interfaces { @@ -17,7 +18,7 @@ public interface IRealtimeChannel delegate void StateChangedHandler(IRealtimeChannel sender, ChannelState state); - delegate void PostgresChangesHandler(IRealtimeChannel sender, PostgresChangesResponse changes); + delegate void PostgresChangesHandler(IRealtimeChannel sender, PostgresChangesResponse change); bool HasJoinedOnce { get; } bool IsClosed { get; } @@ -44,13 +45,11 @@ public interface IRealtimeChannel void ClearMessageReceivedListeners(); - void AddPostgresChangesListener(PostgresChangesOptions.ListenType listenType, - PostgresChangesHandler postgresChangesHandler); + void AddPostgresChangeListener(ListenType listenType, PostgresChangesHandler postgresChangeHandler); - void RemovePostgresChangesListener(PostgresChangesOptions.ListenType listenType, - IRealtimeChannel.PostgresChangesHandler postgresChangesHandler); + void RemovePostgresChangeListener(ListenType listenType, PostgresChangesHandler postgresChangeHandler); - void ClearPostgresChangesListeners(); + void ClearPostgresChangeListeners(); IRealtimeBroadcast? Broadcast(); IRealtimePresence? Presence(); diff --git a/Realtime/RealtimeChannel.cs b/Realtime/RealtimeChannel.cs index 42399dc..c160676 100644 --- a/Realtime/RealtimeChannel.cs +++ b/Realtime/RealtimeChannel.cs @@ -264,25 +264,25 @@ private void NotifyMessageReceived(SocketResponse message) handler.Invoke(this, message); } - public void AddPostgresChangesListener(PostgresChangesOptions.ListenType listenType, - IRealtimeChannel.PostgresChangesHandler postgresChangesHandler) + public void AddPostgresChangeListener(PostgresChangesOptions.ListenType listenType, + IRealtimeChannel.PostgresChangesHandler postgresChangeHandler) { if (_postgresChangesHandlers[listenType] == null) _postgresChangesHandlers[listenType] = new List(); - if (!_postgresChangesHandlers[listenType].Contains(postgresChangesHandler)) - _postgresChangesHandlers[listenType].Add(postgresChangesHandler); + if (!_postgresChangesHandlers[listenType].Contains(postgresChangeHandler)) + _postgresChangesHandlers[listenType].Add(postgresChangeHandler); } - public void RemovePostgresChangesListener(PostgresChangesOptions.ListenType listenType, - IRealtimeChannel.PostgresChangesHandler postgresChangesHandler) + public void RemovePostgresChangeListener(PostgresChangesOptions.ListenType listenType, + IRealtimeChannel.PostgresChangesHandler postgresChangeHandler) { if (_postgresChangesHandlers.ContainsKey(listenType) && - _postgresChangesHandlers[listenType].Contains(postgresChangesHandler)) - _postgresChangesHandlers[listenType].Remove(postgresChangesHandler); + _postgresChangesHandlers[listenType].Contains(postgresChangeHandler)) + _postgresChangesHandlers[listenType].Remove(postgresChangeHandler); } - public void ClearPostgresChangesListeners() => + public void ClearPostgresChangeListeners() => _postgresChangesHandlers.Clear(); private void NotifyPostgresChanges(EventType eventType, PostgresChangesResponse response) diff --git a/RealtimeTests/ChannelTests.cs b/RealtimeTests/ChannelTests.cs index f612940..bf2bf72 100644 --- a/RealtimeTests/ChannelTests.cs +++ b/RealtimeTests/ChannelTests.cs @@ -9,343 +9,345 @@ using Supabase.Realtime; using Supabase.Realtime.Interfaces; using Supabase.Realtime.Models; +using Supabase.Realtime.PostgresChanges; using static Supabase.Realtime.Constants; namespace RealtimeTests { - public class TimePresence : BasePresence - { - [JsonProperty("time")] - public DateTime? Time { get; set; } - } - - public class BroadcastExample : BaseBroadcast - { - [JsonProperty("userId")] - public string? UserId { get; set; } - } - - [TestClass] - public class ChannelTests - { - private IPostgrestClient? restClient; - private IRealtimeClient? socketClient; - - [TestInitialize] - public async Task InitializeTest() - { - var session = await Helpers.GetSession(); - restClient = Helpers.RestClient(session!.AccessToken!); - socketClient = Helpers.SocketClient(); - - await socketClient!.ConnectAsync(); - socketClient!.SetAuth(session.AccessToken!); - } - - [TestCleanup] - public void CleanupTest() - { - socketClient!.Disconnect(); - } - - [TestMethod("Channel: Can create presence")] - public async Task ClientCanCreatePresence() - { - var tsc = new TaskCompletionSource(); - var tsc2 = new TaskCompletionSource(); - - var guid1 = Guid.NewGuid().ToString(); - var guid2 = Guid.NewGuid().ToString(); - - var channel1 = socketClient!.Channel("online-users"); - var presence1 = channel1.Register(guid1); - presence1.OnSync += (_, _) => - { - var state = presence1.CurrentState; - if (state.ContainsKey(guid2) && state[guid2].First().Time != null) - { - tsc.SetResult(true); - } - }; - - var client2 = Helpers.SocketClient(); - await client2.ConnectAsync(); - var channel2 = client2.Channel("online-users"); - var presence2 = channel2.Register(guid2); - presence2.OnSync += (_, _) => - { - var state = presence2.CurrentState; - if (state.ContainsKey(guid1) && state[guid1].First().Time != null) - { - tsc2.SetResult(true); - } - }; - - await channel1.Subscribe(); - await channel2.Subscribe(); - - presence1.Track(new TimePresence { Time = DateTime.Now }); - presence2.Track(new TimePresence { Time = DateTime.Now }); - - await Task.WhenAll(new[] { tsc.Task, tsc2.Task }); - } - - [TestMethod("Channel: Can listen for broadcast")] - public async Task ClientCanListenForBroadcast() - { - var tsc = new TaskCompletionSource(); - var tsc2 = new TaskCompletionSource(); - - var guid1 = Guid.NewGuid().ToString(); - var guid2 = Guid.NewGuid().ToString(); - - var channel1 = socketClient!.Channel("online-users"); - var broadcast1 = channel1.Register(true, true); - broadcast1.OnBroadcast += (_, _) => - { - var broadcast = broadcast1.Current(); - if (broadcast?.UserId != guid1 && broadcast?.Event == "user") - { - tsc.TrySetResult(true); - } - }; - - var client2 = Helpers.SocketClient(); - await client2.ConnectAsync(); - var channel2 = client2.Channel("online-users"); - var broadcast2 = channel2.Register(true, true); - broadcast2.OnBroadcast += (_, _) => - { - var broadcast = broadcast2.Current(); - if (broadcast?.UserId != guid2 && broadcast?.Event == "user") - { - tsc2.TrySetResult(true); - } - }; - - await channel1.Subscribe(); - await channel2.Subscribe(); - - await broadcast1.Send("user", new BroadcastExample { UserId = guid1 }); - await broadcast2.Send("user", new BroadcastExample { UserId = guid2 }); - - await Task.WhenAll(new[] { tsc.Task, tsc2.Task }); - } - - [TestMethod("Channel: Payload returns a modeled response (if possible)")] - public async Task ChannelPayloadReturnsModel() - { - var tsc = new TaskCompletionSource(); - - var channel = socketClient!.Channel("realtime", "public", "*"); - - channel.OnInsert += (_, e) => - { - var model = e.Response?.Model(); - tsc.SetResult(model != null); - }; - - await channel.Subscribe(); - - await restClient!.Table().Insert(new Todo { UserId = 1, Details = "Client Models a response? ✅" }); - - var check = await tsc.Task; - Assert.IsTrue(check); - } - - [TestMethod("Channel: Close Event Handler")] - public async Task ChannelCloseEventHandler() - { - var tsc = new TaskCompletionSource(); - - var channel = socketClient!.Channel("realtime", "public", "todos"); - channel.OnClose += (_, args) => - { - tsc.SetResult(ChannelState.Closed == args.State); - }; - - await channel.Subscribe(); - channel.Unsubscribe(); - - var check = await tsc.Task; - Assert.IsTrue(check); - } - - - [TestMethod("Channel: Receives Insert Callback")] - public async Task ChannelReceivesInsertCallback() - { - var tsc = new TaskCompletionSource(); - - var channel = socketClient!.Channel("realtime", "public", "todos"); - - channel.OnInsert += (_, _) => tsc.SetResult(true); + public class TimePresence : BasePresence + { + [JsonProperty("time")] public DateTime? Time { get; set; } + } + + public class BroadcastExample : BaseBroadcast + { + [JsonProperty("userId")] public string? UserId { get; set; } + } + + [TestClass] + public class ChannelTests + { + private IPostgrestClient? _restClient; + private IRealtimeClient? _socketClient; + + [TestInitialize] + public async Task InitializeTest() + { + var session = await Helpers.GetSession(); + _restClient = Helpers.RestClient(session!.AccessToken!); + _socketClient = Helpers.SocketClient(); + + await _socketClient!.ConnectAsync(); + _socketClient!.SetAuth(session.AccessToken!); + } + + [TestCleanup] + public void CleanupTest() + { + _socketClient!.Disconnect(); + } + + [TestMethod("Channel: Can create presence")] + public async Task ClientCanCreatePresence() + { + var tsc = new TaskCompletionSource(); + var tsc2 = new TaskCompletionSource(); + + var guid1 = Guid.NewGuid().ToString(); + var guid2 = Guid.NewGuid().ToString(); + + var channel1 = _socketClient!.Channel("online-users"); + var presence1 = channel1.Register(guid1); + presence1.OnSync += (_, _) => + { + var state = presence1.CurrentState; + if (state.ContainsKey(guid2) && state[guid2].First().Time != null) + { + tsc.SetResult(true); + } + }; + + var client2 = Helpers.SocketClient(); + await client2.ConnectAsync(); + var channel2 = client2.Channel("online-users"); + var presence2 = channel2.Register(guid2); + presence2.OnSync += (_, _) => + { + var state = presence2.CurrentState; + if (state.ContainsKey(guid1) && state[guid1].First().Time != null) + { + tsc2.SetResult(true); + } + }; + + await channel1.Subscribe(); + await channel2.Subscribe(); + + presence1.Track(new TimePresence { Time = DateTime.Now }); + presence2.Track(new TimePresence { Time = DateTime.Now }); + + await Task.WhenAll(new[] { tsc.Task, tsc2.Task }); + } + + [TestMethod("Channel: Can listen for broadcast")] + public async Task ClientCanListenForBroadcast() + { + var tsc = new TaskCompletionSource(); + var tsc2 = new TaskCompletionSource(); + + var guid1 = Guid.NewGuid().ToString(); + var guid2 = Guid.NewGuid().ToString(); + + var channel1 = _socketClient!.Channel("online-users"); + var broadcast1 = channel1.Register(true, true); + broadcast1.OnBroadcast += (_, _) => + { + var broadcast = broadcast1.Current(); + if (broadcast?.UserId != guid1 && broadcast?.Event == "user") + { + tsc.TrySetResult(true); + } + }; + + var client2 = Helpers.SocketClient(); + await client2.ConnectAsync(); + var channel2 = client2.Channel("online-users"); + var broadcast2 = channel2.Register(true, true); + broadcast2.OnBroadcast += (_, _) => + { + var broadcast = broadcast2.Current(); + if (broadcast?.UserId != guid2 && broadcast?.Event == "user") + { + tsc2.TrySetResult(true); + } + }; + + await channel1.Subscribe(); + await channel2.Subscribe(); + + await broadcast1.Send("user", new BroadcastExample { UserId = guid1 }); + await broadcast2.Send("user", new BroadcastExample { UserId = guid2 }); + + await Task.WhenAll(new[] { tsc.Task, tsc2.Task }); + } + + [TestMethod("Channel: Payload returns a modeled response (if possible)")] + public async Task ChannelPayloadReturnsModel() + { + var tsc = new TaskCompletionSource(); + + var channel = _socketClient!.Channel("realtime", "public", "*"); + + channel.AddPostgresChangeListener(PostgresChangesOptions.ListenType.Inserts, (_, changes) => + { + var model = changes.Model(); + tsc.SetResult(model != null); + }); + + await channel.Subscribe(); + + await _restClient!.Table().Insert(new Todo { UserId = 1, Details = "Client Models a response? ✅" }); + + var check = await tsc.Task; + Assert.IsTrue(check); + } + + [TestMethod("Channel: Close Event Handler")] + public async Task ChannelCloseEventHandler() + { + var tsc = new TaskCompletionSource(); + + var channel = _socketClient!.Channel("realtime", "public", "todos"); + channel.AddStateChangedListener((_, state) => + { + if (state == ChannelState.Closed) + tsc.SetResult(true); + }); + + await channel.Subscribe(); + channel.Unsubscribe(); + + var check = await tsc.Task; + Assert.IsTrue(check); + } + - await channel.Subscribe(); - await restClient!.Table().Insert(new Todo { UserId = 1, Details = "Client receives insert callback? ✅" }); + [TestMethod("Channel: Receives Insert Callback")] + public async Task ChannelReceivesInsertCallback() + { + var tsc = new TaskCompletionSource(); - var check = await tsc.Task; - Assert.IsTrue(check); - } - - [TestMethod("Channel: Receives Update Callback")] - public async Task ChannelReceivesUpdateCallback() - { - var tsc = new TaskCompletionSource(); - - var result = await restClient!.Table().Order(x => x.InsertedAt!, Postgrest.Constants.Ordering.Descending).Get(); - var model = result.Models.First(); - var oldDetails = model.Details; - var newDetails = $"I'm an updated item ✏️ - {DateTime.Now}"; + var channel = _socketClient!.Channel("realtime", "public", "todos"); - var channel = socketClient!.Channel("realtime", "public", "todos"); - - channel.OnUpdate += (_, args) => - { - var oldModel = args.Response?.OldModel(); + channel.AddPostgresChangeListener(PostgresChangesOptions.ListenType.Inserts, + (_, _) => tsc.SetResult(true)); - Assert.AreEqual(oldDetails, oldModel?.Details); + await channel.Subscribe(); + await _restClient!.Table() + .Insert(new Todo { UserId = 1, Details = "Client receives insert callback? ✅" }); - var updated = args.Response?.Model(); - Assert.AreEqual(newDetails, updated?.Details); - - if (updated != null) - { - Assert.AreEqual(model.Id, updated.Id); - Assert.AreEqual(model.UserId, updated.UserId); - } + var check = await tsc.Task; + Assert.IsTrue(check); + } - tsc.SetResult(true); - }; + [TestMethod("Channel: Receives Update Callback")] + public async Task ChannelReceivesUpdateCallback() + { + var tsc = new TaskCompletionSource(); - await channel.Subscribe(); + var result = await _restClient!.Table() + .Order(x => x.InsertedAt!, Postgrest.Constants.Ordering.Descending).Get(); + var model = result.Models.First(); + var oldDetails = model.Details; + var newDetails = $"I'm an updated item ✏️ - {DateTime.Now}"; - await restClient.Table() - .Set(x => x.Details!, newDetails) - .Match(model) - .Update(); + var channel = _socketClient!.Channel("realtime", "public", "todos"); - var check = await tsc.Task; - Assert.IsTrue(check); - } + channel.AddPostgresChangeListener(PostgresChangesOptions.ListenType.Updates, (_, changes) => + { + var oldModel = changes.OldModel(); - [TestMethod("Channel: Receives Delete Callback")] - public async Task ChannelReceivesDeleteCallback() - { - var tsc = new TaskCompletionSource(); + Assert.AreEqual(oldDetails, oldModel?.Details); - var channel = socketClient!.Channel("realtime", "public", "todos"); + var updated = changes.Model(); + Assert.AreEqual(newDetails, updated?.Details); - channel.OnDelete += (_, _) => - { - tsc.SetResult(true); - }; + if (updated != null) + { + Assert.AreEqual(model.Id, updated.Id); + Assert.AreEqual(model.UserId, updated.UserId); + } - await channel.Subscribe(); + tsc.SetResult(true); + }); - var result = await restClient!.Table().Get(); - var model = result.Models.Last(); + await channel.Subscribe(); - await restClient.Table().Match(model).Delete(); + await _restClient.Table() + .Set(x => x.Details!, newDetails) + .Match(model) + .Update(); - var check = await tsc.Task; - Assert.IsTrue(check); - } + var check = await tsc.Task; + Assert.IsTrue(check); + } - [TestMethod("Channel: Supports WALRUS Array Changes")] - public async Task ChannelSupportsWalrusArray() - { - Todo? result = null; - var tsc = new TaskCompletionSource(); + [TestMethod("Channel: Receives Delete Callback")] + public async Task ChannelReceivesDeleteCallback() + { + var tsc = new TaskCompletionSource(); - var channel = socketClient!.Channel("realtime", "public", "todos"); - var numbers = new List { 4, 5, 6 }; + var channel = _socketClient!.Channel("realtime", "public", "todos"); - await channel.Subscribe(); + channel.AddPostgresChangeListener(PostgresChangesOptions.ListenType.Deletes, + (_, _) => tsc.SetResult(true)); - channel.OnInsert += (_, args) => - { - result = args.Response?.Model(); - tsc.SetResult(true); - }; + await channel.Subscribe(); - await restClient!.Table().Insert(new Todo { UserId = 1, Numbers = numbers }); + var result = await _restClient!.Table().Get(); + var model = result.Models.Last(); - await tsc.Task; - CollectionAssert.AreEqual(numbers, result?.Numbers); - } + await _restClient.Table().Match(model).Delete(); - [TestMethod("Channel: Sends Join parameters")] - public async Task ChannelSendsJoinParameters() - { - var parameters = new Dictionary { { "key", "value" } }; - var channel = socketClient!.Channel("realtime", "public", "todos", parameters: parameters); + var check = await tsc.Task; + Assert.IsTrue(check); + } - await channel.Subscribe(); + [TestMethod("Channel: Supports WALRUS Array Changes")] + public async Task ChannelSupportsWalrusArray() + { + Todo? result = null; + var tsc = new TaskCompletionSource(); - var serialized = JsonConvert.SerializeObject(channel.JoinPush?.Payload); - Assert.IsTrue(serialized.Contains("\"key\":\"value\"")); - } + var channel = _socketClient!.Channel("realtime", "public", "todos"); + var numbers = new List { 4, 5, 6 }; - [TestMethod("Channel: Returns single subscription per unique topic.")] - public async Task ChannelJoinsDuplicateSubscription() - { - var subscription1 = socketClient!.Channel("realtime", "public", "todos"); - var subscription2 = socketClient!.Channel("realtime", "public", "todos"); - var subscription3 = socketClient!.Channel("realtime", "public", "todos", "user_id", "1"); + await channel.Subscribe(); - Assert.AreEqual(subscription1.Topic, subscription2.Topic); + channel.AddPostgresChangeListener(PostgresChangesOptions.ListenType.Inserts, (_, changes) => + { + result = changes.Model(); + tsc.SetResult(true); + }); - await subscription1.Subscribe(); + await _restClient!.Table().Insert(new Todo { UserId = 1, Numbers = numbers }); - Assert.AreEqual(subscription1.HasJoinedOnce, subscription2.HasJoinedOnce); - Assert.AreNotEqual(subscription1.HasJoinedOnce, subscription3.HasJoinedOnce); + await tsc.Task; + CollectionAssert.AreEqual(numbers, result?.Numbers); + } - var subscription4 = socketClient!.Channel("realtime", "public", "todos"); + [TestMethod("Channel: Sends Join parameters")] + public async Task ChannelSendsJoinParameters() + { + var parameters = new Dictionary { { "key", "value" } }; + var channel = _socketClient!.Channel("realtime", "public", "todos", parameters: parameters); - Assert.AreEqual(subscription1.HasJoinedOnce, subscription4.HasJoinedOnce); - } + await channel.Subscribe(); - [TestMethod("Channel: Receives '*' Callback")] - public async Task ChannelReceivesWildcardCallback() - { - var insertTsc = new TaskCompletionSource(); - var updateTsc = new TaskCompletionSource(); - var deleteTsc = new TaskCompletionSource(); + var serialized = JsonConvert.SerializeObject(channel.JoinPush?.Payload); + Assert.IsTrue(serialized.Contains("\"key\":\"value\"")); + } - List tasks = new List { insertTsc.Task, updateTsc.Task, deleteTsc.Task }; + [TestMethod("Channel: Returns single subscription per unique topic.")] + public async Task ChannelJoinsDuplicateSubscription() + { + var subscription1 = _socketClient!.Channel("realtime", "public", "todos"); + var subscription2 = _socketClient!.Channel("realtime", "public", "todos"); + var subscription3 = _socketClient!.Channel("realtime", "public", "todos", "user_id", "1"); - var channel = socketClient!.Channel("realtime", "public", "todos"); + Assert.AreEqual(subscription1.Topic, subscription2.Topic); - channel.OnPostgresChange += (_, e) => - { - switch (e.Response?.Payload?.Data?.Type) - { - case EventType.Insert: - insertTsc.SetResult(true); - break; - case EventType.Update: - updateTsc.SetResult(true); - break; - case EventType.Delete: - deleteTsc.SetResult(true); - break; - } - }; + await subscription1.Subscribe(); - await channel.Subscribe(); + Assert.AreEqual(subscription1.HasJoinedOnce, subscription2.HasJoinedOnce); + Assert.AreNotEqual(subscription1.HasJoinedOnce, subscription3.HasJoinedOnce); - var modeledResponse = await restClient!.Table().Insert(new Todo { UserId = 1, Details = "Client receives wildcard callbacks? ✅" }); - var newModel = modeledResponse.Models.First(); + var subscription4 = _socketClient!.Channel("realtime", "public", "todos"); - await restClient.Table().Set(x => x.Details!, "And edits.").Match(newModel).Update(); - await restClient.Table().Match(newModel).Delete(); + Assert.AreEqual(subscription1.HasJoinedOnce, subscription4.HasJoinedOnce); + } - await Task.WhenAll(tasks); + [TestMethod("Channel: Receives '*' Callback")] + public async Task ChannelReceivesWildcardCallback() + { + var insertTsc = new TaskCompletionSource(); + var updateTsc = new TaskCompletionSource(); + var deleteTsc = new TaskCompletionSource(); - Assert.IsTrue(insertTsc.Task.Result); - Assert.IsTrue(updateTsc.Task.Result); - Assert.IsTrue(deleteTsc.Task.Result); - } - } + List tasks = new List { insertTsc.Task, updateTsc.Task, deleteTsc.Task }; + + var channel = _socketClient!.Channel("realtime", "public", "todos"); + + channel.AddPostgresChangeListener(PostgresChangesOptions.ListenType.All, (_, changes) => + { + switch (changes.Payload?.Data?.Type) + { + case EventType.Insert: + insertTsc.SetResult(true); + break; + case EventType.Update: + updateTsc.SetResult(true); + break; + case EventType.Delete: + deleteTsc.SetResult(true); + break; + } + }); + + await channel.Subscribe(); + + var modeledResponse = await _restClient!.Table().Insert(new Todo + { UserId = 1, Details = "Client receives wildcard callbacks? ✅" }); + var newModel = modeledResponse.Models.First(); + + await _restClient.Table().Set(x => x.Details!, "And edits.").Match(newModel).Update(); + await _restClient.Table().Match(newModel).Delete(); + + await Task.WhenAll(tasks); + + Assert.IsTrue(insertTsc.Task.Result); + Assert.IsTrue(updateTsc.Task.Result); + Assert.IsTrue(deleteTsc.Task.Result); + } + } } \ No newline at end of file From 698e2fb1a5b2348570554a69e4931e1c2204083e Mon Sep 17 00:00:00 2001 From: Joseph Schultz Date: Fri, 12 May 2023 20:00:06 -0500 Subject: [PATCH 5/7] Refactor to using `delegates` instead of `EventHandlers` --- Examples/PresenceExample/Pages/Index.razor | 576 +++++++++++---------- Examples/RealtimeExample/Program.cs | 8 +- Realtime/Interfaces/IRealtimeBroadcast.cs | 12 +- Realtime/Interfaces/IRealtimeChannel.cs | 15 +- Realtime/Interfaces/IRealtimePresence.cs | 32 +- Realtime/RealtimeBroadcast.cs | 172 +++--- Realtime/RealtimeChannel.cs | 135 +++-- Realtime/RealtimePresence.cs | 327 +++++++----- RealtimeTests/ChannelTests.cs | 38 +- 9 files changed, 725 insertions(+), 590 deletions(-) diff --git a/Examples/PresenceExample/Pages/Index.razor b/Examples/PresenceExample/Pages/Index.razor index cee2220..15a4351 100644 --- a/Examples/PresenceExample/Pages/Index.razor +++ b/Examples/PresenceExample/Pages/Index.razor @@ -3,311 +3,313 @@ @using PresenceExample.Components; @using PresenceExample.Models; @using Supabase.Realtime; +@using Supabase.Realtime.Interfaces @using Supabase.Realtime.Models; -@using static Supabase.Realtime.Socket.SocketStateChangedEventArgs; -@inject IJSRuntime JS; -@inject Client realtime; -@inject Blazored.LocalStorage.ISyncLocalStorageService localStorage; +@inject IJSRuntime Js; +@inject Client Realtime; +@inject Blazored.LocalStorage.ISyncLocalStorageService LocalStorage; Realtime Example
-
- - - -
- - - - +
+ + + +
+ + + +
-
- -
- @if ((realtime?.Socket?.IsConnected ?? false) && (userChannel?.IsJoined ?? false)) - { - - - } +
+ +
+ @if ((Realtime?.Socket?.IsConnected ?? false) && (_userChannel?.IsJoined ?? false)) + { + + + }
-@foreach (var position in mousePositions) +@foreach (var position in _mousePositions) { - + }
- @foreach (var message in messages) - { - - } + @foreach (var message in _messages) + { + + }
- +
@code { - private bool isConnected = false; - private double latency = -1; - private int userCount = 0; - private bool isTyping = false; - - private RealtimeChannel? userChannel; - private RealtimeBroadcast? mousePositionBroadcast; - private RealtimePresence? presence; - - private RealtimeChannel? messageChannel; - private RealtimeBroadcast? messageBroadcast; - - private DotNetObjectReference? objRef; - - private InputText? chatInput; - private string? chatMessage; - - private static Random rnd = new Random(); - - private string? userColor; - private string userId - { - get - { - var id = localStorage.GetItem("userId"); - if (string.IsNullOrEmpty(id)) - { - id = Guid.NewGuid().ToString(); - localStorage.SetItem("userId", id); - } - - return id!; - } - } - - private List mousePositions = new List(); - private List messages = new List(); - private List colorOptions = new List { "slate", "stone", "red", "orange", "amber", "yellow", "green", "blue", "indigo", "purple", "fuchsia", "pink", "rose" }; - - override protected async Task OnInitializedAsync() - { - objRef = DotNetObjectReference.Create(this); - - await JS.InvokeAsync("registerMouseMoveListener", objRef); - await JS.InvokeAsync("registerKeydownListener", objRef); - - realtime.AddStateChangedListener((_, state) => isConnected = state != Constants.SocketState.Close); - - await realtime.ConnectAsync(); - - userColor = colorOptions[rnd.Next(colorOptions.Count)]; - - InitializeUserChannel(); - InitializeMessageChannel(); - InitializeLatencyTimer(); - InitializeCleanupMousePositionsTimer(); - } - - public void Dispose() - { - objRef?.Dispose(); - userChannel?.Unsubscribe(); - messageChannel?.Unsubscribe(); - } - - [JSInvokable] - public void HandleMouseMoved(double[] position) - { - mousePositionBroadcast?.Send(null, new MousePosition { UserId = userId, MouseX = position[0], MouseY = position[1], Color = userColor }); - } - - [JSInvokable] - public void HandleKeydown(KeydownEvent keydownEvent) - { - switch (keydownEvent.key) - { - case "Enter": - if (!isTyping) - chatInput?.Element!.Value.FocusAsync(); - else - SendMessage(); - break; - case "Escape": - chatMessage = ""; - StateHasChanged(); - break; - } - } - - private async void HandleConnectionButton(MouseEventArgs args) - { - if (userChannel == null) return; - - if (userChannel.IsJoined) - { - userChannel?.Unsubscribe(); - } - else - { - await userChannel.Subscribe(); - presence!.Track(new BasePresence()); - } - StateHasChanged(); - } - - /// - /// Inits the user presence channel for mouse events - /// - private async void InitializeUserChannel() - { - userChannel = realtime.Channel("presence"); - - mousePositionBroadcast = userChannel.Register(true, true); - mousePositionBroadcast.OnBroadcast += HandleMousePositionBroadcastReceived; - - presence = userChannel.Register(userId!); - presence.OnSync += HandlePresenceSync; - - await userChannel.Subscribe(); - - presence!.Track(new BasePresence()); - } - - /// - /// Inits the message channel for message broadcasts - /// - private async void InitializeMessageChannel() - { - messageChannel = realtime.Channel("messages"); - - messageBroadcast = messageChannel.Register(true, true); - messageBroadcast.OnBroadcast += HandleMessageBroadcastReceived; - - await messageChannel.Subscribe(); - } - - /// - /// Removes old mousePositions after a set timeout. - /// - private void InitializeCleanupMousePositionsTimer() - { - var timer = new System.Timers.Timer(); - - timer.Elapsed += (sender, elapsed) => - { - mousePositions - .FindAll(x => x.AddedAt < (DateTime.Now - TimeSpan.FromSeconds(15))) - .ForEach(x => mousePositions.Remove(x)); - StateHasChanged(); - }; - - timer.Interval = 15; - timer.Enabled = true; - } - - /// - /// Sets repeating timer to show latency on screen. - /// - private void InitializeLatencyTimer() - { - var timer = new System.Timers.Timer(); - - timer.Elapsed += async (sender, elapsed) => - { - latency = await realtime.Socket!.GetLatency(); - presence!.Track(new BasePresence()); - StateHasChanged(); - }; - - timer.Interval = 2000; - timer.Enabled = true; - } - - /// - /// Keeps track of current count of users. - /// - /// - /// - private void HandlePresenceSync(object? sender, EventArgs? args) - { - var state = presence!.CurrentState; - userCount = state.Keys.Count; - - Console.WriteLine($"Online: {JsonConvert.SerializeObject(state.Keys)}"); - StateHasChanged(); - } - - /// - /// Keeps track of the list of active users, updating positions if the user already exists. - /// - /// - /// - private void HandleMousePositionBroadcastReceived(object? sender, EventArgs? args) - { - var item = mousePositionBroadcast?.Current(); - - if (item == null) return; - - item.AddedAt = DateTime.Now; - - var index = mousePositions.FindIndex(x => x.UserId == item.UserId); - - if (index > -1) - mousePositions[index] = item; - else - mousePositions.Add(item); - - StateHasChanged(); - } - - /// - /// Keeps track of messages received. - /// - /// - /// - private void HandleMessageBroadcastReceived(object? sender, EventArgs? args) - { - var item = messageBroadcast?.Current(); - - if (item == null) return; - - messages.Add(item); - messages.Sort((x, y) => x.CreatedAt >= y.CreatedAt ? 1 : -1); - - StateHasChanged(); - } - - /// - /// Broadcast a message - /// - private void SendMessage() - { - if (string.IsNullOrEmpty(chatMessage)) return; - - messageBroadcast?.Send("message", new Message { UserId = userId, Color = userColor, Content = chatMessage, CreatedAt = DateTime.Now }); - chatMessage = ""; - StateHasChanged(); - } - - private void HandleChatInputFocus(FocusEventArgs args) - { - isTyping = true; - StateHasChanged(); - } - - private void HandleChatInputLostFocus(FocusEventArgs args) - { - isTyping = false; - StateHasChanged(); - } + private bool _isConnected; + private double _latency = -1; + private int _userCount; + private bool _isTyping; + + private RealtimeChannel? _userChannel; + private RealtimeBroadcast? _mousePositionBroadcast; + private RealtimePresence? _presence; + + private RealtimeChannel? _messageChannel; + private RealtimeBroadcast? _messageBroadcast; + + private DotNetObjectReference? _objRef; + + private InputText? _chatInput; + private string? _chatMessage; + + private static readonly Random Random = new Random(); + + private string? _userColor; + + private string UserId + { + get + { + var id = LocalStorage.GetItem("userId"); + if (string.IsNullOrEmpty(id)) + { + id = Guid.NewGuid().ToString(); + LocalStorage.SetItem("userId", id); + } + + return id; + } + } + + private readonly List _mousePositions = new List(); + private readonly List _messages = new List(); + private readonly List _colorOptions = new List { "slate", "stone", "red", "orange", "amber", "yellow", "green", "blue", "indigo", "purple", "fuchsia", "pink", "rose" }; + + protected override async Task OnInitializedAsync() + { + _objRef = DotNetObjectReference.Create(this); + + await Js.InvokeAsync("registerMouseMoveListener", _objRef); + await Js.InvokeAsync("registerKeydownListener", _objRef); + + Realtime.AddStateChangedListener((_, state) => _isConnected = state != Constants.SocketState.Close); + + await Realtime.ConnectAsync(); + + _userColor = _colorOptions[Random.Next(_colorOptions.Count)]; + + InitializeUserChannel(); + InitializeMessageChannel(); + InitializeLatencyTimer(); + InitializeCleanupMousePositionsTimer(); + } + + public void Dispose() + { + _objRef?.Dispose(); + _userChannel?.Unsubscribe(); + _messageChannel?.Unsubscribe(); + } + + [JSInvokable] + public void HandleMouseMoved(double[] position) + { + _mousePositionBroadcast?.Send(null, new MousePosition { UserId = UserId, MouseX = position[0], MouseY = position[1], Color = _userColor }); + } + + [JSInvokable] + public void HandleKeydown(KeydownEvent keydownEvent) + { + switch (keydownEvent.key) + { + case "Enter": + if (!_isTyping) + _chatInput?.Element!.Value.FocusAsync(); + else + SendMessage(); + break; + case "Escape": + _chatMessage = ""; + StateHasChanged(); + break; + } + } + + private async void HandleConnectionButton(MouseEventArgs args) + { + if (_userChannel == null) return; + + if (_userChannel.IsJoined) + { + _userChannel?.Unsubscribe(); + } + else + { + await _userChannel.Subscribe(); + _presence!.Track(new BasePresence()); + } + StateHasChanged(); + } + + /// + /// Inits the user presence channel for mouse events + /// + private async void InitializeUserChannel() + { + _userChannel = Realtime.Channel("presence"); + + _mousePositionBroadcast = _userChannel.Register(true, true); + _mousePositionBroadcast.AddBroadcastEventHandler(HandleMousePositionBroadcastReceived); + + _presence = _userChannel.Register(UserId); + _presence.AddPresenceEventHandler(IRealtimePresence.EventType.Sync, HandlePresenceSync); + + await _userChannel.Subscribe(); + + _presence!.Track(new BasePresence()); + } + + /// + /// Inits the message channel for message broadcasts + /// + private async void InitializeMessageChannel() + { + _messageChannel = Realtime.Channel("messages"); + + _messageBroadcast = _messageChannel.Register(true, true); + _messageBroadcast.AddBroadcastEventHandler(HandleMessageBroadcastReceived); + + await _messageChannel.Subscribe(); + } + + /// + /// Removes old mousePositions after a set timeout. + /// + private void InitializeCleanupMousePositionsTimer() + { + var timer = new System.Timers.Timer(); + + timer.Elapsed += (_, _) => + { + _mousePositions + .FindAll(x => x.AddedAt < (DateTime.Now - TimeSpan.FromSeconds(15))) + .ForEach(x => _mousePositions.Remove(x)); + StateHasChanged(); + }; + + timer.Interval = 15; + timer.Enabled = true; + } + + /// + /// Sets repeating timer to show latency on screen. + /// + private void InitializeLatencyTimer() + { + var timer = new System.Timers.Timer(); + + timer.Elapsed += async (_, _) => + { + _latency = await Realtime.Socket!.GetLatency(); + _presence!.Track(new BasePresence()); + StateHasChanged(); + }; + + timer.Interval = 2000; + timer.Enabled = true; + } + + /// + /// Keeps track of current count of users. + /// + /// + /// + private void HandlePresenceSync(IRealtimePresence sender, IRealtimePresence.EventType eventType) + { + var state = _presence!.CurrentState; + _userCount = state.Keys.Count; + + Console.WriteLine($"Online: {JsonConvert.SerializeObject(state.Keys)}"); + StateHasChanged(); + } + + /// + /// Keeps track of the list of active users, updating positions if the user already exists. + /// + /// + /// + private void HandleMousePositionBroadcastReceived(IRealtimeBroadcast? sender, BaseBroadcast? args) + { + var item = _mousePositionBroadcast?.Current(); + + if (item == null) return; + + item.AddedAt = DateTime.Now; + + var index = _mousePositions.FindIndex(x => x.UserId == item.UserId); + + if (index > -1) + _mousePositions[index] = item; + else + _mousePositions.Add(item); + + StateHasChanged(); + } + + /// + /// Keeps track of messages received. + /// + /// + /// + private void HandleMessageBroadcastReceived(IRealtimeBroadcast sender, BaseBroadcast? args) + { + var item = _messageBroadcast?.Current(); + + if (item == null) return; + + _messages.Add(item); + _messages.Sort((x, y) => x.CreatedAt >= y.CreatedAt ? 1 : -1); + + StateHasChanged(); + } + + /// + /// Broadcast a message + /// + private void SendMessage() + { + if (string.IsNullOrEmpty(_chatMessage)) return; + + _messageBroadcast?.Send("message", new Message { UserId = UserId, Color = _userColor, Content = _chatMessage, CreatedAt = DateTime.Now }); + _chatMessage = ""; + StateHasChanged(); + } + + private void HandleChatInputFocus(FocusEventArgs args) + { + _isTyping = true; + StateHasChanged(); + } + + private void HandleChatInputLostFocus(FocusEventArgs args) + { + _isTyping = false; + StateHasChanged(); + } + } \ No newline at end of file diff --git a/Examples/RealtimeExample/Program.cs b/Examples/RealtimeExample/Program.cs index 97f7dcb..9b0f9a9 100644 --- a/Examples/RealtimeExample/Program.cs +++ b/Examples/RealtimeExample/Program.cs @@ -25,11 +25,11 @@ static async Task Main(string[] args) // Subscribe to a channel and events var channelUsers = realtimeClient.Channel("realtime", "public", "users"); - channelUsers.AddPostgresChangeListener(ListenType.Inserts, + channelUsers.AddPostgresChangeHandler(ListenType.Inserts, (_, change) => { Console.WriteLine($"New item inserted: {change.Model()}"); }); - channelUsers.AddPostgresChangeListener(ListenType.Updates, + channelUsers.AddPostgresChangeHandler(ListenType.Updates, (_, change) => { Console.WriteLine($"Item Updated: {change.Model()}"); }); - channelUsers.AddPostgresChangeListener(ListenType.Deletes, + channelUsers.AddPostgresChangeHandler(ListenType.Deletes, (_, change) => { Console.WriteLine($"Item Deleted"); }); Console.WriteLine("Subscribing to users channel"); @@ -38,7 +38,7 @@ static async Task Main(string[] args) //Subscribing to another channel var channelTodos = realtimeClient.Channel("realtime", "public", "todos"); - channelTodos.AddStateChangedListener((_, state) => + channelTodos.AddStateChangedHandler((_, state) => { Console.WriteLine($"Channel todos {state}!!"); }); diff --git a/Realtime/Interfaces/IRealtimeBroadcast.cs b/Realtime/Interfaces/IRealtimeBroadcast.cs index ab76ca2..01456f8 100644 --- a/Realtime/Interfaces/IRealtimeBroadcast.cs +++ b/Realtime/Interfaces/IRealtimeBroadcast.cs @@ -1,15 +1,21 @@ using Supabase.Realtime.Socket; using System; using System.Threading.Tasks; +using Supabase.Realtime.Models; using static Supabase.Realtime.Constants; namespace Supabase.Realtime.Interfaces { public interface IRealtimeBroadcast { - event EventHandler? OnBroadcast; - Task Send(string? broadcastEventName, object payload, int timeoutMs = DefaultTimeout); + delegate void BroadcastEventHandler(IRealtimeBroadcast sender, BaseBroadcast? broadcast); - void TriggerReceived(SocketResponseEventArgs args); + void AddBroadcastEventHandler(BroadcastEventHandler broadcastEventHandler); + void RemoveBroadcastEventHandler(BroadcastEventHandler broadcastEventHandler); + void ClearBroadcastEventHandlers(); + + Task Send(string? broadcastEventName, object payload, int timeoutMs = DefaultTimeout); + + void TriggerReceived(SocketResponse response); } } \ No newline at end of file diff --git a/Realtime/Interfaces/IRealtimeChannel.cs b/Realtime/Interfaces/IRealtimeChannel.cs index 99739c4..0cdc862 100644 --- a/Realtime/Interfaces/IRealtimeChannel.cs +++ b/Realtime/Interfaces/IRealtimeChannel.cs @@ -4,7 +4,6 @@ using Supabase.Realtime.PostgresChanges; using Supabase.Realtime.Presence; using Supabase.Realtime.Socket; -using System; using System.Collections.Generic; using System.Threading.Tasks; using static Supabase.Realtime.Constants; @@ -33,23 +32,23 @@ public interface IRealtimeChannel ChannelState State { get; } string Topic { get; } - void AddStateChangedListener(StateChangedHandler stateChangedHandler); + void AddStateChangedHandler(StateChangedHandler stateChangedHandler); - void RemoveStateChangedListener(StateChangedHandler stateChangedHandler); + void RemoveStateChangedHandler(StateChangedHandler stateChangedHandler); - void ClearStateChangedListeners(); + void ClearStateChangedHandlers(); void AddMessageReceivedHandler(MessageReceivedHandler messageReceivedHandler); void RemoveMessageReceivedHandler(MessageReceivedHandler messageReceivedHandler); - void ClearMessageReceivedListeners(); + void ClearMessageReceivedHandlers(); - void AddPostgresChangeListener(ListenType listenType, PostgresChangesHandler postgresChangeHandler); + void AddPostgresChangeHandler(ListenType listenType, PostgresChangesHandler postgresChangeHandler); - void RemovePostgresChangeListener(ListenType listenType, PostgresChangesHandler postgresChangeHandler); + void RemovePostgresChangeHandler(ListenType listenType, PostgresChangesHandler postgresChangeHandler); - void ClearPostgresChangeListeners(); + void ClearPostgresChangeHandlers(); IRealtimeBroadcast? Broadcast(); IRealtimePresence? Presence(); diff --git a/Realtime/Interfaces/IRealtimePresence.cs b/Realtime/Interfaces/IRealtimePresence.cs index fb7f23d..cd8f002 100644 --- a/Realtime/Interfaces/IRealtimePresence.cs +++ b/Realtime/Interfaces/IRealtimePresence.cs @@ -1,18 +1,30 @@ using Supabase.Realtime.Socket; using System; +using Supabase.Realtime.Models; using static Supabase.Realtime.Constants; namespace Supabase.Realtime.Interfaces { - public interface IRealtimePresence - { - event EventHandler? OnJoin; - event EventHandler? OnLeave; - event EventHandler? OnSync; + public interface IRealtimePresence + { + delegate void PresenceEventHandler(IRealtimePresence sender, EventType eventType); - void Track(object? payload, int timeoutMs = DefaultTimeout); + public enum EventType + { + Sync, + Join, + Leave + } - void TriggerSync(SocketResponseEventArgs args); - void TriggerDiff(SocketResponseEventArgs args); - } -} + void Track(object? payload, int timeoutMs = DefaultTimeout); + + void TriggerSync(SocketResponse response); + void TriggerDiff(SocketResponse args); + + void AddPresenceEventHandler(EventType eventType, PresenceEventHandler presenceEventHandler); + + void RemovePresenceEventHandlers(EventType eventType, PresenceEventHandler presenceEventHandler); + + void ClearPresenceEventHandlers(EventType? eventType = null); + } +} \ No newline at end of file diff --git a/Realtime/RealtimeBroadcast.cs b/Realtime/RealtimeBroadcast.cs index 01a291c..c68f238 100644 --- a/Realtime/RealtimeBroadcast.cs +++ b/Realtime/RealtimeBroadcast.cs @@ -4,78 +4,112 @@ using Supabase.Realtime.Models; using Supabase.Realtime.Socket; using System; +using System.Collections.Generic; using System.Threading.Tasks; using static Supabase.Realtime.Constants; namespace Supabase.Realtime { + /// + /// Represents a realtime broadcast client. + /// + /// Broadcast follows the publish-subscribe pattern where a client publishes messages to a channel with a unique identifier. + /// Other clients can elect to receive the message in real-time by subscribing to the channel with the same unique identifier. If these clients are online and subscribed then they will receive the message. + /// + /// Broadcast works by connecting your client to the nearest Realtime server, which will communicate with other servers to relay messages to other clients. + /// A common use-case is sharing a user's cursor position with other clients in an online game. + /// + /// A model representing expected payload. + public class RealtimeBroadcast : IRealtimeBroadcast where TBroadcastModel : BaseBroadcast + { + private readonly RealtimeChannel _channel; + private readonly JsonSerializerSettings _serializerSettings; - /// - /// Represents a realtime broadcast client. - /// - /// Broadcast follows the publish-subscribe pattern where a client publishes messages to a channel with a unique identifier. - /// Other clients can elect to receive the message in real-time by subscribing to the channel with the same unique identifier. If these clients are online and subscribed then they will receive the message. - /// - /// Broadcast works by connecting your client to the nearest Realtime server, which will communicate with other servers to relay messages to other clients. - /// A common use-case is sharing a user's cursor position with other clients in an online game. - /// - /// A model representing expected payload. - public class RealtimeBroadcast : IRealtimeBroadcast where TBroadcastModel : BaseBroadcast - { - public event EventHandler? OnBroadcast; - - private readonly RealtimeChannel channel; - private readonly JsonSerializerSettings serializerSettings; - - private SocketResponse? lastSocketResponse; - - /// - /// The last received broadcast. - /// - public TBroadcastModel? Current() - { - if (lastSocketResponse == null) return null; - - var obj = JsonConvert.DeserializeObject>(lastSocketResponse.Json!, serializerSettings); - - if (obj == null || obj.Payload == null) return null; - - return obj.Payload; - } - - public RealtimeBroadcast(RealtimeChannel channel, BroadcastOptions options, JsonSerializerSettings serializerSettings) - { - this.channel = channel; - this.serializerSettings = serializerSettings; - } - - /// - /// Called by when a broadcast event is received, then parsed/typed here. - /// - /// - /// - public void TriggerReceived(SocketResponseEventArgs args) - { - if (args.Response == null || args.Response.Json == null) - throw new ArgumentException( - $"Expected parsable JSON response, instead received: `{JsonConvert.SerializeObject(args.Response)}`"); - - lastSocketResponse = args.Response; - OnBroadcast?.Invoke(this, null); - } - - /// - /// Broadcasts an arbitrary payload - /// - /// - /// - /// - public Task Send(string? broadcastEventName, object payload, int timeoutMs = 10000) - { - if (payload is BaseBroadcast baseBroadcast && string.IsNullOrEmpty(baseBroadcast.Event)) - baseBroadcast.Event = broadcastEventName; - - return channel.Send(ChannelEventName.Broadcast, broadcastEventName, payload, timeoutMs); - } - } -} + private SocketResponse? _lastSocketResponse; + + private readonly List _broadcastEventHandlers = new(); + + /// + /// The last received broadcast. + /// + public TBroadcastModel? Current() + { + if (_lastSocketResponse == null) return null; + + var obj = JsonConvert.DeserializeObject>(_lastSocketResponse.Json!, + _serializerSettings); + + if (obj == null || obj.Payload == null) return null; + + return obj.Payload; + } + + public RealtimeBroadcast(RealtimeChannel channel, BroadcastOptions options, + JsonSerializerSettings serializerSettings) + { + _channel = channel; + _serializerSettings = serializerSettings; + } + + /// + /// Adds a broadcast event listener. + /// + /// + public void AddBroadcastEventHandler(IRealtimeBroadcast.BroadcastEventHandler broadcastEventHandler) + { + if (!_broadcastEventHandlers.Contains(broadcastEventHandler)) + _broadcastEventHandlers.Add(broadcastEventHandler); + } + + /// + /// Removes a broadcast event listener. + /// + /// + public void RemoveBroadcastEventHandler(IRealtimeBroadcast.BroadcastEventHandler broadcastEventHandler) + { + if (_broadcastEventHandlers.Contains(broadcastEventHandler)) + _broadcastEventHandlers.Remove(broadcastEventHandler); + } + + /// + /// Clears all broadcast event listeners + /// + public void ClearBroadcastEventHandlers() => + _broadcastEventHandlers.Clear(); + + private void NotifyBroadcastEventHandlers() + { + foreach (var handler in _broadcastEventHandlers) + handler.Invoke(this, Current()); + } + + /// + /// Called by when a broadcast event is received, then parsed/typed here. + /// + /// + /// + public void TriggerReceived(SocketResponse response) + { + if (response == null || response.Json == null) + throw new ArgumentException( + $"Expected parsable JSON response, instead received: `{JsonConvert.SerializeObject(response)}`"); + + _lastSocketResponse = response; + NotifyBroadcastEventHandlers(); + } + + /// + /// Broadcasts an arbitrary payload + /// + /// + /// + /// + public Task Send(string? broadcastEventName, object payload, int timeoutMs = 10000) + { + if (payload is BaseBroadcast baseBroadcast && string.IsNullOrEmpty(baseBroadcast.Event)) + baseBroadcast.Event = broadcastEventName; + + return _channel.Send(ChannelEventName.Broadcast, broadcastEventName, payload, timeoutMs); + } + } +} \ No newline at end of file diff --git a/Realtime/RealtimeChannel.cs b/Realtime/RealtimeChannel.cs index c160676..a9ee290 100644 --- a/Realtime/RealtimeChannel.cs +++ b/Realtime/RealtimeChannel.cs @@ -34,12 +34,6 @@ public class RealtimeChannel : IRealtimeChannel public bool IsJoining => State == ChannelState.Joining; public bool IsLeaving => State == ChannelState.Leaving; - private readonly List _stateChangedHandlers = new(); - private readonly List _messageReceivedHandlers = new(); - - private readonly Dictionary> - _postgresChangesHandlers = new(); - /// /// The channel's topic (identifier) /// @@ -116,9 +110,15 @@ public class RealtimeChannel : IRealtimeChannel internal Push? LastPush; // Event handlers that pass events to typed instances for broadcast and presence. - internal event EventHandler? OnBroadcast; - internal event EventHandler? OnPresenceDiff; - internal event EventHandler? OnPresenceSync; + internal delegate void BroadcastEventHandler(IRealtimeChannel sender, SocketResponse response); + + internal delegate void PresenceDiffHandler(IRealtimeChannel sender, SocketResponse response); + + internal delegate void PresenceSyncHandler(IRealtimeChannel sender, SocketResponse response); + + internal BroadcastEventHandler? BroadcastHandler; + internal PresenceDiffHandler? PresenceDiff; + internal PresenceSyncHandler? PresenceSync; /// /// Buffer of Pushes held because of Socket availability @@ -128,6 +128,13 @@ public class RealtimeChannel : IRealtimeChannel private readonly IRealtimeSocket _socket; private IRealtimePresence? _presence; private IRealtimeBroadcast? _broadcast; + + private readonly List _stateChangedHandlers = new(); + private readonly List _messageReceivedHandlers = new(); + + private readonly Dictionary> _postgresChangesHandlers = + new(); + private bool CanPush => IsJoined && _socket.IsConnected; private bool _hasJoinedOnce; private readonly Timer _rejoinTimer; @@ -185,7 +192,7 @@ public RealtimeBroadcast Register(bool b new RealtimeBroadcast(this, BroadcastOptions, Options.SerializerSettings); _broadcast = instance; - OnBroadcast += (_, args) => _broadcast.TriggerReceived(args); + BroadcastHandler = (_, response) => _broadcast.TriggerReceived(response); return instance; } @@ -208,27 +215,43 @@ public RealtimePresence Register(string pr var instance = new RealtimePresence(this, PresenceOptions, Options.SerializerSettings); _presence = instance; - OnPresenceSync += (_, args) => _presence.TriggerSync(args); - OnPresenceDiff += (_, args) => _presence.TriggerDiff(args); + PresenceSync = (_, response) => _presence.TriggerSync(response); + PresenceDiff = (_, response) => _presence.TriggerDiff(response); return instance; } - public void AddStateChangedListener(IRealtimeChannel.StateChangedHandler stateChangedHandler) + /// + /// Registers a state changed listener relative to this channel. Called when channel state changes. + /// + /// + public void AddStateChangedHandler(IRealtimeChannel.StateChangedHandler stateChangedHandler) { if (!_stateChangedHandlers.Contains(stateChangedHandler)) _stateChangedHandlers.Add(stateChangedHandler); } - public void RemoveStateChangedListener(IRealtimeChannel.StateChangedHandler stateChangedHandler) + /// + /// Removes a channel state changed listener + /// + /// + public void RemoveStateChangedHandler(IRealtimeChannel.StateChangedHandler stateChangedHandler) { if (_stateChangedHandlers.Contains(stateChangedHandler)) _stateChangedHandlers.Remove(stateChangedHandler); } - public void ClearStateChangedListeners() => + /// + /// Clears all channel state changed listeners + /// + public void ClearStateChangedHandlers() => _stateChangedHandlers.Clear(); + /// + /// Notifies registered listeners that a channel state has changed. + /// + /// + /// private void NotifyStateChanged(ChannelState state, bool shouldRejoin = true) { State = state; @@ -243,38 +266,62 @@ private void NotifyStateChanged(ChannelState state, bool shouldRejoin = true) handler.Invoke(this, state); } + /// + /// Registers a message received listener, called when a socket message is received for this channel. + /// + /// public void AddMessageReceivedHandler(IRealtimeChannel.MessageReceivedHandler messageReceivedHandler) { if (!_messageReceivedHandlers.Contains(messageReceivedHandler)) _messageReceivedHandlers.Add(messageReceivedHandler); } + /// + /// Removes a message received listener. + /// + /// public void RemoveMessageReceivedHandler(IRealtimeChannel.MessageReceivedHandler messageReceivedHandler) { if (_messageReceivedHandlers.Contains(messageReceivedHandler)) _messageReceivedHandlers.Remove(messageReceivedHandler); } - public void ClearMessageReceivedListeners() => + /// + /// Clears message received listeners. + /// + public void ClearMessageReceivedHandlers() => _messageReceivedHandlers.Clear(); + /// + /// Notifies registered listeners that a channel message has been received. + /// + /// private void NotifyMessageReceived(SocketResponse message) { foreach (var handler in _messageReceivedHandlers) handler.Invoke(this, message); } - public void AddPostgresChangeListener(PostgresChangesOptions.ListenType listenType, + /// + /// Add a postgres changes listener. Should be paired with . + /// + /// The type of event this callback should process. + /// + public void AddPostgresChangeHandler(ListenType listenType, IRealtimeChannel.PostgresChangesHandler postgresChangeHandler) { - if (_postgresChangesHandlers[listenType] == null) - _postgresChangesHandlers[listenType] = new List(); + _postgresChangesHandlers[listenType] ??= new List(); if (!_postgresChangesHandlers[listenType].Contains(postgresChangeHandler)) _postgresChangesHandlers[listenType].Add(postgresChangeHandler); } - public void RemovePostgresChangeListener(PostgresChangesOptions.ListenType listenType, + /// + /// Removes a postgres changes listener. + /// + /// The type of event this callback was registered to process. + /// + public void RemovePostgresChangeHandler(ListenType listenType, IRealtimeChannel.PostgresChangesHandler postgresChangeHandler) { if (_postgresChangesHandlers.ContainsKey(listenType) && @@ -282,26 +329,27 @@ public void RemovePostgresChangeListener(PostgresChangesOptions.ListenType liste _postgresChangesHandlers[listenType].Remove(postgresChangeHandler); } - public void ClearPostgresChangeListeners() => + /// + /// Clears all postgres changes listeners. + /// + public void ClearPostgresChangeHandlers() => _postgresChangesHandlers.Clear(); + /// + /// Notifies listeners of a postgres change message being received. + /// + /// + /// private void NotifyPostgresChanges(EventType eventType, PostgresChangesResponse response) { - var listenType = ListenType.All; - - switch (eventType) + var listenType = eventType switch { - case EventType.Insert: - listenType = ListenType.Inserts; - break; - case EventType.Delete: - listenType = ListenType.Deletes; - break; - case EventType.Update: - listenType = ListenType.Updates; - break; - } - + EventType.Insert => ListenType.Inserts, + EventType.Delete => ListenType.Deletes, + EventType.Update => ListenType.Updates, + _ => ListenType.All + }; + // Invoke the wildcard listener (but only once) if (listenType != ListenType.All) foreach (var handler in _postgresChangesHandlers[ListenType.All]) @@ -311,7 +359,6 @@ private void NotifyPostgresChanges(EventType eventType, PostgresChangesResponse handler.Invoke(this, response); } - /// /// Registers postgres_changes options, can be called multiple times. /// @@ -347,7 +394,7 @@ public Task Subscribe(int timeoutMs = DefaultTimeout) HasJoinedOnce = true; IsSubscribed = true; - sender.RemoveStateChangedListener(channelCallback!); + sender.RemoveStateChangedHandler(channelCallback!); JoinPush.OnTimeout -= joinPushTimeoutCallback; // Clear buffer @@ -361,7 +408,7 @@ public Task Subscribe(int timeoutMs = DefaultTimeout) case ChannelState.Closed: case ChannelState.Errored: IsSubscribed = false; - sender.RemoveStateChangedListener(channelCallback!); + sender.RemoveStateChangedHandler(channelCallback!); JoinPush.OnTimeout -= joinPushTimeoutCallback; tsc.TrySetException(new Exception("Error occurred connecting to channel. Check logs.")); @@ -372,7 +419,7 @@ public Task Subscribe(int timeoutMs = DefaultTimeout) // Throw an exception if there is a problem receiving a join response joinPushTimeoutCallback = (_, _) => { - RemoveStateChangedListener(channelCallback); + RemoveStateChangedHandler(channelCallback); JoinPush.OnTimeout -= joinPushTimeoutCallback; tsc.TrySetException(new RealtimeException("Push Timeout") @@ -381,7 +428,7 @@ public Task Subscribe(int timeoutMs = DefaultTimeout) }); }; - AddStateChangedListener(channelCallback); + AddStateChangedHandler(channelCallback); // Set a flag to prevent multiple join attempts. _hasJoinedOnce = true; @@ -608,20 +655,18 @@ internal void HandleSocketMessage(SocketResponse message) deserialized.Json = message.Json; deserialized.serializerSettings = Options.SerializerSettings; - var newArgs = new PostgresChangesEventArgs(deserialized); - // Invoke '*' listener NotifyPostgresChanges(deserialized.Payload!.Data!.Type, deserialized); break; case EventType.Broadcast: - //OnBroadcast?.Invoke(this, message); + BroadcastHandler?.Invoke(this, message); break; case EventType.PresenceState: - //OnPresenceSync?.Invoke(this, message); + PresenceSync?.Invoke(this, message); break; case EventType.PresenceDiff: - //OnPresenceDiff?.Invoke(this, message); + PresenceDiff?.Invoke(this, message); break; } } diff --git a/Realtime/RealtimePresence.cs b/Realtime/RealtimePresence.cs index 57f8a40..dc4003d 100644 --- a/Realtime/RealtimePresence.cs +++ b/Realtime/RealtimePresence.cs @@ -10,144 +10,189 @@ namespace Supabase.Realtime { - /// - /// Represents a realtime presence client. - /// - /// When a client subscribes to a channel, it will immediately receive the channel's latest state in a single message. - /// Clients are free to come-and-go as they please, and as long as they are all subscribed to the same channel then they will all have the same Presence state as each other. - /// If a client is suddenly disconnected (for example, they go offline), their state will be automatically removed from the shared state. - /// - /// A model representing expected payload. - public class RealtimePresence : IRealtimePresence where TPresenceModel : BasePresence - { - /// - /// The Last State of this Presence instance. - /// - public Dictionary> LastState { get; private set; } = new Dictionary>(); - - /// - /// The Current State of this Presence instance. - /// - public Dictionary> CurrentState { get; private set; } = new Dictionary>(); - - /// - /// Called when Presence Joins (incoming changes) are present in a websocket response. - /// - public event EventHandler? OnJoin; - - /// - /// Called when Presence Leaves (previous state) are present in a websocket response. - /// - public event EventHandler? OnLeave; - - /// - /// Called on every recieved Presence message after setting and - /// - public event EventHandler? OnSync; - - private RealtimeChannel channel; - private PresenceOptions options; - private JsonSerializerSettings serializerSettings; - - private SocketResponseEventArgs? currentResponse; - - public RealtimePresence(RealtimeChannel channel, PresenceOptions options, JsonSerializerSettings serializerSettings) - { - this.channel = channel; - this.options = options; - this.serializerSettings = serializerSettings; - } - - /// - /// Called in two cases: - /// - By `RealtimeChannel` when it receives a `presence_state` initializing message. - /// - By `RealtimeChannel` When a diff has been received and a new response is saved. - /// - /// - public void TriggerSync(SocketResponseEventArgs args) - { - var lastState = new Dictionary>(LastState); - - currentResponse = args; - SetState(); - - OnSync?.Invoke(this, null); - } - - /// - /// Triggers a diff comparison and emits events accordingly. - /// - /// - /// - public void TriggerDiff(SocketResponseEventArgs args) - { - if (args.Response == null || args.Response.Json == null) - throw new ArgumentException(string.Format("Expected parsable JSON response, instead recieved: `{0}`", JsonConvert.SerializeObject(args.Response))); - - var obj = JsonConvert.DeserializeObject>(args.Response.Json, serializerSettings); - - if (obj == null || obj.Payload == null) return; - - TriggerSync(args); - - if (obj.Payload.Joins!.Count > 0) - OnJoin?.Invoke(this, null); - - if (obj.Payload.Leaves!.Count > 0) - OnLeave?.Invoke(this, null); - } - - /// - /// "Tracks" an event, used with . - /// - /// - /// - public void Track(object? payload, int timeoutMs = DefaultTimeout) - { - var eventName = Core.Helpers.GetMappedToAttr(ChannelEventName.Presence).Mapping; - channel.Push(eventName, "track", new Dictionary { { "event", "track" }, { "payload", payload } }, timeoutMs); - } - - public void Untrack() - { - var eventName = Core.Helpers.GetMappedToAttr(ChannelEventName.Presence).Mapping; - channel.Push(eventName, "untrack"); - } - - /// - /// Sets the internal Presence State from the - /// - private void SetState() - { - LastState = new Dictionary>(CurrentState); - - if (currentResponse == null || currentResponse.Response.Json == null) return; - - // Is a diff response? - if (currentResponse.Response.Payload!.Joins != null || currentResponse.Response.Payload!.Leaves != null) - { - var state = JsonConvert.DeserializeObject>(currentResponse.Response.Json, serializerSettings)!; - - if (state == null || state.Payload == null) return; - - // Remove any result that has "left" - foreach (var item in state.Payload.Leaves!) - CurrentState.Remove(item.Key); - - // Add any results that have come in. - foreach (var item in state.Payload.Joins!) - CurrentState[item.Key] = item.Value.Metas!; - } - else - { - // It's a presence_state init response - var state = JsonConvert.DeserializeObject>(currentResponse.Response.Json, serializerSettings)!; - - if (state == null || state.Payload == null) return; - - foreach (var item in state.Payload) - CurrentState[item.Key] = item.Value.Metas!; - } - } - } -} + /// + /// Represents a realtime presence client. + /// + /// When a client subscribes to a channel, it will immediately receive the channel's latest state in a single message. + /// Clients are free to come-and-go as they please, and as long as they are all subscribed to the same channel then they will all have the same Presence state as each other. + /// If a client is suddenly disconnected (for example, they go offline), their state will be automatically removed from the shared state. + /// + /// A model representing expected payload. + public class RealtimePresence : IRealtimePresence where TPresenceModel : BasePresence + { + /// + /// The Last State of this Presence instance. + /// + public Dictionary> LastState { get; private set; } = + new(); + + /// + /// The Current State of this Presence instance. + /// + public Dictionary> CurrentState { get; } = new(); + + private PresenceOptions _options; + private SocketResponse? _currentResponse; + private readonly RealtimeChannel _channel; + private readonly JsonSerializerSettings _serializerSettings; + + private readonly Dictionary> + _presenceEventListeners = new(); + + public RealtimePresence(RealtimeChannel channel, PresenceOptions options, + JsonSerializerSettings serializerSettings) + { + _channel = channel; + _options = options; + _serializerSettings = serializerSettings; + } + + /// + /// Add presence event handler for a given event type. + /// + /// + /// + public void AddPresenceEventHandler(IRealtimePresence.EventType eventType, + IRealtimePresence.PresenceEventHandler presenceEventHandler) + { + _presenceEventListeners[eventType] ??= new List(); + + if (!_presenceEventListeners[eventType].Contains(presenceEventHandler)) + _presenceEventListeners[eventType].Add(presenceEventHandler); + } + + /// + /// Remove an event handler + /// + /// + /// + public void RemovePresenceEventHandlers(IRealtimePresence.EventType eventType, + IRealtimePresence.PresenceEventHandler presenceEventHandler) + { + if (_presenceEventListeners.ContainsKey(eventType) && + _presenceEventListeners[eventType].Contains(presenceEventHandler)) + _presenceEventListeners[eventType].Remove(presenceEventHandler); + } + + /// + /// Clears all event handlers for a given type (if specified) or clears all handlers. + /// + /// + public void ClearPresenceEventHandlers(IRealtimePresence.EventType? eventType = null) + { + if (eventType != null && _presenceEventListeners.TryGetValue(eventType.Value, out var list)) + list.Clear(); + else + _presenceEventListeners.Clear(); + } + + /// + /// Notifies listeners of state changes + /// + /// + private void NotifyPresenceEventHandlers(IRealtimePresence.EventType eventType) + { + foreach (var handler in _presenceEventListeners[eventType]) + handler.Invoke(this, eventType); + } + + /// + /// Called in two cases: + /// - By `RealtimeChannel` when it receives a `presence_state` initializing message. + /// - By `RealtimeChannel` When a diff has been received and a new response is saved. + /// + /// + public void TriggerSync(SocketResponse response) + { + _currentResponse = response; + SetState(); + + NotifyPresenceEventHandlers(IRealtimePresence.EventType.Sync); + } + + /// + /// Triggers a diff comparison and emits events accordingly. + /// + /// + /// + public void TriggerDiff(SocketResponse response) + { + if (response == null || response.Json == null) + throw new ArgumentException( + $"Expected parsable JSON response, instead received: `{JsonConvert.SerializeObject(response)}`"); + + var obj = JsonConvert.DeserializeObject>(response.Json, + _serializerSettings); + + if (obj?.Payload == null) return; + + TriggerSync(response); + + if (obj.Payload.Joins!.Count > 0) + NotifyPresenceEventHandlers(IRealtimePresence.EventType.Join); + + if (obj.Payload.Leaves!.Count > 0) + NotifyPresenceEventHandlers(IRealtimePresence.EventType.Leave); + } + + /// + /// "Tracks" an event, used with . + /// + /// + /// + public void Track(object? payload, int timeoutMs = DefaultTimeout) + { + var eventName = Core.Helpers.GetMappedToAttr(ChannelEventName.Presence).Mapping; + _channel.Push(eventName, "track", + new Dictionary { { "event", "track" }, { "payload", payload } }, timeoutMs); + } + + /// + /// Untracks an event. + /// + public void Untrack() + { + var eventName = Core.Helpers.GetMappedToAttr(ChannelEventName.Presence).Mapping; + _channel.Push(eventName, "untrack"); + } + + /// + /// Sets the internal Presence State from the + /// + private void SetState() + { + LastState = new Dictionary>(CurrentState); + + if (_currentResponse?.Json == null) return; + + // Is a diff response? + if (_currentResponse.Payload!.Joins != null || _currentResponse.Payload!.Leaves != null) + { + var state = JsonConvert.DeserializeObject>(_currentResponse.Json, + _serializerSettings)!; + + if (state?.Payload == null) return; + + // Remove any result that has "left" + foreach (var item in state.Payload.Leaves!) + CurrentState.Remove(item.Key); + + // Add any results that have come in. + foreach (var item in state.Payload.Joins!) + CurrentState[item.Key] = item.Value.Metas!; + } + else + { + // It's a presence_state init response + var state = + JsonConvert.DeserializeObject>(_currentResponse.Json, + _serializerSettings)!; + + if (state?.Payload == null) return; + + foreach (var item in state.Payload) + CurrentState[item.Key] = item.Value.Metas!; + } + } + } +} \ No newline at end of file diff --git a/RealtimeTests/ChannelTests.cs b/RealtimeTests/ChannelTests.cs index bf2bf72..481fcbb 100644 --- a/RealtimeTests/ChannelTests.cs +++ b/RealtimeTests/ChannelTests.cs @@ -58,27 +58,23 @@ public async Task ClientCanCreatePresence() var channel1 = _socketClient!.Channel("online-users"); var presence1 = channel1.Register(guid1); - presence1.OnSync += (_, _) => + presence1.AddPresenceEventHandler(IRealtimePresence.EventType.Sync, (_, _) => { var state = presence1.CurrentState; if (state.ContainsKey(guid2) && state[guid2].First().Time != null) - { tsc.SetResult(true); - } - }; + }); var client2 = Helpers.SocketClient(); await client2.ConnectAsync(); var channel2 = client2.Channel("online-users"); var presence2 = channel2.Register(guid2); - presence2.OnSync += (_, _) => + presence2.AddPresenceEventHandler(IRealtimePresence.EventType.Sync, (_, _) => { var state = presence2.CurrentState; if (state.ContainsKey(guid1) && state[guid1].First().Time != null) - { tsc2.SetResult(true); - } - }; + }); await channel1.Subscribe(); await channel2.Subscribe(); @@ -100,27 +96,23 @@ public async Task ClientCanListenForBroadcast() var channel1 = _socketClient!.Channel("online-users"); var broadcast1 = channel1.Register(true, true); - broadcast1.OnBroadcast += (_, _) => + broadcast1.AddBroadcastEventHandler((_, _) => { var broadcast = broadcast1.Current(); if (broadcast?.UserId != guid1 && broadcast?.Event == "user") - { tsc.TrySetResult(true); - } - }; + }); var client2 = Helpers.SocketClient(); await client2.ConnectAsync(); var channel2 = client2.Channel("online-users"); var broadcast2 = channel2.Register(true, true); - broadcast2.OnBroadcast += (_, _) => + broadcast2.AddBroadcastEventHandler((_, _) => { var broadcast = broadcast2.Current(); if (broadcast?.UserId != guid2 && broadcast?.Event == "user") - { tsc2.TrySetResult(true); - } - }; + }); await channel1.Subscribe(); await channel2.Subscribe(); @@ -138,7 +130,7 @@ public async Task ChannelPayloadReturnsModel() var channel = _socketClient!.Channel("realtime", "public", "*"); - channel.AddPostgresChangeListener(PostgresChangesOptions.ListenType.Inserts, (_, changes) => + channel.AddPostgresChangeHandler(PostgresChangesOptions.ListenType.Inserts, (_, changes) => { var model = changes.Model(); tsc.SetResult(model != null); @@ -158,7 +150,7 @@ public async Task ChannelCloseEventHandler() var tsc = new TaskCompletionSource(); var channel = _socketClient!.Channel("realtime", "public", "todos"); - channel.AddStateChangedListener((_, state) => + channel.AddStateChangedHandler((_, state) => { if (state == ChannelState.Closed) tsc.SetResult(true); @@ -179,7 +171,7 @@ public async Task ChannelReceivesInsertCallback() var channel = _socketClient!.Channel("realtime", "public", "todos"); - channel.AddPostgresChangeListener(PostgresChangesOptions.ListenType.Inserts, + channel.AddPostgresChangeHandler(PostgresChangesOptions.ListenType.Inserts, (_, _) => tsc.SetResult(true)); await channel.Subscribe(); @@ -203,7 +195,7 @@ public async Task ChannelReceivesUpdateCallback() var channel = _socketClient!.Channel("realtime", "public", "todos"); - channel.AddPostgresChangeListener(PostgresChangesOptions.ListenType.Updates, (_, changes) => + channel.AddPostgresChangeHandler(PostgresChangesOptions.ListenType.Updates, (_, changes) => { var oldModel = changes.OldModel(); @@ -239,7 +231,7 @@ public async Task ChannelReceivesDeleteCallback() var channel = _socketClient!.Channel("realtime", "public", "todos"); - channel.AddPostgresChangeListener(PostgresChangesOptions.ListenType.Deletes, + channel.AddPostgresChangeHandler(PostgresChangesOptions.ListenType.Deletes, (_, _) => tsc.SetResult(true)); await channel.Subscribe(); @@ -264,7 +256,7 @@ public async Task ChannelSupportsWalrusArray() await channel.Subscribe(); - channel.AddPostgresChangeListener(PostgresChangesOptions.ListenType.Inserts, (_, changes) => + channel.AddPostgresChangeHandler(PostgresChangesOptions.ListenType.Inserts, (_, changes) => { result = changes.Model(); tsc.SetResult(true); @@ -318,7 +310,7 @@ public async Task ChannelReceivesWildcardCallback() var channel = _socketClient!.Channel("realtime", "public", "todos"); - channel.AddPostgresChangeListener(PostgresChangesOptions.ListenType.All, (_, changes) => + channel.AddPostgresChangeHandler(PostgresChangesOptions.ListenType.All, (_, changes) => { switch (changes.Payload?.Data?.Type) { From 37aab6154fa40574668d8681bbdb40c8429aabfa Mon Sep 17 00:00:00 2001 From: Joseph Schultz Date: Sun, 14 May 2023 22:01:41 -0500 Subject: [PATCH 6/7] Update tests to connect to local socket server instead of hosted --- Realtime/RealtimeChannel.cs | 3 +- Realtime/RealtimeSocket.cs | 36 ++---- RealtimeTests/ChannelTests.cs | 9 +- RealtimeTests/ClientTests.cs | 4 - RealtimeTests/Helpers.cs | 54 +++----- RealtimeTests/Models/Todo.cs | 17 +-- RealtimeTests/RealtimeTests.csproj | 2 +- RealtimeTests/db/00-schema.sql | 39 ++---- RealtimeTests/db/01-dummy-schema.sql | 184 +++++++++++++++++++++++++++ RealtimeTests/db/02-dummy-data.sql | 48 +++++++ docker-compose.yml | 53 ++++++++ realtime-csharp.sln | 1 + 12 files changed, 337 insertions(+), 113 deletions(-) create mode 100644 RealtimeTests/db/01-dummy-schema.sql create mode 100644 RealtimeTests/db/02-dummy-data.sql create mode 100644 docker-compose.yml diff --git a/Realtime/RealtimeChannel.cs b/Realtime/RealtimeChannel.cs index a9ee290..ed310cf 100644 --- a/Realtime/RealtimeChannel.cs +++ b/Realtime/RealtimeChannel.cs @@ -310,7 +310,8 @@ private void NotifyMessageReceived(SocketResponse message) public void AddPostgresChangeHandler(ListenType listenType, IRealtimeChannel.PostgresChangesHandler postgresChangeHandler) { - _postgresChangesHandlers[listenType] ??= new List(); + if (!_postgresChangesHandlers.ContainsKey(listenType)) + _postgresChangesHandlers[listenType] = new List(); if (!_postgresChangesHandlers[listenType].Contains(postgresChangeHandler)) _postgresChangesHandlers[listenType].Add(postgresChangeHandler); diff --git a/Realtime/RealtimeSocket.cs b/Realtime/RealtimeSocket.cs index 4e5b958..8d428c2 100644 --- a/Realtime/RealtimeSocket.cs +++ b/Realtime/RealtimeSocket.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; @@ -82,24 +83,6 @@ public RealtimeSocket(string endpoint, ClientOptions options) options.Headers.Add("X-Client-Info", Core.Util.GetAssemblyVersion(typeof(Client))); _connection = new WebsocketClient(new Uri(EndpointUrl)); - - AddStateChangedListener(HandleSocketStateChanged); - } - - private void HandleSocketStateChanged(IRealtimeSocket sender, SocketState state) - { - switch (state) - { - case SocketState.Open: - HandleSocketOpened(); - break; - case SocketState.Close: - HandleSocketClosed(); - break; - case SocketState.Error: - HandleSocketError(); - break; - } } void IDisposable.Dispose() => @@ -121,7 +104,7 @@ public async Task Connect() if (reconnectionInfo.Type != ReconnectionType.Initial) _isReconnecting = true; - NotifySocketStateChange(SocketState.Open); + HandleSocketOpened(); }); _connection.DisconnectionHappened.Subscribe(disconnectionInfo => @@ -173,7 +156,9 @@ public void RemoveStateChangedListener(IRealtimeSocket.StateEventHandler stateEv /// private void NotifySocketStateChange(SocketState newState) { - foreach (var handler in _socketEventHandlers) + if (!_socketEventHandlers.Any()) return; + + foreach (var handler in _socketEventHandlers.ToArray()) handler.Invoke(this, newState); } @@ -335,7 +320,7 @@ private void SendHeartbeat() private void HandleSocketOpened() { // Was a reconnection attempt - if (_isReconnecting == true) + if (_isReconnecting) NotifySocketStateChange(SocketState.Reconnect); // Reset flag for reconnections @@ -404,8 +389,11 @@ private void OnConnectionMessage(object sender, ResponseMessage args) private void HandleSocketError(DisconnectionInfo? disconnectionInfo = null) { - AttemptReconnection(); - NotifySocketStateChange(SocketState.Error); + if (disconnectionInfo?.Type != DisconnectionType.Error) + AttemptReconnection(); + + if (disconnectionInfo != null) + throw disconnectionInfo.Exception; } /// @@ -417,8 +405,6 @@ private void HandleSocketClosed(DisconnectionInfo? disconnectionInfo = null) if (disconnectionInfo?.Type != DisconnectionType.ByUser) AttemptReconnection(); - - NotifySocketStateChange(SocketState.Close); } private void AttemptReconnection() diff --git a/RealtimeTests/ChannelTests.cs b/RealtimeTests/ChannelTests.cs index 481fcbb..97dfd65 100644 --- a/RealtimeTests/ChannelTests.cs +++ b/RealtimeTests/ChannelTests.cs @@ -33,12 +33,9 @@ public class ChannelTests [TestInitialize] public async Task InitializeTest() { - var session = await Helpers.GetSession(); - _restClient = Helpers.RestClient(session!.AccessToken!); + _restClient = Helpers.RestClient(); _socketClient = Helpers.SocketClient(); - await _socketClient!.ConnectAsync(); - _socketClient!.SetAuth(session.AccessToken!); } [TestCleanup] @@ -128,8 +125,8 @@ public async Task ChannelPayloadReturnsModel() { var tsc = new TaskCompletionSource(); - var channel = _socketClient!.Channel("realtime", "public", "*"); - + var channel = _socketClient!.Channel("example"); + channel.Register(new PostgresChangesOptions("public", "*")); channel.AddPostgresChangeHandler(PostgresChangesOptions.ListenType.Inserts, (_, changes) => { var model = changes.Model(); diff --git a/RealtimeTests/ClientTests.cs b/RealtimeTests/ClientTests.cs index 4be4776..043dd0e 100644 --- a/RealtimeTests/ClientTests.cs +++ b/RealtimeTests/ClientTests.cs @@ -9,16 +9,12 @@ namespace RealtimeTests public class ClientTests { private Supabase.Realtime.Client? socketClient; - private Session? session; [TestInitialize] public async Task InitializeTest() { - session = await Helpers.GetSession(); socketClient = Helpers.SocketClient(); - await socketClient!.ConnectAsync(); - socketClient!.SetAuth(session!.AccessToken!); } [TestCleanup] diff --git a/RealtimeTests/Helpers.cs b/RealtimeTests/Helpers.cs index 56d155a..48b3b29 100644 --- a/RealtimeTests/Helpers.cs +++ b/RealtimeTests/Helpers.cs @@ -4,49 +4,31 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Client = Supabase.Realtime.Client; namespace RealtimeTests { - internal static class Helpers - { - private static string SupabasePublicKey => Environment.GetEnvironmentVariable("SUPABASE_PUBLIC_KEY") ?? string.Empty; - private static string SupabaseUrl => Environment.GetEnvironmentVariable("SUPABASE_URL") ?? "http://localhost:4000"; + internal static class Helpers + { + private const string ApiKey = + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiIiLCJpYXQiOjE2NzEyMzc4NzMsImV4cCI6MjAwMjc3Mzk5MywiYXVkIjoiIiwic3ViIjoiIiwicm9sZSI6ImF1dGhlbnRpY2F0ZWQifQ.qoYdljDZ9rjfs1DKj5_OqMweNtj7yk20LZKlGNLpUO8"; - private static string SupabaseUsername => Environment.GetEnvironmentVariable("SUPABASE_USERNAME") ?? string.Empty; - private static string SupabasePassword => Environment.GetEnvironmentVariable("SUPABASE_PASSWORD") ?? string.Empty; + private static readonly string SocketEndpoint = $"ws://realtime-dev.localhost:4000/socket"; + private static readonly string RestEndpoint = "http://localhost:3000"; - private static readonly string SocketEndpoint = $"{SupabaseUrl}/realtime/v1".Replace("https", "wss"); - private static readonly string RestEndpoint = $"{SupabaseUrl}/rest/v1"; - private static readonly string AuthEndpoint = $"{SupabaseUrl}/auth/v1"; + public static Postgrest.Client RestClient() => new(RestEndpoint, new Postgrest.ClientOptions()); - private static Supabase.Gotrue.Client AuthClient => new(new ClientOptions - { - Url = AuthEndpoint, - Headers = new Dictionary { { "apiKey", SupabasePublicKey } } - }); - - public static Postgrest.Client RestClient(string userToken) => new(RestEndpoint, new Postgrest.ClientOptions - { - Headers = new Dictionary - { - { "Authorization", $"Bearer {userToken}" }, - { "apiKey", SupabasePublicKey } - } - }); - - public static Task GetSession() => AuthClient.SignInWithPassword(SupabaseUsername, SupabasePassword); - - public static Supabase.Realtime.Client SocketClient() - { - var client = new Supabase.Realtime.Client(SocketEndpoint, new ClientOptions + public static Client SocketClient() + { + var client = new Client(SocketEndpoint, new ClientOptions { - Parameters = new SocketOptionsParameters + Parameters = new SocketOptionsParameters { - ApiKey = SupabasePublicKey + ApiKey = ApiKey } - }); + }); - return client; - } - } -} + return client; + } + } +} \ No newline at end of file diff --git a/RealtimeTests/Models/Todo.cs b/RealtimeTests/Models/Todo.cs index 727d255..d93725c 100644 --- a/RealtimeTests/Models/Todo.cs +++ b/RealtimeTests/Models/Todo.cs @@ -8,19 +8,14 @@ namespace RealtimeTests.Models [Table("todos")] public class Todo : BaseModel { - [PrimaryKey("id")] - public int Id { get; set; } + [PrimaryKey("id")] public int Id { get; set; } - [Column("details")] - public string? Details { get; set; } + [Column("details")] public string? Details { get; set; } - [Column("user_id")] - public int UserId { get; set; } + [Column("user_id")] public int UserId { get; set; } - [Column("numbers")] - public List? Numbers { get; set; } + [Column("numbers")] public List? Numbers { get; set; } = new List(); - [Column("inserted_at")] - public DateTime? InsertedAt { get; set; } + [Column("inserted_at")] public DateTime? InsertedAt { get; set; } } -} +} \ No newline at end of file diff --git a/RealtimeTests/RealtimeTests.csproj b/RealtimeTests/RealtimeTests.csproj index eb77e3b..c256d4a 100644 --- a/RealtimeTests/RealtimeTests.csproj +++ b/RealtimeTests/RealtimeTests.csproj @@ -1,7 +1,7 @@  - netcoreapp3.1 + net7.0 false diff --git a/RealtimeTests/db/00-schema.sql b/RealtimeTests/db/00-schema.sql index 5af4deb..6ab047a 100644 --- a/RealtimeTests/db/00-schema.sql +++ b/RealtimeTests/db/00-schema.sql @@ -1,33 +1,14 @@ -ALTER SYSTEM SET wal_level='logical'; -ALTER SYSTEM SET max_wal_senders='10'; -ALTER SYSTEM SET max_replication_slots='10'; +create role anon nologin noinherit; +create role authenticated nologin noinherit; +create role service_role nologin noinherit bypassrls; --- Tables for testing +grant usage on schema public to anon, authenticated, service_role; -CREATE TYPE public.user_status AS ENUM ('ACTIVE', 'INACTIVE'); -CREATE TABLE public.users ( - id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - name text -); -INSERT INTO - public.users (name) -VALUES - ('Joe Bloggs'), - ('Jane Doe'); +alter default privileges in schema public grant all on tables to anon, authenticated, service_role; +alter default privileges in schema public grant all on functions to anon, authenticated, service_role; +alter default privileges in schema public grant all on sequences to anon, authenticated, service_role; -CREATE TABLE public.todos ( - id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - details text, - numbers int[], - user_id bigint REFERENCES users NOT NULL, - inserted_at timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL -); +create schema if not exists _realtime; +create schema if not exists realtime; -INSERT INTO - public.todos (details, user_id) -VALUES - ('Star the repo', 1), - ('Watch the releases', 2); - --- Create the Replication publication -CREATE PUBLICATION supabase_realtime FOR ALL TABLES; \ No newline at end of file +create publication supabase_realtime with (publish = 'insert, update, delete'); \ No newline at end of file diff --git a/RealtimeTests/db/01-dummy-schema.sql b/RealtimeTests/db/01-dummy-schema.sql new file mode 100644 index 0000000..be8c87f --- /dev/null +++ b/RealtimeTests/db/01-dummy-schema.sql @@ -0,0 +1,184 @@ +-- Create a second schema +CREATE SCHEMA personal; + +-- USERS +CREATE TYPE public.user_status AS ENUM ('ONLINE', 'OFFLINE'); +CREATE TABLE public.users +( + username text primary key, + inserted_at timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + updated_at timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + favorite_numbers int[] DEFAULT null, + data jsonb DEFAULT null, + age_range int4range DEFAULT null, + status user_status DEFAULT 'ONLINE':: public.user_status, + catchphrase tsvector DEFAULT null +); +ALTER TABLE public.users + REPLICA IDENTITY FULL; -- Send "previous data" to supabase +COMMENT + ON COLUMN public.users.data IS 'For unstructured data and prototyping.'; + +CREATE TYPE public.todo_status AS ENUM ('NOT STARTED', 'STARTED', 'COMPLETED'); +create table public.todos +( + id bigint generated by default as identity not null, + name text null, + notes text null, + done boolean null default false, + details text null, + inserted_at timestamp without time zone null default now(), + numbers int[] null, + user_id text null, + status public.todo_status not null default 'NOT STARTED'::todo_status, + constraint todos_pkey primary key (id) +) tablespace pg_default; + +ALTER publication supabase_realtime add table public.todos; + +-- CHANNELS +CREATE TABLE public.channels +( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + inserted_at timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + updated_at timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + data jsonb DEFAULT null, + slug text +); +ALTER TABLE public.users + REPLICA IDENTITY FULL; -- Send "previous data" to supabase +COMMENT + ON COLUMN public.channels.data IS 'For unstructured data and prototyping.'; + +-- MESSAGES +CREATE TABLE public.messages +( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + inserted_at timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + updated_at timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + data jsonb DEFAULT null, + message text, + username text REFERENCES users NOT NULL, + channel_id bigint REFERENCES channels NOT NULL +); +ALTER TABLE public.messages + REPLICA IDENTITY FULL; -- Send "previous data" to supabase +COMMENT + ON COLUMN public.messages.data IS 'For unstructured data and prototyping.'; + +create table "public"."kitchen_sink" +( + "id" serial primary key, + "string_value" varchar(255) null, + "bool_value" BOOL DEFAULT false, + "unique_value" varchar(255) UNIQUE, + "int_value" INT null, + "float_value" FLOAT null, + "double_value" DOUBLE PRECISION null, + "datetime_value" timestamp null, + "datetime_value_1" timestamp null, + "datetime_pos_infinite_value" timestamp null, + "datetime_neg_infinite_value" timestamp null, + "list_of_strings" TEXT[] null, + "list_of_datetimes" DATE[] null, + "list_of_ints" INT[] null, + "list_of_floats" FLOAT[] null, + "int_range" INT4RANGE null +); + +CREATE TABLE public.movie +( + id serial primary key, + created_at timestamp without time zone NOT NULL DEFAULT now(), + name character varying(255) NULL +); + +CREATE TABLE public.person +( + id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at timestamp without time zone NOT NULL DEFAULT now(), + first_name character varying(255) NULL, + last_name character varying(255) NULL +); + +CREATE TABLE public.profile +( + profile_id int PRIMARY KEY references person (id), + email character varying(255) null, + created_at timestamp without time zone NOT NULL DEFAULT now() +); + +CREATE TABLE public.movie_person +( + id int generated by default as identity, + movie_id int references movie (id), + person_id int references person (id), + primary key (id, movie_id, person_id) +); + +insert into "public"."movie" ("created_at", "id", "name") +values ('2022-08-20 00:29:45.400188', 1, 'Top Gun: Maverick'); +insert into "public"."movie" ("created_at", "id", "name") +values ('2022-08-22 00:29:45.400188', 2, 'Mad Max: Fury Road'); +insert into "public"."movie" ("created_at", "id", "name") +values ('2022-08-28 00:29:45.400188', 3, 'Guns Away'); + + +insert into "public"."person" ("created_at", "first_name", "id", "last_name") +values ('2022-08-20 00:30:02.120528', 'Tom', 1, 'Cruise'); +insert into "public"."person" ("created_at", "first_name", "id", "last_name") +values ('2022-08-20 00:30:02.120528', 'Tom', 2, 'Holland'); +insert into "public"."person" ("created_at", "first_name", "id", "last_name") +values ('2022-08-20 00:30:33.72443', 'Bob', 3, 'Saggett'); +insert into "public"."person" ("created_at", "first_name", "id", "last_name") +values ('2022-08-20 00:30:33.72443', 'Random', 4, 'Actor'); + + +insert into "public"."profile" ("created_at", "email", "profile_id") +values ('2022-08-20 00:30:33.72443', 'tom.cruise@supabase.io', 1); +insert into "public"."profile" ("created_at", "email", "profile_id") +values ('2022-08-20 00:30:33.72443', 'tom.holland@supabase.io', 2); +insert into "public"."profile" ("created_at", "email", "profile_id") +values ('2022-08-20 00:30:33.72443', 'bob.saggett@supabase.io', 3); + +insert into "public"."movie_person" ("id", "movie_id", "person_id") +values (1, 1, 1); +insert into "public"."movie_person" ("id", "movie_id", "person_id") +values (2, 2, 2); +insert into "public"."movie_person" ("id", "movie_id", "person_id") +values (3, 1, 3); +insert into "public"."movie_person" ("id", "movie_id", "person_id") +values (4, 3, 4); + + +-- STORED FUNCTION +CREATE FUNCTION public.get_status(name_param text) + RETURNS user_status AS +$$ +SELECT status +from users +WHERE username = name_param; +$$ + LANGUAGE SQL IMMUTABLE; + +-- SECOND SCHEMA USERS +CREATE TYPE personal.user_status AS ENUM ('ONLINE', 'OFFLINE'); +CREATE TABLE personal.users +( + username text primary key, + inserted_at timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + updated_at timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + data jsonb DEFAULT null, + age_range int4range DEFAULT null, + status user_status DEFAULT 'ONLINE':: public.user_status +); + +-- SECOND SCHEMA STORED FUNCTION +CREATE FUNCTION personal.get_status(name_param text) + RETURNS user_status AS +$$ +SELECT status +from users +WHERE username = name_param; +$$ + LANGUAGE SQL IMMUTABLE; \ No newline at end of file diff --git a/RealtimeTests/db/02-dummy-data.sql b/RealtimeTests/db/02-dummy-data.sql new file mode 100644 index 0000000..7953b12 --- /dev/null +++ b/RealtimeTests/db/02-dummy-data.sql @@ -0,0 +1,48 @@ +INSERT INTO public.users (username, status, age_range, catchphrase) +VALUES ('supabot', 'ONLINE', '[1,2)'::int4range, 'fat cat'::tsvector), + ('kiwicopple', 'OFFLINE', '[25,35)'::int4range, 'cat bat'::tsvector), + ('awailas', 'ONLINE', '[25,35)'::int4range, 'bat rat'::tsvector), + ('dragarcia', 'ONLINE', '[20,30)'::int4range, 'rat fat'::tsvector); + +INSERT INTO public.channels (slug) +VALUES ('public'), + ('random'); + +INSERT INTO public.messages (message, channel_id, username) +VALUES ('Hello World 👋', 1, 'supabot'), + ('Perfection is attained, not when there is nothing more to add, but when there is nothing left to take away.', + 2, 'supabot'); + +INSERT INTO personal.users (username, status, age_range) +VALUES ('supabot', 'ONLINE', '[1,2)'::int4range), + ('kiwicopple', 'OFFLINE', '[25,35)'::int4range), + ('awailas', 'ONLINE', '[25,35)'::int4range), + ('dragarcia', 'ONLINE', '[20,30)'::int4range), + ('leroyjenkins', 'ONLINE', '[20,40)'::int4range); + +INSERT INTO public.kitchen_sink (string_value, + int_value, + float_value, + double_value, + datetime_value, + datetime_value_1, + datetime_pos_infinite_value, + datetime_neg_infinite_value, + list_of_strings, + list_of_datetimes, + list_of_ints, + list_of_floats, + int_range) +VALUES ('Im the Kitchen Sink!', + 99999, + '99999.0'::float4, + '99999.0'::float8, + 'Tue May 24 06:30:00 2022'::timestamp, + 'Tue May 20 06:00:00 2022'::timestamp, + 'Infinity', + '-infinity', + '{"set", "of", "strings"}', + '{NOW()}', + '{10, 20, 30, 40}', + '{10.0, 12.0}', + '[20,50]'::int4range); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ae457d6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,53 @@ +version: '3' + +services: + db: + image: supabase/postgres:14.1.0.105 + container_name: realtime-db + ports: + - "5432:5432" + volumes: + - ./RealtimeTests/db:/docker-entrypoint-initdb.d/ + command: postgres -c config_file=/etc/postgresql/postgresql.conf + environment: + POSTGRES_HOST: /var/run/postgresql + POSTGRES_PASSWORD: postgres + + rest: + image: postgrest/postgrest:latest + container_name: rest + ports: + - "3000:3000" + environment: + PGRST_DB_URI: postgres://postgres:postgres@db:5432/postgres + PGRST_DB_SCHEMA: public, personal + PGRST_DB_ANON_ROLE: postgres + PGRST_JWT_SECRET: "reallyreallyreallyreallyverysafe" + depends_on: + - db + + realtime: + depends_on: + - db + image: supabase/realtime:v2.13.0 + container_name: realtime-server + ports: + - "4000:4000" + environment: + PORT: 4000 + DB_HOST: host.docker.internal + DB_PORT: 5432 + DB_USER: postgres + DB_PASSWORD: postgres + DB_NAME: postgres + DB_ENC_KEY: supabaserealtime + DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' + API_JWT_SECRET: dc447559-996d-4761-a306-f47a5eab1623 + FLY_ALLOC_ID: fly123 + FLY_APP_NAME: realtime + SECRET_KEY_BASE: UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq + ERL_AFLAGS: -proto_dist inet_tcp + ENABLE_TAILSCALE: "false" + DNS_NODES: "''" + command: sh -c "/app/bin/migrate && /app/bin/realtime eval 'Realtime.Release.seeds(Realtime.Repo)' && /app/bin/server" + \ No newline at end of file diff --git a/realtime-csharp.sln b/realtime-csharp.sln index 5cca8b7..ed7ce76 100644 --- a/realtime-csharp.sln +++ b/realtime-csharp.sln @@ -12,6 +12,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution RealtimeTests\.runsettings = RealtimeTests\.runsettings CHANGELOG.md = CHANGELOG.md README.md = README.md + docker-compose.yml = docker-compose.yml EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples", "Examples", "{595D76A4-2809-4D38-A6FE-18785E1B3749}" From 65f3d541632bf21f3e9d3efaa754fb8117d44729 Mon Sep 17 00:00:00 2001 From: Joseph Schultz Date: Tue, 16 May 2023 11:37:35 -0500 Subject: [PATCH 7/7] Tests now build and use local file --- .github/workflows/dotnet-core.yml | 3 + Realtime/Channel/Push.cs | 5 +- Realtime/Client.cs | 8 +- Realtime/Constants.cs | 38 ++++- Realtime/Exceptions/FailureHint.cs | 4 +- Realtime/Interfaces/IRealtimeChannel.cs | 10 ++ Realtime/Interfaces/IRealtimeClient.cs | 2 +- .../PostgresChangesEventArgs.cs | 14 -- .../PostgresChangesResponse.cs | 4 +- Realtime/Realtime.csproj | 3 + Realtime/RealtimeBroadcast.cs | 2 +- Realtime/RealtimeChannel.cs | 135 +++++++++++++++--- Realtime/RealtimePresence.cs | 7 +- Realtime/RealtimeSocket.cs | 50 +++---- ...{PheonixResponse.cs => PhoenixResponse.cs} | 2 +- Realtime/Socket/SocketResponse.cs | 48 ++++--- RealtimeTests/ChannelTests.cs | 7 +- RealtimeTests/ClientTests.cs | 29 ++-- RealtimeTests/Helpers.cs | 5 +- RealtimeTests/RealtimeTests.csproj | 3 +- RealtimeTests/db/01-dummy-schema.sql | 1 + 21 files changed, 257 insertions(+), 123 deletions(-) delete mode 100644 Realtime/PostgresChanges/PostgresChangesEventArgs.cs rename Realtime/Socket/Responses/{PheonixResponse.cs => PhoenixResponse.cs} (87%) diff --git a/.github/workflows/dotnet-core.yml b/.github/workflows/dotnet-core.yml index 4d54fba..bf68e32 100644 --- a/.github/workflows/dotnet-core.yml +++ b/.github/workflows/dotnet-core.yml @@ -23,6 +23,9 @@ jobs: uses: actions/setup-dotnet@v1 with: dotnet-version: 3.1.301 + + - name: Initialize Testing Stack + run: docker-compose up -d - name: Install dependencies run: dotnet restore diff --git a/Realtime/Channel/Push.cs b/Realtime/Channel/Push.cs index e57ffec..1af0869 100644 --- a/Realtime/Channel/Push.cs +++ b/Realtime/Channel/Push.cs @@ -183,7 +183,7 @@ public void RemoveMessageReceivedListener(IRealtimePush private void NotifyMessageReceived(SocketResponse messageResponse) { - foreach (var handler in _messageEventHandlers) + foreach (var handler in _messageEventHandlers.ToArray()) handler.Invoke(this, messageResponse); } @@ -198,7 +198,4 @@ public void ClearMessageReceivedListeners() => private void CancelTimeout() => _timer.Stop(); } - public class PushTimeoutException : Exception - { - } } \ No newline at end of file diff --git a/Realtime/Client.cs b/Realtime/Client.cs index 8d549e5..7846964 100644 --- a/Realtime/Client.cs +++ b/Realtime/Client.cs @@ -213,7 +213,7 @@ public void ClearStateChangedListeners() => /// private void NotifySocketStateChange(SocketState stateChanged) { - foreach (var handler in _socketEventHandlers) + foreach (var handler in _socketEventHandlers.ToArray()) handler.Invoke(this, stateChanged); } @@ -316,12 +316,12 @@ public RealtimeChannel Channel(string channelName) /// Adds a RealtimeChannel subscription - if a subscription exists with the same signature, the existing subscription will be returned. /// /// Database to connect to, with Supabase this will likely be `realtime`. - /// Postgres schema, for example, `public` + /// Postgres schema, usually `public` /// Postgres table name /// Postgres column name /// Value the specified column should have /// - public RealtimeChannel Channel(string database = "realtime", string schema = "public", string? table = null, + public RealtimeChannel Channel(string database = "realtime", string schema = "public", string table = "*", string? column = null, string? value = null, Dictionary? parameters = null) { var key = Utils.GenerateChannelTopic(database, schema, table, column, value); @@ -390,9 +390,7 @@ private void DefaultMessageDecoder(string payload, Action callb private void HandleSocketMessageReceived(IRealtimeSocket sender, SocketResponse message) { if (message.Topic != null && _subscriptions.TryGetValue(message.Topic, out var subscription)) - { subscription.HandleSocketMessage(message); - } } private void HandleSocketStateChanged(IRealtimeSocket sender, SocketState state) diff --git a/Realtime/Constants.cs b/Realtime/Constants.cs index eb1b337..e529325 100644 --- a/Realtime/Constants.cs +++ b/Realtime/Constants.cs @@ -72,12 +72,12 @@ public enum ChannelState /// /// Phoenix Socket Server Event: CLOSE /// - public static string CHANNEL_EVENT_CLOSE = "phx_close"; + public static string ChannelEventClose = "phx_close"; /// /// Phoenix Socket Server Event: ERROR /// - public static string CHANNEL_EVENT_ERROR = "phx_error"; + public static string ChannelEventError = "phx_error"; /// /// Phoenix Socket Server Event: JOIN @@ -89,13 +89,45 @@ public enum ChannelState /// public const string ChannelEventReply = "phx_reply"; + /// + /// Phoenix Socket Server Event: SYSTEM + /// + public const string ChannelEventSystem = "system"; + /// /// Phoenix Socket Server Event: LEAVE /// public const string ChannelEventLeave = "phx_leave"; + /// + /// Phoenix Server Event: OK + /// public const string PhoenixStatusOk = "ok"; - public const string PheonixStatusError = "error"; + + /// + /// Phoenix Server Event: POSTGRES_CHANGES + /// + public const string ChannelEventPostgresChanges = "postgres_changes"; + + /// + /// Phoenix Server Event: BROADCAST + /// + public const string ChannelEventBroadcast = "broadcast"; + + /// + /// Phoenix Server Event: PRESENCE_STATE + /// + public const string ChannelEventPresenceState = "presence_state"; + + /// + /// Phoenix Server Event: PRESENCE_DIFF + /// + public const string ChannelEventPresenceDiff = "presence_diff"; + + /// + /// Phoenix Server Event: ERROR + /// + public const string PhoenixStatusError = "error"; public const string TransportWebsocket = "websocket"; diff --git a/Realtime/Exceptions/FailureHint.cs b/Realtime/Exceptions/FailureHint.cs index b642300..321ce47 100644 --- a/Realtime/Exceptions/FailureHint.cs +++ b/Realtime/Exceptions/FailureHint.cs @@ -5,7 +5,9 @@ public class FailureHint public enum Reason { Unknown, - PushTimeout + PushTimeout, + ChannelNotOpen, + JoinFailure } //public static Reason DetectReason(Socket gte) {} diff --git a/Realtime/Interfaces/IRealtimeChannel.cs b/Realtime/Interfaces/IRealtimeChannel.cs index 0cdc862..decdc32 100644 --- a/Realtime/Interfaces/IRealtimeChannel.cs +++ b/Realtime/Interfaces/IRealtimeChannel.cs @@ -6,6 +6,7 @@ using Supabase.Realtime.Socket; using System.Collections.Generic; using System.Threading.Tasks; +using Supabase.Realtime.Exceptions; using static Supabase.Realtime.Constants; using static Supabase.Realtime.PostgresChanges.PostgresChangesOptions; @@ -18,6 +19,9 @@ public interface IRealtimeChannel delegate void StateChangedHandler(IRealtimeChannel sender, ChannelState state); delegate void PostgresChangesHandler(IRealtimeChannel sender, PostgresChangesResponse change); + + delegate void ErrorEventHandler(IRealtimeChannel sender, RealtimeException exception); + bool HasJoinedOnce { get; } bool IsClosed { get; } @@ -49,6 +53,12 @@ public interface IRealtimeChannel void RemovePostgresChangeHandler(ListenType listenType, PostgresChangesHandler postgresChangeHandler); void ClearPostgresChangeHandlers(); + + void AddErrorHandler(ErrorEventHandler handler); + + void RemoveErrorHandler(ErrorEventHandler handler); + + void ClearErrorHandlers(); IRealtimeBroadcast? Broadcast(); IRealtimePresence? Presence(); diff --git a/Realtime/Interfaces/IRealtimeClient.cs b/Realtime/Interfaces/IRealtimeClient.cs index a46a095..fdba302 100644 --- a/Realtime/Interfaces/IRealtimeClient.cs +++ b/Realtime/Interfaces/IRealtimeClient.cs @@ -27,7 +27,7 @@ public interface IRealtimeClient TChannel Channel(string channelName); - TChannel Channel(string database = "realtime", string schema = "public", string? table = null, + TChannel Channel(string database = "realtime", string schema = "public", string table = "*", string? column = null, string? value = null, Dictionary? parameters = null); IRealtimeClient Connect(Action>? callback = null); diff --git a/Realtime/PostgresChanges/PostgresChangesEventArgs.cs b/Realtime/PostgresChanges/PostgresChangesEventArgs.cs deleted file mode 100644 index da29785..0000000 --- a/Realtime/PostgresChanges/PostgresChangesEventArgs.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; - -namespace Supabase.Realtime.PostgresChanges -{ - public class PostgresChangesEventArgs : EventArgs - { - public PostgresChangesResponse? Response { get; private set; } - - public PostgresChangesEventArgs(PostgresChangesResponse? response) - { - Response = response; - } - } -} diff --git a/Realtime/PostgresChanges/PostgresChangesResponse.cs b/Realtime/PostgresChanges/PostgresChangesResponse.cs index cee8c28..f255d09 100644 --- a/Realtime/PostgresChanges/PostgresChangesResponse.cs +++ b/Realtime/PostgresChanges/PostgresChangesResponse.cs @@ -25,7 +25,7 @@ public PostgresChangesResponse(JsonSerializerSettings serializerSettings) : base { if (Json != null && Payload != null && Payload.Data?.Record != null) { - var response = JsonConvert.DeserializeObject>(Json, serializerSettings); + var response = JsonConvert.DeserializeObject>(Json, SerializerSettings); return response?.Payload?.Data?.Record; } else @@ -45,7 +45,7 @@ public PostgresChangesResponse(JsonSerializerSettings serializerSettings) : base { if (Json != null && Payload != null && Payload.Data?.OldRecord != null) { - var response = JsonConvert.DeserializeObject>(Json, serializerSettings); + var response = JsonConvert.DeserializeObject>(Json, SerializerSettings); return response?.Payload?.Data?.OldRecord; } else diff --git a/Realtime/Realtime.csproj b/Realtime/Realtime.csproj index 7ad2442..9b35958 100644 --- a/Realtime/Realtime.csproj +++ b/Realtime/Realtime.csproj @@ -24,6 +24,9 @@ latest CS8600;CS8602;CS8603 + + true + 5.0.5 diff --git a/Realtime/RealtimeBroadcast.cs b/Realtime/RealtimeBroadcast.cs index c68f238..5efd629 100644 --- a/Realtime/RealtimeBroadcast.cs +++ b/Realtime/RealtimeBroadcast.cs @@ -79,7 +79,7 @@ public void ClearBroadcastEventHandlers() => private void NotifyBroadcastEventHandlers() { - foreach (var handler in _broadcastEventHandlers) + foreach (var handler in _broadcastEventHandlers.ToArray()) handler.Invoke(this, Current()); } diff --git a/Realtime/RealtimeChannel.cs b/Realtime/RealtimeChannel.cs index ed310cf..b29c8b5 100644 --- a/Realtime/RealtimeChannel.cs +++ b/Realtime/RealtimeChannel.cs @@ -28,10 +28,29 @@ namespace Supabase.Realtime; /// public class RealtimeChannel : IRealtimeChannel { + /// + /// As to whether this Channel is Closed + /// public bool IsClosed => State == ChannelState.Closed; + + /// + /// As to if this Channel has Errored + /// public bool IsErrored => State == ChannelState.Errored; + + /// + /// As to if this Channel is currently Joined + /// public bool IsJoined => State == ChannelState.Joined; + + /// + /// As to if this Channel is currently Joining + /// public bool IsJoining => State == ChannelState.Joining; + + /// + /// As to if this channel is currently leaving + /// public bool IsLeaving => State == ChannelState.Leaving; /// @@ -128,9 +147,11 @@ public class RealtimeChannel : IRealtimeChannel private readonly IRealtimeSocket _socket; private IRealtimePresence? _presence; private IRealtimeBroadcast? _broadcast; + private RealtimeException? _exception; private readonly List _stateChangedHandlers = new(); private readonly List _messageReceivedHandlers = new(); + private readonly List _errorEventHandlers = new(); private readonly Dictionary> _postgresChangesHandlers = new(); @@ -262,7 +283,7 @@ private void NotifyStateChanged(ChannelState state, bool shouldRejoin = true) else _rejoinTimer.Stop(); - foreach (var handler in _stateChangedHandlers) + foreach (var handler in _stateChangedHandlers.ToArray()) handler.Invoke(this, state); } @@ -298,7 +319,7 @@ public void ClearMessageReceivedHandlers() => /// private void NotifyMessageReceived(SocketResponse message) { - foreach (var handler in _messageReceivedHandlers) + foreach (var handler in _messageReceivedHandlers.ToArray()) handler.Invoke(this, message); } @@ -336,6 +357,43 @@ public void RemovePostgresChangeHandler(ListenType listenType, public void ClearPostgresChangeHandlers() => _postgresChangesHandlers.Clear(); + /// + /// Adds an error event handler. + /// + /// + public void AddErrorHandler(IRealtimeChannel.ErrorEventHandler handler) + { + if (!_errorEventHandlers.Contains(handler)) + _errorEventHandlers.Add(handler); + } + + /// + /// Removes an error event handler + /// + /// + /// + public void RemoveErrorHandler(IRealtimeChannel.ErrorEventHandler handler) + { + if (_errorEventHandlers.Contains(handler)) + _errorEventHandlers.Remove(handler); + } + + /// + /// Clears Error Event Handlers + /// + public void ClearErrorHandlers() => + _errorEventHandlers.Clear(); + + private void NotifyErrorOccurred(RealtimeException exception) + { + _exception = exception; + + NotifyStateChanged(ChannelState.Errored); + + foreach (var handler in _errorEventHandlers) + handler.Invoke(this, exception); + } + /// /// Notifies listeners of a postgres change message being received. /// @@ -352,12 +410,14 @@ private void NotifyPostgresChanges(EventType eventType, PostgresChangesResponse }; // Invoke the wildcard listener (but only once) - if (listenType != ListenType.All) - foreach (var handler in _postgresChangesHandlers[ListenType.All]) + if (listenType != ListenType.All && + _postgresChangesHandlers.TryGetValue(ListenType.All, out var changesHandler)) + foreach (var handler in changesHandler.ToArray()) handler.Invoke(this, response); - foreach (var handler in _postgresChangesHandlers[listenType]) - handler.Invoke(this, response); + if (_postgresChangesHandlers.TryGetValue(listenType, out var postgresChangesHandler)) + foreach (var handler in postgresChangesHandler.ToArray()) + handler.Invoke(this, response); } /// @@ -411,8 +471,7 @@ public Task Subscribe(int timeoutMs = DefaultTimeout) IsSubscribed = false; sender.RemoveStateChangedHandler(channelCallback!); JoinPush.OnTimeout -= joinPushTimeoutCallback; - - tsc.TrySetException(new Exception("Error occurred connecting to channel. Check logs.")); + tsc.TrySetException(_exception); break; } }; @@ -423,7 +482,7 @@ public Task Subscribe(int timeoutMs = DefaultTimeout) RemoveStateChangedHandler(channelCallback); JoinPush.OnTimeout -= joinPushTimeoutCallback; - tsc.TrySetException(new RealtimeException("Push Timeout") + NotifyErrorOccurred(new RealtimeException("Push Timeout") { Reason = FailureHint.Reason.PushTimeout }); @@ -468,8 +527,13 @@ public IRealtimeChannel Unsubscribe() public Push Push(string eventName, string? type = null, object? payload = null, int timeoutMs = DefaultTimeout) { if (!_hasJoinedOnce) - throw new Exception( - $"Tried to push '{eventName}' to '{Topic}' before joining. Use `Channel.Subscribe()` before pushing events"); + { + throw new RealtimeException( + $"Tried to push '{eventName}' to '{Topic}' before joining. Use `Channel.Subscribe()` before pushing events") + { + Reason = FailureHint.Reason.ChannelNotOpen + }; + } var push = new Push(_socket, this, eventName, type, payload, timeoutMs); Enqueue(push); @@ -481,6 +545,7 @@ public Push Push(string eventName, string? type = null, object? payload = null, /// Sends an arbitrary payload with a given payload type () /// /// + /// /// /// public Task Send(ChannelEventName eventName, string? type, object payload, int timeoutMs = DefaultTimeout) @@ -587,8 +652,7 @@ private void SendJoin(int timeoutMs = DefaultTimeout) NotifyStateChanged(ChannelState.Joining); // Remove handler if exists - if (JoinPush != null) - JoinPush.RemoveMessageReceivedListener(HandleJoinResponse); + JoinPush?.RemoveMessageReceivedListener(HandleJoinResponse); JoinPush = GenerateJoinPush(); JoinPush.AddMessageReceivedListener(HandleJoinResponse); @@ -604,14 +668,13 @@ private void HandleJoinResponse(IRealtimePush s { if (message._event != ChannelEventReply) return; - var obj = JsonConvert.DeserializeObject( - JsonConvert.SerializeObject(message.Payload, Options.SerializerSettings), + var obj = JsonConvert.DeserializeObject>(message.Json!, Options.SerializerSettings); + if (obj?.Payload == null) return; - if (obj == null) return; - - switch (obj.Status) + switch (obj.Payload.Status) { + // A response was received from the channel case PhoenixStatusOk: // Disable Rejoin Timeout _rejoinTimer.Stop(); @@ -620,13 +683,16 @@ private void HandleJoinResponse(IRealtimePush s var authPush = GenerateAuthPush(); authPush?.Send(); - NotifyStateChanged(ChannelState.Joined); + // If postgres_changes options are specified, we need to wait for a system event + // that registers a successful subscription (see HandleSocketMessage.System) + if (PostgresChangesOptions.Count == 0) + NotifyStateChanged(ChannelState.Joined); break; - case PheonixStatusError: + case PhoenixStatusError: _rejoinTimer.Stop(); _isRejoining = false; - NotifyStateChanged(ChannelState.Errored); + NotifyErrorOccurred(new RealtimeException(message.Json) { Reason = FailureHint.Reason.JoinFailure }); break; } } @@ -646,6 +712,31 @@ internal void HandleSocketMessage(SocketResponse message) switch (message.Event) { + // If a channel is subscribed to postgres changes then we have a special case to account for: + // A system event is emitted after the normal join ACK that says: + // {"event":"system","payload":{"channel":"public:todos","extension":"postgres_changes","message":"Subscribed to PostgreSQL","status":"ok"}} + // This switch case emits the join event after this has been received. + case EventType.System: + if (!IsJoining) return; + + var obj = JsonConvert.DeserializeObject>(message.Json!, + Options.SerializerSettings); + + if (obj?.Payload == null) return; + + switch (obj.Payload.Status) + { + case PhoenixStatusOk: + NotifyStateChanged(ChannelState.Joined); + break; + case PhoenixStatusError: + NotifyErrorOccurred(new RealtimeException(message.Json) + { Reason = FailureHint.Reason.JoinFailure }); + break; + } + + break; + // Handles Insert, Update, Delete case EventType.PostgresChanges: var deserialized = JsonConvert.DeserializeObject(message.Json!, @@ -654,7 +745,7 @@ internal void HandleSocketMessage(SocketResponse message) if (deserialized?.Payload?.Data == null) return; deserialized.Json = message.Json; - deserialized.serializerSettings = Options.SerializerSettings; + deserialized.SerializerSettings = Options.SerializerSettings; // Invoke '*' listener NotifyPostgresChanges(deserialized.Payload!.Data!.Type, deserialized); diff --git a/Realtime/RealtimePresence.cs b/Realtime/RealtimePresence.cs index dc4003d..944dcca 100644 --- a/Realtime/RealtimePresence.cs +++ b/Realtime/RealtimePresence.cs @@ -55,7 +55,8 @@ public RealtimePresence(RealtimeChannel channel, PresenceOptions options, public void AddPresenceEventHandler(IRealtimePresence.EventType eventType, IRealtimePresence.PresenceEventHandler presenceEventHandler) { - _presenceEventListeners[eventType] ??= new List(); + if (!_presenceEventListeners.ContainsKey(eventType)) + _presenceEventListeners[eventType] = new List(); if (!_presenceEventListeners[eventType].Contains(presenceEventHandler)) _presenceEventListeners[eventType].Add(presenceEventHandler); @@ -92,7 +93,9 @@ public void ClearPresenceEventHandlers(IRealtimePresence.EventType? eventType = /// private void NotifyPresenceEventHandlers(IRealtimePresence.EventType eventType) { - foreach (var handler in _presenceEventListeners[eventType]) + if (!_presenceEventListeners.ContainsKey(eventType)) return; + + foreach (var handler in _presenceEventListeners[eventType].ToArray()) handler.Invoke(this, eventType); } diff --git a/Realtime/RealtimeSocket.cs b/Realtime/RealtimeSocket.cs index 8d428c2..1351db7 100644 --- a/Realtime/RealtimeSocket.cs +++ b/Realtime/RealtimeSocket.cs @@ -7,6 +7,7 @@ using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; +using Supabase.Realtime.Exceptions; using Websocket.Client; using static Supabase.Realtime.Constants; @@ -157,7 +158,7 @@ public void RemoveStateChangedListener(IRealtimeSocket.StateEventHandler stateEv private void NotifySocketStateChange(SocketState newState) { if (!_socketEventHandlers.Any()) return; - + foreach (var handler in _socketEventHandlers.ToArray()) handler.Invoke(this, newState); } @@ -198,7 +199,7 @@ public void RemoveMessageReceivedListener(IRealtimeSocket.MessageEventHandler he /// private void NotifyMessageReceived(SocketResponse heartbeat) { - foreach (var handler in _messageEventHandlers) + foreach (var handler in _messageEventHandlers.ToArray()) handler.Invoke(this, heartbeat); } @@ -234,7 +235,7 @@ public void RemoveHeartbeatListener(IRealtimeSocket.HeartbeatEventHandler heartb /// private void NotifyHeartbeatReceived(SocketResponse heartbeat) { - foreach (var handler in _heartbeatEventHandlers) + foreach (var handler in _heartbeatEventHandlers.ToArray()) handler.Invoke(this, heartbeat); } @@ -243,7 +244,7 @@ private void NotifyHeartbeatReceived(SocketResponse heartbeat) /// public void ClearHeartbeatListeners() => _heartbeatEventHandlers.Clear(); - + /// /// Pushes formatted data to the socket server. @@ -352,47 +353,34 @@ private void HandleSocketOpened() } /// - /// Parses a recieved socket message into a non-generic type. + /// Parses a received socket message into a non-generic type. /// /// /// private void OnConnectionMessage(object sender, ResponseMessage args) { - Task.Run(() => + _options.Decode!(args.Text, decoded => { - _options.Decode!(args.Text, decoded => + _options.Logger("receive", args.Text, null); + + // Send Separate heartbeat event + if (decoded!.Ref == _pendingHeartbeatRef) { - try - { - _options.Logger("receive", args.Text, null); - - // Send Separate heartbeat event - if (decoded!.Ref == _pendingHeartbeatRef) - { - NotifyHeartbeatReceived(decoded); - return; - } - - if (decoded.Event != EventType.System) - { - decoded!.Json = args.Text; - NotifyMessageReceived(decoded); - } - } - catch (Exception ex) - { - Debug.WriteLine($"{ex.Message}"); - } - }); + NotifyHeartbeatReceived(decoded); + return; + } + + decoded!.Json = args.Text; + NotifyMessageReceived(decoded); }); } private void HandleSocketError(DisconnectionInfo? disconnectionInfo = null) { - if (disconnectionInfo?.Type != DisconnectionType.Error) + if (disconnectionInfo?.Type != DisconnectionType.Error) AttemptReconnection(); - if (disconnectionInfo != null) + if (disconnectionInfo is { Exception: not RealtimeException }) throw disconnectionInfo.Exception; } diff --git a/Realtime/Socket/Responses/PheonixResponse.cs b/Realtime/Socket/Responses/PhoenixResponse.cs similarity index 87% rename from Realtime/Socket/Responses/PheonixResponse.cs rename to Realtime/Socket/Responses/PhoenixResponse.cs index e21113e..f1ec225 100644 --- a/Realtime/Socket/Responses/PheonixResponse.cs +++ b/Realtime/Socket/Responses/PhoenixResponse.cs @@ -2,7 +2,7 @@ namespace Supabase.Realtime.Socket.Responses { - public class PheonixResponse + public class PhoenixResponse { [JsonProperty("response")] public object? Response; diff --git a/Realtime/Socket/SocketResponse.cs b/Realtime/Socket/SocketResponse.cs index af0efe9..9477604 100644 --- a/Realtime/Socket/SocketResponse.cs +++ b/Realtime/Socket/SocketResponse.cs @@ -10,9 +10,13 @@ namespace Supabase.Realtime.Socket /// public class SocketResponse : SocketResponse where T : class { + /// public SocketResponse(JsonSerializerSettings serializerSettings) : base(serializerSettings) { } + /// + /// The typed payload response + /// [JsonProperty("payload")] public new T? Payload { get; set; } } @@ -22,11 +26,15 @@ public SocketResponse(JsonSerializerSettings serializerSettings) : base(serializ /// public class SocketResponse : IRealtimeSocketResponse { - internal JsonSerializerSettings serializerSettings; + internal JsonSerializerSettings SerializerSettings; + /// + /// Represents a socket response + /// + /// public SocketResponse(JsonSerializerSettings serializerSettings) { - this.serializerSettings = serializerSettings; + SerializerSettings = serializerSettings; } /// @@ -35,33 +43,30 @@ public SocketResponse(JsonSerializerSettings serializerSettings) [JsonProperty("topic")] public string? Topic { get; set; } + /// + /// The internal, raw event given by the socket + /// [JsonProperty("event")] public string? _event { get; set; } + /// + /// The typed, parsed event given by this library. + /// [JsonIgnore] public EventType Event { get { - switch (_event) + return _event switch { - case "presence_state": - return EventType.PresenceState; - case "presence_diff": - return EventType.PresenceDiff; - case "broadcast": - return EventType.Broadcast; - case "postgres_changes": - return EventType.PostgresChanges; - case "system": - return EventType.System; - case "phx_reply": - return EventType.PostgresChanges; - } - - if (Payload == null) return EventType.Unknown; - - return Payload.Type; + ChannelEventPresenceState => EventType.PresenceState, + ChannelEventPresenceDiff => EventType.PresenceDiff, + ChannelEventBroadcast => EventType.Broadcast, + ChannelEventPostgresChanges => EventType.PostgresChanges, + ChannelEventSystem => EventType.System, + ChannelEventReply => EventType.PostgresChanges, + _ => Payload?.Type ?? EventType.Unknown + }; } } @@ -77,6 +82,9 @@ public EventType Event [JsonProperty("ref")] public string? Ref { get; set; } + /// + /// The raw JSON string of the received data. + /// [JsonIgnore] internal string? Json { get; set; } } diff --git a/RealtimeTests/ChannelTests.cs b/RealtimeTests/ChannelTests.cs index 97dfd65..bd2d4e8 100644 --- a/RealtimeTests/ChannelTests.cs +++ b/RealtimeTests/ChannelTests.cs @@ -184,9 +184,10 @@ public async Task ChannelReceivesUpdateCallback() { var tsc = new TaskCompletionSource(); - var result = await _restClient!.Table() - .Order(x => x.InsertedAt!, Postgrest.Constants.Ordering.Descending).Get(); - var model = result.Models.First(); + var response = await _restClient!.Table() + .Insert(new Todo { UserId = 1, Details = "Client receives insert callback? ✅" }); + + var model = response.Models.First(); var oldDetails = model.Details; var newDetails = $"I'm an updated item ✏️ - {DateTime.Now}"; diff --git a/RealtimeTests/ClientTests.cs b/RealtimeTests/ClientTests.cs index 043dd0e..1aa5c5c 100644 --- a/RealtimeTests/ClientTests.cs +++ b/RealtimeTests/ClientTests.cs @@ -1,6 +1,9 @@ +using System; +using System.Net; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; using Supabase.Gotrue; +using Supabase.Realtime.Exceptions; using static Supabase.Realtime.Constants; namespace RealtimeTests @@ -13,6 +16,9 @@ public class ClientTests [TestInitialize] public async Task InitializeTest() { + Console.WriteLine(); + Console.WriteLine(Dns.GetHostEntryAsync(Dns.GetHostName()).GetAwaiter().GetResult().AddressList[0]); + socketClient = Helpers.SocketClient(); await socketClient!.ConnectAsync(); } @@ -27,37 +33,40 @@ public void CleanupTest() [TestMethod("Client: Join channels of format: {database}")] public async Task ClientJoinsChannel_DB() { - var channel = socketClient!.Channel("realtime", "*"); + var channel = socketClient!.Channel(table: "todos"); await channel.Subscribe(); - Assert.AreEqual("realtime:*", channel.Topic); + Assert.AreEqual("realtime:public:todos", channel.Topic); } - [TestMethod("Client: Join channels of format: {database}:{schema}")] + [TestMethod("Client: Join channels of format: {database}:{schema}:*")] public async Task ClientJoinsChannel_DB_Schema() { - var channel = socketClient!.Channel(schema: "public"); + var channel = socketClient!.Channel("realtime", "public", "*"); await channel.Subscribe(); - Assert.AreEqual("realtime:public", channel.Topic); + Assert.AreEqual("realtime:public:*", channel.Topic); } [TestMethod("Client: Join channels of format: {database}:{schema}:{table}")] public async Task ClientJoinsChannel_DB_Schema_Table() { var channel = socketClient!.Channel("realtime", "public", "users"); - await channel.Subscribe(); + await Assert.ThrowsExceptionAsync(() => channel.Subscribe()); - Assert.AreEqual("realtime:public:users", channel.Topic); + var channel2 = socketClient!.Channel("realtime", "public", "todos"); + await channel2.Subscribe(); + + Assert.AreEqual("realtime:public:todos", channel2.Topic); } [TestMethod("Client: Join channels of format: {database}:{schema}:{table}:{col}=eq.{val}")] public async Task ClientJoinsChannel_DB_Schema_Table_Query() { - var channel = socketClient!.Channel("realtime", "public", "users", "id", "1"); + var channel = socketClient!.Channel("realtime", "public", "todos", "id", "1"); await channel.Subscribe(); - Assert.AreEqual("realtime:public:users:id=eq.1", channel.Topic); + Assert.AreEqual("realtime:public:todos:id=eq.1", channel.Topic); } [TestMethod("Client: Returns a single instance of a channel based on topic")] @@ -91,7 +100,7 @@ public async Task ClientCanRemoveChannelSubscription() public async Task ClientSetsAuth() { var channel = socketClient!.Channel("realtime", "public", "todos"); - var channel2 = socketClient!.Channel("realtime", "public", "users"); + var channel2 = socketClient!.Channel("realtime", "public", "todos"); var token = @"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.C8oVtF5DICct_4HcdSKt8pdrxBFMQOAnPpbiiUbaXAY"; diff --git a/RealtimeTests/Helpers.cs b/RealtimeTests/Helpers.cs index 48b3b29..75e6120 100644 --- a/RealtimeTests/Helpers.cs +++ b/RealtimeTests/Helpers.cs @@ -3,6 +3,7 @@ using Supabase.Realtime.Socket; using System; using System.Collections.Generic; +using System.Net; using System.Threading.Tasks; using Client = Supabase.Realtime.Client; @@ -13,8 +14,8 @@ internal static class Helpers private const string ApiKey = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiIiLCJpYXQiOjE2NzEyMzc4NzMsImV4cCI6MjAwMjc3Mzk5MywiYXVkIjoiIiwic3ViIjoiIiwicm9sZSI6ImF1dGhlbnRpY2F0ZWQifQ.qoYdljDZ9rjfs1DKj5_OqMweNtj7yk20LZKlGNLpUO8"; - private static readonly string SocketEndpoint = $"ws://realtime-dev.localhost:4000/socket"; - private static readonly string RestEndpoint = "http://localhost:3000"; + private const string SocketEndpoint = "ws://realtime-dev.localhost:4000/socket"; + private const string RestEndpoint = "http://localhost:3000"; public static Postgrest.Client RestClient() => new(RestEndpoint, new Postgrest.ClientOptions()); diff --git a/RealtimeTests/RealtimeTests.csproj b/RealtimeTests/RealtimeTests.csproj index c256d4a..2a1df82 100644 --- a/RealtimeTests/RealtimeTests.csproj +++ b/RealtimeTests/RealtimeTests.csproj @@ -1,9 +1,10 @@  - net7.0 false + + net7.0 diff --git a/RealtimeTests/db/01-dummy-schema.sql b/RealtimeTests/db/01-dummy-schema.sql index be8c87f..718e09b 100644 --- a/RealtimeTests/db/01-dummy-schema.sql +++ b/RealtimeTests/db/01-dummy-schema.sql @@ -35,6 +35,7 @@ create table public.todos ) tablespace pg_default; ALTER publication supabase_realtime add table public.todos; +alter table public.todos replica identity full; -- CHANNELS CREATE TABLE public.channels