diff --git a/SignalR.sln b/SignalR.sln index 5e114980b9..a7094d12f6 100644 --- a/SignalR.sln +++ b/SignalR.sln @@ -17,8 +17,6 @@ Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "SocketsSample", "samples\So EndProject Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.Sockets", "src\Microsoft.AspNetCore.Sockets\Microsoft.AspNetCore.Sockets.xproj", "{1715EA8D-8E13-4ACF-8BCA-57D048E55ED8}" EndProject -Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "ClientSample", "samples\ClientSample\ClientSample.xproj", "{BA99C2A1-48F9-4FA5-B95A-9687A73B7CC9}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{6A35B453-52EC-48AF-89CA-D4A69800F131}" EndProject Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.Sockets.Tests", "test\Microsoft.AspNetCore.Sockets.Tests\Microsoft.AspNetCore.Sockets.Tests.xproj", "{AAD719D5-5E31-4ED1-A60F-6EB92EFA66D9}" @@ -45,6 +43,30 @@ Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.Signal EndProject Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.SignalR.Tests", "test\Microsoft.AspNetCore.SignalR.Tests\Microsoft.AspNetCore.SignalR.Tests.xproj", "{1CE2B3BE-056C-41E3-A5F5-6A1EF1D288BA}" EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "ClientSample", "samples\ClientSample\ClientSample.xproj", "{BA99C2A1-48F9-4FA5-B95A-9687A73B7CC9}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "WebSocketSample", "samples\WebSocketSample\WebSocketSample.xproj", "{EE790D50-C632-46B9-A430-06FA2F2FDCD7}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.Sockets.Client", "src\Microsoft.AspNetCore.Sockets.Client\Microsoft.AspNetCore.Sockets.Client.xproj", "{623FD372-36DE-41A9-A564-F6040D570DBD}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.Sockets.Client.Tests", "test\Microsoft.AspNetCore.Sockets.Client.Tests\Microsoft.AspNetCore.Sockets.Client.Tests.xproj", "{B19C15A5-F5EA-4CA7-936B-1166ABEE35C4}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.SignalR.Common", "src\Microsoft.AspNetCore.SignalR.Common\Microsoft.AspNetCore.SignalR.Common.xproj", "{E37324FF-6BAF-4243-BA80-7C024CF5F29D}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.SignalR.Client", "src\Microsoft.AspNetCore.SignalR.Client\Microsoft.AspNetCore.SignalR.Client.xproj", "{354335AB-CEE9-4434-A641-78058F6EFE56}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.SignalR.Client.FunctionalTests", "test\Microsoft.AspNetCore.SignalR.Client.FunctionalTests\Microsoft.AspNetCore.SignalR.Client.FunctionalTests.xproj", "{455B68D2-C5B6-4BF4-A685-964B07AFAAF8}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.Server.IntegrationTesting", "..\Hosting\src\Microsoft.AspNetCore.Server.IntegrationTesting\Microsoft.AspNetCore.Server.IntegrationTesting.xproj", "{3DA89347-6731-4366-80C4-548F24E8607B}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.Hosting.Abstractions", "..\Hosting\src\Microsoft.AspNetCore.Hosting.Abstractions\Microsoft.AspNetCore.Hosting.Abstractions.xproj", "{BB780FBB-7842-4759-8DE7-96FA2E5571C1}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.Hosting.Server.Abstractions", "..\Hosting\src\Microsoft.AspNetCore.Hosting.Server.Abstractions\Microsoft.AspNetCore.Hosting.Server.Abstractions.xproj", "{FDBBA081-5248-4FC0-9E08-B46BEF3FA438}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.Hosting", "..\Hosting\src\Microsoft.AspNetCore.Hosting\Microsoft.AspNetCore.Hosting.xproj", "{3944F036-7E75-47E8-AA52-C4B89A64EC3A}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.TestHost", "..\Hosting\src\Microsoft.AspNetCore.TestHost\Microsoft.AspNetCore.TestHost.xproj", "{1A415A3F-1081-45DB-809B-EE19CEA02DC0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -59,10 +81,6 @@ Global {1715EA8D-8E13-4ACF-8BCA-57D048E55ED8}.Debug|Any CPU.Build.0 = Debug|Any CPU {1715EA8D-8E13-4ACF-8BCA-57D048E55ED8}.Release|Any CPU.ActiveCfg = Release|Any CPU {1715EA8D-8E13-4ACF-8BCA-57D048E55ED8}.Release|Any CPU.Build.0 = Release|Any CPU - {BA99C2A1-48F9-4FA5-B95A-9687A73B7CC9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BA99C2A1-48F9-4FA5-B95A-9687A73B7CC9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BA99C2A1-48F9-4FA5-B95A-9687A73B7CC9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BA99C2A1-48F9-4FA5-B95A-9687A73B7CC9}.Release|Any CPU.Build.0 = Release|Any CPU {AAD719D5-5E31-4ED1-A60F-6EB92EFA66D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AAD719D5-5E31-4ED1-A60F-6EB92EFA66D9}.Debug|Any CPU.Build.0 = Debug|Any CPU {AAD719D5-5E31-4ED1-A60F-6EB92EFA66D9}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -111,6 +129,54 @@ Global {1CE2B3BE-056C-41E3-A5F5-6A1EF1D288BA}.Debug|Any CPU.Build.0 = Debug|Any CPU {1CE2B3BE-056C-41E3-A5F5-6A1EF1D288BA}.Release|Any CPU.ActiveCfg = Release|Any CPU {1CE2B3BE-056C-41E3-A5F5-6A1EF1D288BA}.Release|Any CPU.Build.0 = Release|Any CPU + {BA99C2A1-48F9-4FA5-B95A-9687A73B7CC9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA99C2A1-48F9-4FA5-B95A-9687A73B7CC9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA99C2A1-48F9-4FA5-B95A-9687A73B7CC9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA99C2A1-48F9-4FA5-B95A-9687A73B7CC9}.Release|Any CPU.Build.0 = Release|Any CPU + {EE790D50-C632-46B9-A430-06FA2F2FDCD7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EE790D50-C632-46B9-A430-06FA2F2FDCD7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EE790D50-C632-46B9-A430-06FA2F2FDCD7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EE790D50-C632-46B9-A430-06FA2F2FDCD7}.Release|Any CPU.Build.0 = Release|Any CPU + {623FD372-36DE-41A9-A564-F6040D570DBD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {623FD372-36DE-41A9-A564-F6040D570DBD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {623FD372-36DE-41A9-A564-F6040D570DBD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {623FD372-36DE-41A9-A564-F6040D570DBD}.Release|Any CPU.Build.0 = Release|Any CPU + {B19C15A5-F5EA-4CA7-936B-1166ABEE35C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B19C15A5-F5EA-4CA7-936B-1166ABEE35C4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B19C15A5-F5EA-4CA7-936B-1166ABEE35C4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B19C15A5-F5EA-4CA7-936B-1166ABEE35C4}.Release|Any CPU.Build.0 = Release|Any CPU + {E37324FF-6BAF-4243-BA80-7C024CF5F29D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E37324FF-6BAF-4243-BA80-7C024CF5F29D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E37324FF-6BAF-4243-BA80-7C024CF5F29D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E37324FF-6BAF-4243-BA80-7C024CF5F29D}.Release|Any CPU.Build.0 = Release|Any CPU + {354335AB-CEE9-4434-A641-78058F6EFE56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {354335AB-CEE9-4434-A641-78058F6EFE56}.Debug|Any CPU.Build.0 = Debug|Any CPU + {354335AB-CEE9-4434-A641-78058F6EFE56}.Release|Any CPU.ActiveCfg = Release|Any CPU + {354335AB-CEE9-4434-A641-78058F6EFE56}.Release|Any CPU.Build.0 = Release|Any CPU + {455B68D2-C5B6-4BF4-A685-964B07AFAAF8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {455B68D2-C5B6-4BF4-A685-964B07AFAAF8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {455B68D2-C5B6-4BF4-A685-964B07AFAAF8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {455B68D2-C5B6-4BF4-A685-964B07AFAAF8}.Release|Any CPU.Build.0 = Release|Any CPU + {3DA89347-6731-4366-80C4-548F24E8607B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3DA89347-6731-4366-80C4-548F24E8607B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3DA89347-6731-4366-80C4-548F24E8607B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3DA89347-6731-4366-80C4-548F24E8607B}.Release|Any CPU.Build.0 = Release|Any CPU + {BB780FBB-7842-4759-8DE7-96FA2E5571C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BB780FBB-7842-4759-8DE7-96FA2E5571C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BB780FBB-7842-4759-8DE7-96FA2E5571C1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BB780FBB-7842-4759-8DE7-96FA2E5571C1}.Release|Any CPU.Build.0 = Release|Any CPU + {FDBBA081-5248-4FC0-9E08-B46BEF3FA438}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FDBBA081-5248-4FC0-9E08-B46BEF3FA438}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FDBBA081-5248-4FC0-9E08-B46BEF3FA438}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FDBBA081-5248-4FC0-9E08-B46BEF3FA438}.Release|Any CPU.Build.0 = Release|Any CPU + {3944F036-7E75-47E8-AA52-C4B89A64EC3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3944F036-7E75-47E8-AA52-C4B89A64EC3A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3944F036-7E75-47E8-AA52-C4B89A64EC3A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3944F036-7E75-47E8-AA52-C4B89A64EC3A}.Release|Any CPU.Build.0 = Release|Any CPU + {1A415A3F-1081-45DB-809B-EE19CEA02DC0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1A415A3F-1081-45DB-809B-EE19CEA02DC0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A415A3F-1081-45DB-809B-EE19CEA02DC0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1A415A3F-1081-45DB-809B-EE19CEA02DC0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -118,7 +184,6 @@ Global GlobalSection(NestedProjects) = preSolution {C4AEAB04-F341-4539-B6C0-52368FB4BF9E} = {C4BC9889-B49F-41B6-806B-F84941B2549B} {1715EA8D-8E13-4ACF-8BCA-57D048E55ED8} = {DA69F624-5398-4884-87E4-B816698CDE65} - {BA99C2A1-48F9-4FA5-B95A-9687A73B7CC9} = {C4BC9889-B49F-41B6-806B-F84941B2549B} {AAD719D5-5E31-4ED1-A60F-6EB92EFA66D9} = {6A35B453-52EC-48AF-89CA-D4A69800F131} {5D9DA986-2EAB-4C6D-BF15-9A4BDD4DE775} = {DA69F624-5398-4884-87E4-B816698CDE65} {A7050BAE-3DB9-4FB3-A49D-303201415B13} = {6A35B453-52EC-48AF-89CA-D4A69800F131} @@ -131,5 +196,12 @@ Global {8D789F94-CB74-45FD-ACE7-92AF6E55042E} = {C4BC9889-B49F-41B6-806B-F84941B2549B} {A0BF246B-FE7D-4E12-99BF-FFDC131B85D8} = {6A35B453-52EC-48AF-89CA-D4A69800F131} {1CE2B3BE-056C-41E3-A5F5-6A1EF1D288BA} = {6A35B453-52EC-48AF-89CA-D4A69800F131} + {BA99C2A1-48F9-4FA5-B95A-9687A73B7CC9} = {C4BC9889-B49F-41B6-806B-F84941B2549B} + {EE790D50-C632-46B9-A430-06FA2F2FDCD7} = {C4BC9889-B49F-41B6-806B-F84941B2549B} + {623FD372-36DE-41A9-A564-F6040D570DBD} = {DA69F624-5398-4884-87E4-B816698CDE65} + {B19C15A5-F5EA-4CA7-936B-1166ABEE35C4} = {6A35B453-52EC-48AF-89CA-D4A69800F131} + {E37324FF-6BAF-4243-BA80-7C024CF5F29D} = {DA69F624-5398-4884-87E4-B816698CDE65} + {354335AB-CEE9-4434-A641-78058F6EFE56} = {DA69F624-5398-4884-87E4-B816698CDE65} + {455B68D2-C5B6-4BF4-A685-964B07AFAAF8} = {6A35B453-52EC-48AF-89CA-D4A69800F131} EndGlobalSection EndGlobal diff --git a/samples/ClientSample/HubSample.cs b/samples/ClientSample/HubSample.cs new file mode 100644 index 0000000000..71e948678d --- /dev/null +++ b/samples/ClientSample/HubSample.cs @@ -0,0 +1,65 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO.Pipelines; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.AspNetCore.Sockets.Client; +using Microsoft.Extensions.Logging; + +namespace ClientSample +{ + internal class HubSample + { + public static async Task MainAsync(string[] args) + { + var baseUrl = "http://localhost:5000/hubs"; + if (args.Length > 0) + { + baseUrl = args[0]; + } + + var loggerFactory = new LoggerFactory(); + loggerFactory.AddConsole(LogLevel.Debug); + var logger = loggerFactory.CreateLogger(); + + using (var httpClient = new HttpClient(new LoggingMessageHandler(loggerFactory, new HttpClientHandler()))) + using (var pipelineFactory = new PipelineFactory()) + { + logger.LogInformation("Connecting to {0}", baseUrl); + var transport = new LongPollingTransport(httpClient, loggerFactory); + using (var connection = await HubConnection.ConnectAsync(new Uri(baseUrl), new JsonNetInvocationAdapter(), transport, httpClient, pipelineFactory, loggerFactory)) + { + logger.LogInformation("Connected to {0}", baseUrl); + + var cts = new CancellationTokenSource(); + Console.CancelKeyPress += (sender, a) => + { + a.Cancel = true; + logger.LogInformation("Stopping loops..."); + cts.Cancel(); + }; + + // Set up handler + connection.On("Send", new[] { typeof(string) }, a => + { + var message = (string)a[0]; + Console.WriteLine("RECEIVED: " + message); + }); + + while (!cts.Token.IsCancellationRequested) + { + var line = Console.ReadLine(); + logger.LogInformation("Sending: {0}", line); + + await connection.Invoke("Send", line); + } + } + } + } + } +} diff --git a/samples/ClientSample/LoggingMessageHandler.cs b/samples/ClientSample/LoggingMessageHandler.cs new file mode 100644 index 0000000000..9ff48d5390 --- /dev/null +++ b/samples/ClientSample/LoggingMessageHandler.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace ClientSample +{ + internal class LoggingMessageHandler : DelegatingHandler + { + private readonly ILogger _logger; + + public LoggingMessageHandler(ILoggerFactory loggerFactory) + { + _logger = loggerFactory.CreateLogger(); + } + + public LoggingMessageHandler(ILoggerFactory loggerFactory, HttpMessageHandler innerHandler) : base(innerHandler) + { + _logger = loggerFactory.CreateLogger(); + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + _logger.LogDebug("Send: {0} {1}", request.Method, request.RequestUri); + var result = await base.SendAsync(request, cancellationToken); + _logger.LogDebug("Recv: {0} {1}", (int)result.StatusCode, request.RequestUri); + return result; + } + } +} \ No newline at end of file diff --git a/samples/ClientSample/Program.cs b/samples/ClientSample/Program.cs index 69b8534047..328ed29c4b 100644 --- a/samples/ClientSample/Program.cs +++ b/samples/ClientSample/Program.cs @@ -2,68 +2,20 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using System.Net.WebSockets; +using System.IO.Pipelines; +using System.Net.Http; using System.Threading; -using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.AspNetCore.Sockets.Client; +using Microsoft.Extensions.Logging; namespace ClientSample { public class Program { - public static void Main(string[] args) - { - RunWebSockets().GetAwaiter().GetResult(); - } - - private static async Task RunWebSockets() - { - var ws = new ClientWebSocket(); - await ws.ConnectAsync(new Uri("ws://localhost:5000/chat/ws"), CancellationToken.None); - - Console.WriteLine("Connected"); - - var sending = Task.Run(async () => - { - string line; - while ((line = Console.ReadLine()) != null) - { - var bytes = Encoding.UTF8.GetBytes(line); - await ws.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, endOfMessage: true, cancellationToken: CancellationToken.None); - } - - await ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None); - }); - - var receiving = Receiving(ws); - - await Task.WhenAll(sending, receiving); - } - - private static async Task Receiving(ClientWebSocket ws) - { - var buffer = new byte[2048]; - - while (true) - { - var result = await ws.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); - - if (result.MessageType == WebSocketMessageType.Text) - { - Console.WriteLine(Encoding.UTF8.GetString(buffer, 0, result.Count)); - } - else if (result.MessageType == WebSocketMessageType.Binary) - { - } - else if (result.MessageType == WebSocketMessageType.Close) - { - await ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None); - break; - } - - } - } + //public static void Main(string[] args) => HubSample.MainAsync(args).Wait(); + public static void Main(string[] args) => RawSample.MainAsync(args).Wait(); } } diff --git a/samples/ClientSample/RawSample.cs b/samples/ClientSample/RawSample.cs new file mode 100644 index 0000000000..0e33084633 --- /dev/null +++ b/samples/ClientSample/RawSample.cs @@ -0,0 +1,98 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO.Pipelines; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Sockets.Client; +using Microsoft.Extensions.Logging; + +namespace ClientSample +{ + internal class RawSample + { + public static async Task MainAsync(string[] args) + { + var baseUrl = "http://localhost:5000/chat"; + if (args.Length > 0) + { + baseUrl = args[0]; + } + + var loggerFactory = new LoggerFactory(); + loggerFactory.AddConsole(LogLevel.Debug); + var logger = loggerFactory.CreateLogger(); + + using (var httpClient = new HttpClient(new LoggingMessageHandler(loggerFactory, new HttpClientHandler()))) + using (var pipelineFactory = new PipelineFactory()) + { + logger.LogInformation("Connecting to {0}", baseUrl); + var transport = new LongPollingTransport(httpClient, loggerFactory); + using (var connection = await Connection.ConnectAsync(new Uri(baseUrl), transport, httpClient, pipelineFactory, loggerFactory)) + { + logger.LogInformation("Connected to {0}", baseUrl); + + var cts = new CancellationTokenSource(); + Console.CancelKeyPress += (sender, a) => + { + a.Cancel = true; + logger.LogInformation("Stopping loops..."); + cts.Cancel(); + }; + + // Ready to start the loops + var receive = StartReceiving(loggerFactory.CreateLogger("ReceiveLoop"), connection, cts.Token); + var send = StartSending(loggerFactory.CreateLogger("SendLoop"), connection, cts.Token); + + await Task.WhenAll(receive, send); + } + } + } + + private static async Task StartSending(ILogger logger, Connection connection, CancellationToken cancellationToken) + { + logger.LogInformation("Send loop starting"); + while (!cancellationToken.IsCancellationRequested) + { + var line = Console.ReadLine(); + logger.LogInformation("Sending: {0}", line); + + await connection.Output.WriteAsync(Encoding.UTF8.GetBytes(line)); + } + logger.LogInformation("Send loop terminated"); + } + + private static async Task StartReceiving(ILogger logger, Connection connection, CancellationToken cancellationToken) + { + logger.LogInformation("Receive loop starting"); + using (cancellationToken.Register(() => connection.Input.Complete())) + { + while (!cancellationToken.IsCancellationRequested) + { + var result = await connection.Input.ReadAsync(); + var buffer = result.Buffer; + try + { + if (!buffer.IsEmpty) + { + var message = Encoding.UTF8.GetString(buffer.ToArray()); + logger.LogInformation("Received: {0}", message); + } + } + finally + { + connection.Input.Advance(buffer.End); + } + if (result.IsCompleted) + { + break; + } + } + } + logger.LogInformation("Receive loop terminated"); + } + } +} diff --git a/samples/ClientSample/project.json b/samples/ClientSample/project.json index 36d99be344..b385a0b72a 100644 --- a/samples/ClientSample/project.json +++ b/samples/ClientSample/project.json @@ -8,7 +8,9 @@ "version": "1.1.0-*", "type": "platform" }, - "System.Net.WebSockets.Client": "4.3.0-*" + "Microsoft.Extensions.Logging.Console": "1.2.0-*", + "Microsoft.Extensions.Logging": "1.2.0-*", + "Microsoft.AspNetCore.SignalR.Client": "1.0.0-*" }, "frameworks": { diff --git a/samples/SocketsSample/LineInvocationAdapter.cs b/samples/SocketsSample/LineInvocationAdapter.cs index 54307ad1b7..e2a17ed1a5 100644 --- a/samples/SocketsSample/LineInvocationAdapter.cs +++ b/samples/SocketsSample/LineInvocationAdapter.cs @@ -4,6 +4,7 @@ using System; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR; @@ -11,7 +12,7 @@ namespace SocketsSample { public class LineInvocationAdapter : IInvocationAdapter { - public async Task ReadInvocationDescriptorAsync(Stream stream, Func getParams) + public async Task ReadMessageAsync(Stream stream, IInvocationBinder binder, CancellationToken cancellationToken) { var streamReader = new StreamReader(stream); var line = await streamReader.ReadLineAsync(); @@ -22,31 +23,61 @@ namespace SocketsSample var values = line.Split(','); - var method = values[1].Substring(1); + var type = values[0].Substring(0, 2); + var id = values[0].Substring(2); - return new InvocationDescriptor + if (type.Equals("RI")) { - Id = values[0].Substring(2), - Method = method, - Arguments = values.Skip(2).Zip(getParams(method), (v, t) => Convert.ChangeType(v, t)).ToArray() - }; - } - - public async Task WriteInvocationDescriptorAsync(InvocationDescriptor invocationDescriptor, Stream stream) - { - var msg = $"CI{invocationDescriptor.Id},M{invocationDescriptor.Method},{string.Join(",", invocationDescriptor.Arguments.Select(a => a.ToString()))}\n"; - await WriteAsync(msg, stream); - } - - public async Task WriteInvocationResultAsync(InvocationResultDescriptor resultDescriptor, Stream stream) - { - if (string.IsNullOrEmpty(resultDescriptor.Error)) - { - await WriteAsync($"RI{resultDescriptor.Id},E{resultDescriptor.Error}\n", stream); + var resultType = values[1].Substring(0, 1); + var result = values[1].Substring(1); + return new InvocationResultDescriptor() + { + Id = id, + Result = resultType.Equals("E") ? null : result, + Error = resultType.Equals("E") ? result : null, + }; } else { - await WriteAsync($"RI{resultDescriptor.Id},R{(resultDescriptor.Result != null ? resultDescriptor.Result.ToString() : string.Empty)}\n", stream); + var method = values[1].Substring(1); + + return new InvocationDescriptor + { + Id = id, + Method = method, + Arguments = values.Skip(2).Zip(binder.GetParameterTypes(method), (v, t) => Convert.ChangeType(v, t)).ToArray() + }; + } + } + + public Task WriteMessageAsync(InvocationMessage message, Stream stream, CancellationToken cancellationToken) + { + var invocationDescriptor = message as InvocationDescriptor; + if (invocationDescriptor != null) + { + return WriteInvocationDescriptorAsync(invocationDescriptor, stream); + } + else + { + return WriteInvocationResultAsync((InvocationResultDescriptor)message, stream); + } + } + + private Task WriteInvocationDescriptorAsync(InvocationDescriptor invocationDescriptor, Stream stream) + { + var msg = $"CI{invocationDescriptor.Id},M{invocationDescriptor.Method},{string.Join(",", invocationDescriptor.Arguments.Select(a => a.ToString()))}\n"; + return WriteAsync(msg, stream); + } + + private Task WriteInvocationResultAsync(InvocationResultDescriptor resultDescriptor, Stream stream) + { + if (string.IsNullOrEmpty(resultDescriptor.Error)) + { + return WriteAsync($"RI{resultDescriptor.Id},E{resultDescriptor.Error}\n", stream); + } + else + { + return WriteAsync($"RI{resultDescriptor.Id},R{(resultDescriptor.Result != null ? resultDescriptor.Result.ToString() : string.Empty)}\n", stream); } } diff --git a/samples/SocketsSample/Protobuf/ProtobufInvocationAdapter.cs b/samples/SocketsSample/Protobuf/ProtobufInvocationAdapter.cs index c8ac2774ae..9d5501e8a7 100644 --- a/samples/SocketsSample/Protobuf/ProtobufInvocationAdapter.cs +++ b/samples/SocketsSample/Protobuf/ProtobufInvocationAdapter.cs @@ -3,6 +3,7 @@ using System; using System.IO; +using System.Threading; using System.Threading.Tasks; using Google.Protobuf; using Microsoft.AspNetCore.SignalR; @@ -19,17 +20,41 @@ namespace SocketsSample.Protobuf _serviceProvider = serviceProvider; } - public async Task ReadInvocationDescriptorAsync(Stream stream, Func getParams) + public Task ReadMessageAsync(Stream stream, IInvocationBinder binder, CancellationToken cancellationToken) { - return await Task.Run(() => CreateInvocationDescriptorInt(stream, getParams)); + return Task.Run(() => CreateInvocationMessageInt(stream, binder)); } - private Task CreateInvocationDescriptorInt(Stream stream, Func getParams) + public Task WriteMessageAsync(InvocationMessage message, Stream stream, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + private Task CreateInvocationMessageInt(Stream stream, IInvocationBinder binder) { var inputStream = new CodedInputStream(stream, leaveOpen: true); + var messageKind = new RpcMessageKind(); + inputStream.ReadMessage(messageKind); + if(messageKind.MessageKind == RpcMessageKind.Types.Kind.Invocation) + { + return CreateInvocationDescriptorInt(inputStream, binder); + } + else + { + return CreateInvocationResultDescriptorInt(inputStream, binder); + } + } + + private Task CreateInvocationResultDescriptorInt(CodedInputStream inputStream, IInvocationBinder binder) + { + throw new NotImplementedException("Not yet implemented for Protobuf"); + } + + private Task CreateInvocationDescriptorInt(CodedInputStream inputStream, IInvocationBinder binder) + { var invocationHeader = new RpcInvocationHeader(); inputStream.ReadMessage(invocationHeader); - var argumentTypes = getParams(invocationHeader.Name); + var argumentTypes = binder.GetParameterTypes(invocationHeader.Name); var invocationDescriptor = new InvocationDescriptor(); invocationDescriptor.Method = invocationHeader.Name; @@ -59,7 +84,7 @@ namespace SocketsSample.Protobuf } } - return Task.FromResult(invocationDescriptor); + return Task.FromResult(invocationDescriptor); } public async Task WriteInvocationResultAsync(InvocationResultDescriptor resultDescriptor, Stream stream) diff --git a/samples/WebSocketSample/Program.cs b/samples/WebSocketSample/Program.cs new file mode 100644 index 0000000000..ef4afc3772 --- /dev/null +++ b/samples/WebSocketSample/Program.cs @@ -0,0 +1,67 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace WebSocketSample +{ + public class Program + { + public static void Main(string[] args) + { + RunWebSockets().GetAwaiter().GetResult(); + } + + private static async Task RunWebSockets() + { + var ws = new ClientWebSocket(); + await ws.ConnectAsync(new Uri("ws://localhost:5000/chat/ws"), CancellationToken.None); + + Console.WriteLine("Connected"); + + var sending = Task.Run(async () => + { + string line; + while ((line = Console.ReadLine()) != null) + { + var bytes = Encoding.UTF8.GetBytes(line); + await ws.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, endOfMessage: true, cancellationToken: CancellationToken.None); + } + + await ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None); + }); + + var receiving = Receiving(ws); + + await Task.WhenAll(sending, receiving); + } + + private static async Task Receiving(ClientWebSocket ws) + { + var buffer = new byte[2048]; + + while (true) + { + var result = await ws.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + + if (result.MessageType == WebSocketMessageType.Text) + { + Console.WriteLine(Encoding.UTF8.GetString(buffer, 0, result.Count)); + } + else if (result.MessageType == WebSocketMessageType.Binary) + { + } + else if (result.MessageType == WebSocketMessageType.Close) + { + await ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None); + break; + } + + } + } + } +} diff --git a/samples/WebSocketSample/Properties/AssemblyInfo.cs b/samples/WebSocketSample/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..40c0217c29 --- /dev/null +++ b/samples/WebSocketSample/Properties/AssemblyInfo.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("ClientSample")] +[assembly: AssemblyTrademark("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("ba99c2a1-48f9-4fa5-b95a-9687a73b7cc9")] diff --git a/samples/WebSocketSample/WebSocketSample.xproj b/samples/WebSocketSample/WebSocketSample.xproj new file mode 100644 index 0000000000..c4a54a767a --- /dev/null +++ b/samples/WebSocketSample/WebSocketSample.xproj @@ -0,0 +1,19 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + ee790d50-c632-46b9-a430-06fa2f2fdcd7 + ClientSample + .\obj + .\bin\ + v4.5.2 + + + 2.0 + + + \ No newline at end of file diff --git a/samples/WebSocketSample/project.json b/samples/WebSocketSample/project.json new file mode 100644 index 0000000000..36d99be344 --- /dev/null +++ b/samples/WebSocketSample/project.json @@ -0,0 +1,17 @@ +{ + "buildOptions": { + "emitEntryPoint": true + }, + + "dependencies": { + "Microsoft.NETCore.App": { + "version": "1.1.0-*", + "type": "platform" + }, + "System.Net.WebSockets.Client": "4.3.0-*" + }, + + "frameworks": { + "netcoreapp1.1": {} + } +} diff --git a/src/Microsoft.AspNetCore.SignalR.Client/HubConnection.cs b/src/Microsoft.AspNetCore.SignalR.Client/HubConnection.cs new file mode 100644 index 0000000000..f9ffbe06cf --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.Client/HubConnection.cs @@ -0,0 +1,273 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO; +using System.IO.Pipelines; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Sockets.Client; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.SignalR.Client +{ + public class HubConnection : IDisposable + { + private readonly Task _reader; + private readonly Stream _stream; + private readonly ILogger _logger; + private readonly Connection _connection; + private readonly IInvocationAdapter _adapter; + private readonly HubBinder _binder; + + private readonly CancellationTokenSource _readerCts = new CancellationTokenSource(); + private readonly ConcurrentDictionary _pendingCalls = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _handlers = new ConcurrentDictionary(); + + private int _nextId = 0; + + private HubConnection(Connection connection, IInvocationAdapter adapter, ILogger logger) + { + _binder = new HubBinder(this); + _connection = connection; + _stream = connection.GetStream(); + _adapter = adapter; + _logger = logger; + + _reader = ReceiveMessages(_readerCts.Token); + _connection.Output.Writing.ContinueWith( + t => CompletePendingCalls(t.IsFaulted ? t.Exception : null)); + } + + // TODO: Client return values/tasks? + // TODO: Overloads for void hub methods + // TODO: Overloads that use type parameters (like On, On, etc.) + public void On(string methodName, Type[] parameterTypes, Action handler) + { + var invocationHandler = new InvocationHandler(parameterTypes, handler); + _handlers.AddOrUpdate(methodName, invocationHandler, (_, __) => invocationHandler); + } + + public Task Invoke(string methodName, params object[] args) => Invoke(methodName, CancellationToken.None, args); + public async Task Invoke(string methodName, CancellationToken cancellationToken, params object[] args) => ((T)(await Invoke(methodName, typeof(T), cancellationToken, args))); + + public Task Invoke(string methodName, Type returnType, params object[] args) => Invoke(methodName, returnType, CancellationToken.None, args); + public async Task Invoke(string methodName, Type returnType, CancellationToken cancellationToken, params object[] args) + { + // TODO: we should reject calls to here after the connection is "done" (e.g. sending an invocation failed) + + _logger.LogTrace("Preparing invocation of '{0}', with return type '{1}' and {2} args", methodName, returnType.AssemblyQualifiedName, args.Length); + + // Create an invocation descriptor. + var descriptor = new InvocationDescriptor + { + Id = GetNextId(), + Method = methodName, + Arguments = args + }; + + // I just want an excuse to use 'irq' as a variable name... + _logger.LogDebug("Registering Invocation ID '{0}' for tracking", descriptor.Id); + var irq = new InvocationRequest(cancellationToken, returnType); + var addedSuccessfully = _pendingCalls.TryAdd(descriptor.Id, irq); + + // This should always be true since we monotonically increase ids. + Debug.Assert(addedSuccessfully, "Id already in use?"); + + // Trace the invocation, but only if that logging level is enabled (because building the args list is a bit slow) + if (_logger.IsEnabled(LogLevel.Trace)) + { + var argsList = string.Join(", ", args.Select(a => a.GetType().FullName)); + _logger.LogTrace("Invocation #{0}: {1} {2}({3})", descriptor.Id, returnType.FullName, methodName, argsList); + } + + // Write the invocation to the stream + _logger.LogInformation("Sending Invocation #{0}", descriptor.Id); + await _adapter.WriteMessageAsync(descriptor, _stream, cancellationToken); + _logger.LogInformation("Sending Invocation #{0} complete", descriptor.Id); + + // Return the completion task. It will be completed by ReceiveMessages when the response is received. + return await irq.Completion.Task; + } + + public void Dispose() + { + _readerCts.Cancel(); + _connection.Dispose(); + } + + // TODO: Clean up the API here. Negotiation of format would be better than providing an adapter instance. Similarly, we should not require a logger factory + public static Task ConnectAsync(Uri url, IInvocationAdapter adapter, ITransport transport, PipelineFactory pipelineFactory, ILoggerFactory loggerFactory) => ConnectAsync(url, adapter, transport, new HttpClient(), pipelineFactory, loggerFactory); + + public static async Task ConnectAsync(Uri url, IInvocationAdapter adapter, ITransport transport, HttpClient httpClient, PipelineFactory pipelineFactory, ILoggerFactory loggerFactory) + { + // Connect the underlying connection + var connection = await Connection.ConnectAsync(url, transport, httpClient, pipelineFactory, loggerFactory); + + // Create the RPC connection wrapper + return new HubConnection(connection, adapter, loggerFactory.CreateLogger()); + } + + private async Task ReceiveMessages(CancellationToken cancellationToken) + { + await Task.Yield(); + + _logger.LogTrace("Beginning receive loop"); + while (!cancellationToken.IsCancellationRequested) + { + // This is a little odd... we want to remove the InvocationRequest once and only once so we pull it out in the callback, + // and stash it here because we know the callback will have finished before the end of the await. + var message = await _adapter.ReadMessageAsync(_stream, _binder, cancellationToken); + + var invocationDescriptor = message as InvocationDescriptor; + if (invocationDescriptor != null) + { + DispatchInvocation(invocationDescriptor, cancellationToken); + } + else + { + var invocationResultDescriptor = message as InvocationResultDescriptor; + if (invocationResultDescriptor != null) + { + DispatchInvocationResult(invocationResultDescriptor, cancellationToken); + } + } + } + _logger.LogTrace("Ending receive loop"); + } + + private void CompletePendingCalls(Exception e) + { + _logger.LogTrace("Completing pending calls"); + + foreach (var call in _pendingCalls.Values) + { + if (e == null) + { + call.Completion.TrySetCanceled(); + } + else + { + call.Completion.TrySetException(e); + } + } + _pendingCalls.Clear(); + } + + private void DispatchInvocation(InvocationDescriptor invocationDescriptor, CancellationToken cancellationToken) + { + // Find the handler + InvocationHandler handler; + if (!_handlers.TryGetValue(invocationDescriptor.Method, out handler)) + { + _logger.LogWarning("Failed to find handler for '{0}' method", invocationDescriptor.Method); + } + + // TODO: Return values + // TODO: Dispatch to a sync context to ensure we aren't blocking this loop. + handler.Handler(invocationDescriptor.Arguments); + } + + private void DispatchInvocationResult(InvocationResultDescriptor result, CancellationToken cancellationToken) + { + InvocationRequest irq; + var successfullyRemoved = _pendingCalls.TryRemove(result.Id, out irq); + Debug.Assert(successfullyRemoved, $"Invocation request {result.Id} was removed from the pending calls dictionary!"); + + _logger.LogInformation("Received Result for Invocation #{0}", result.Id); + + if (cancellationToken.IsCancellationRequested) + { + return; + } + + Debug.Assert(irq.Completion != null, "Didn't properly capture InvocationRequest in callback for ReadInvocationResultDescriptorAsync"); + + // If the invocation hasn't been cancelled, dispatch the result + if (!irq.CancellationToken.IsCancellationRequested) + { + irq.Registration.Dispose(); + + // Complete the request based on the result + // TODO: the TrySetXYZ methods will cause continuations attached to the Task to run, so we should dispatch to a sync context or thread pool. + if (!string.IsNullOrEmpty(result.Error)) + { + _logger.LogInformation("Completing Invocation #{0} with error: {1}", result.Id, result.Error); + irq.Completion.TrySetException(new Exception(result.Error)); + } + else + { + _logger.LogInformation("Completing Invocation #{0} with result of type: {1}", result.Id, result.Result?.GetType()?.FullName ?? "<>"); + irq.Completion.TrySetResult(result.Result); + } + } + } + + private string GetNextId() => Interlocked.Increment(ref _nextId).ToString(); + + private class HubBinder : IInvocationBinder + { + private HubConnection _connection; + + public HubBinder(HubConnection connection) + { + _connection = connection; + } + + public Type GetReturnType(string invocationId) + { + InvocationRequest irq; + if (!_connection._pendingCalls.TryGetValue(invocationId, out irq)) + { + _connection._logger.LogError("Unsolicited response received for invocation '{0}'", invocationId); + return null; + } + return irq.ResultType; + } + + public Type[] GetParameterTypes(string methodName) + { + InvocationHandler handler; + if (!_connection._handlers.TryGetValue(methodName, out handler)) + { + _connection._logger.LogWarning("Failed to find handler for '{0}' method", methodName); + return Type.EmptyTypes; + } + return handler.ParameterTypes; + } + } + + private struct InvocationHandler + { + public Action Handler { get; } + public Type[] ParameterTypes { get; } + + public InvocationHandler(Type[] parameterTypes, Action handler) + { + Handler = handler; + ParameterTypes = parameterTypes; + } + } + + private struct InvocationRequest + { + public Type ResultType { get; } + public CancellationToken CancellationToken { get; } + public CancellationTokenRegistration Registration { get; } + public TaskCompletionSource Completion { get; } + + public InvocationRequest(CancellationToken cancellationToken, Type resultType) + { + var tcs = new TaskCompletionSource(); + Completion = tcs; + CancellationToken = cancellationToken; + Registration = cancellationToken.Register(() => tcs.TrySetCanceled()); + ResultType = resultType; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.SignalR.Client/Microsoft.AspNetCore.SignalR.Client.xproj b/src/Microsoft.AspNetCore.SignalR.Client/Microsoft.AspNetCore.SignalR.Client.xproj new file mode 100644 index 0000000000..2783f56a59 --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.Client/Microsoft.AspNetCore.SignalR.Client.xproj @@ -0,0 +1,21 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + + 354335ab-cee9-4434-a641-78058f6efe56 + Microsoft.AspNetCore.SignalR.Client + .\obj + .\bin\ + v4.5.2 + + + + 2.0 + + + diff --git a/src/Microsoft.AspNetCore.SignalR.Client/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.SignalR.Client/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..32dcddfc57 --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.Client/Properties/AssemblyInfo.cs @@ -0,0 +1,11 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Reflection; +using System.Resources; + +[assembly: AssemblyMetadata("Serviceable", "True")] +[assembly: NeutralResourcesLanguage("en-us")] +[assembly: AssemblyCompany("Microsoft Corporation.")] +[assembly: AssemblyCopyright("© Microsoft Corporation. All rights reserved.")] +[assembly: AssemblyProduct("Microsoft ASP.NET Core")] \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.SignalR.Client/project.json b/src/Microsoft.AspNetCore.SignalR.Client/project.json new file mode 100644 index 0000000000..c65107bc71 --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.Client/project.json @@ -0,0 +1,34 @@ +{ + "version": "1.0.0-*", + "description": "Client for ASP.NET Core SignalR", + + "packOptions": { + "repository": { + "type": "git", + "url": "git://github.com/aspnet/signalr" + }, + "tags": [ + "aspnetcore", + "signalr" + ] + }, + "buildOptions": { + "warningsAsErrors": true, + "keyFile": "../../tools/Key.snk", + "nowarn": [ + "CS1591" + ], + "xmlDoc": true + }, + + "dependencies": { + "Microsoft.AspNetCore.SignalR.Common": "1.0.0-*", + "Microsoft.AspNetCore.Sockets.Client": "1.0.0-*", + "NETStandard.Library": "1.6.1-*" + }, + + "frameworks": { + "netstandard1.3": {}, + "net451": {} + } +} diff --git a/src/Microsoft.AspNetCore.SignalR.Common/IInvocationAdapter.cs b/src/Microsoft.AspNetCore.SignalR.Common/IInvocationAdapter.cs new file mode 100644 index 0000000000..b92a7864ee --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.Common/IInvocationAdapter.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.SignalR +{ + public interface IInvocationAdapter + { + Task ReadMessageAsync(Stream stream, IInvocationBinder binder, CancellationToken cancellationToken); + + Task WriteMessageAsync(InvocationMessage message, Stream stream, CancellationToken cancellationToken); + } +} diff --git a/src/Microsoft.AspNetCore.SignalR.Common/IInvocationBinder.cs b/src/Microsoft.AspNetCore.SignalR.Common/IInvocationBinder.cs new file mode 100644 index 0000000000..755a2b4434 --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.Common/IInvocationBinder.cs @@ -0,0 +1,10 @@ +using System; + +namespace Microsoft.AspNetCore.SignalR +{ + public interface IInvocationBinder + { + Type GetReturnType(string invocationId); + Type[] GetParameterTypes(string methodName); + } +} diff --git a/src/Microsoft.AspNetCore.SignalR.Common/InvocationAdapterExtensions.cs b/src/Microsoft.AspNetCore.SignalR.Common/InvocationAdapterExtensions.cs new file mode 100644 index 0000000000..8b33886b1b --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.Common/InvocationAdapterExtensions.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.SignalR +{ + public static class InvocationAdapterExtensions + { + public static Task ReadMessageAsync(this IInvocationAdapter self, Stream stream, IInvocationBinder binder) => self.ReadMessageAsync(stream, binder, CancellationToken.None); + + public static Task WriteMessageAsync(this IInvocationAdapter self, InvocationMessage message, Stream stream) => self.WriteMessageAsync(message, stream, CancellationToken.None); + } +} diff --git a/src/Microsoft.AspNetCore.SignalR/InvocationDescriptor.cs b/src/Microsoft.AspNetCore.SignalR.Common/InvocationDescriptor.cs similarity index 85% rename from src/Microsoft.AspNetCore.SignalR/InvocationDescriptor.cs rename to src/Microsoft.AspNetCore.SignalR.Common/InvocationDescriptor.cs index 17bfa24763..a15205192d 100644 --- a/src/Microsoft.AspNetCore.SignalR/InvocationDescriptor.cs +++ b/src/Microsoft.AspNetCore.SignalR.Common/InvocationDescriptor.cs @@ -5,10 +5,8 @@ using System; namespace Microsoft.AspNetCore.SignalR { - public class InvocationDescriptor + public class InvocationDescriptor : InvocationMessage { - public string Id { get; set; } - public string Method { get; set; } public object[] Arguments { get; set; } diff --git a/src/Microsoft.AspNetCore.SignalR.Common/InvocationMessage.cs b/src/Microsoft.AspNetCore.SignalR.Common/InvocationMessage.cs new file mode 100644 index 0000000000..27fbe9ad26 --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.Common/InvocationMessage.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.SignalR +{ + public abstract class InvocationMessage + { + public string Id { get; set; } + } +} diff --git a/src/Microsoft.AspNetCore.SignalR/InvocationResultDescriptor.cs b/src/Microsoft.AspNetCore.SignalR.Common/InvocationResultDescriptor.cs similarity index 82% rename from src/Microsoft.AspNetCore.SignalR/InvocationResultDescriptor.cs rename to src/Microsoft.AspNetCore.SignalR.Common/InvocationResultDescriptor.cs index 8fc9cb9511..32428a32c7 100644 --- a/src/Microsoft.AspNetCore.SignalR/InvocationResultDescriptor.cs +++ b/src/Microsoft.AspNetCore.SignalR.Common/InvocationResultDescriptor.cs @@ -8,10 +8,8 @@ using System.Threading.Tasks; namespace Microsoft.AspNetCore.SignalR { - public class InvocationResultDescriptor + public class InvocationResultDescriptor : InvocationMessage { - public string Id { get; set; } - public object Result { get; set; } public string Error { get; set; } diff --git a/src/Microsoft.AspNetCore.SignalR.Common/JsonNetInvocationAdapter.cs b/src/Microsoft.AspNetCore.SignalR.Common/JsonNetInvocationAdapter.cs new file mode 100644 index 0000000000..e150ee6c5e --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.Common/JsonNetInvocationAdapter.cs @@ -0,0 +1,91 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Internal; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.AspNetCore.SignalR +{ + public class JsonNetInvocationAdapter : IInvocationAdapter + { + private JsonSerializer _serializer = new JsonSerializer(); + + public JsonNetInvocationAdapter() + { + } + + public Task ReadMessageAsync(Stream stream, IInvocationBinder binder, CancellationToken cancellationToken) + { + var reader = new JsonTextReader(new StreamReader(stream)); + // REVIEW: Task.Run() + return Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + var json = _serializer.Deserialize(reader); + if (json == null) + { + return null; + } + + // Determine the type of the message + if (json["Result"] != null) + { + // It's a result + return BindInvocationResultDescriptor(json, binder, cancellationToken); + } + else + { + return BindInvocationDescriptor(json, binder, cancellationToken); + } + }, cancellationToken); + } + + public Task WriteMessageAsync(InvocationMessage message, Stream stream, CancellationToken cancellationToken) + { + var writer = new JsonTextWriter(new StreamWriter(stream)); + _serializer.Serialize(writer, message); + writer.Flush(); + return TaskCache.CompletedTask; + } + + private InvocationDescriptor BindInvocationDescriptor(JObject json, IInvocationBinder binder, CancellationToken cancellationToken) + { + var invocation = new InvocationDescriptor + { + Id = json.Value("Id"), + Method = json.Value("Method"), + }; + + var paramTypes = binder.GetParameterTypes(invocation.Method); + invocation.Arguments = new object[paramTypes.Length]; + + var args = json.Value("Arguments"); + for (var i = 0; i < paramTypes.Length; i++) + { + var paramType = paramTypes[i]; + invocation.Arguments[i] = args[i].ToObject(paramType, _serializer); + } + + return invocation; + } + + private InvocationResultDescriptor BindInvocationResultDescriptor(JObject json, IInvocationBinder binder, CancellationToken cancellationToken) + { + var id = json.Value("Id"); + var returnType = binder.GetReturnType(id); + var result = new InvocationResultDescriptor() + { + Id = id, + Result = returnType == null ? null : json["Result"].ToObject(returnType, _serializer), + Error = json.Value("Error") + }; + return result; + } + } +} diff --git a/src/Microsoft.AspNetCore.SignalR.Common/Microsoft.AspNetCore.SignalR.Common.xproj b/src/Microsoft.AspNetCore.SignalR.Common/Microsoft.AspNetCore.SignalR.Common.xproj new file mode 100644 index 0000000000..ab66b21be3 --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.Common/Microsoft.AspNetCore.SignalR.Common.xproj @@ -0,0 +1,19 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + e37324ff-6baf-4243-ba80-7c024cf5f29d + Microsoft.AspNetCore.SignalR + .\obj + .\bin\ + v4.5.2 + + + 2.0 + + + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.SignalR.Common/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.SignalR.Common/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..32dcddfc57 --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.Common/Properties/AssemblyInfo.cs @@ -0,0 +1,11 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Reflection; +using System.Resources; + +[assembly: AssemblyMetadata("Serviceable", "True")] +[assembly: NeutralResourcesLanguage("en-us")] +[assembly: AssemblyCompany("Microsoft Corporation.")] +[assembly: AssemblyCopyright("© Microsoft Corporation. All rights reserved.")] +[assembly: AssemblyProduct("Microsoft ASP.NET Core")] \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.SignalR.Common/project.json b/src/Microsoft.AspNetCore.SignalR.Common/project.json new file mode 100644 index 0000000000..9bf05132f1 --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.Common/project.json @@ -0,0 +1,37 @@ +{ + "version": "1.0.0-*", + "description": "Common serialiation primitives for SignalR Clients Servers", + + "packOptions": { + "repository": { + "type": "git", + "url": "git://github.com/aspnet/signalr" + }, + "tags": [ + "aspnetcore", + "signalr" + ] + }, + "buildOptions": { + "warningsAsErrors": true, + "keyFile": "../../tools/Key.snk", + "nowarn": [ + "CS1591" + ], + "xmlDoc": true + }, + + "dependencies": { + "NETStandard.Library": "1.6.1-*", + "Microsoft.Extensions.TaskCache.Sources": { + "version": "1.2.0-*", + "type": "build" + }, + "Newtonsoft.Json": "9.0.1" + }, + + "frameworks": { + "netstandard1.3": {}, + "net451": {} + } +} diff --git a/src/Microsoft.AspNetCore.SignalR.Redis/RedisHubLifetimeManager.cs b/src/Microsoft.AspNetCore.SignalR.Redis/RedisHubLifetimeManager.cs index aa19f7c712..f1765e1223 100644 --- a/src/Microsoft.AspNetCore.SignalR.Redis/RedisHubLifetimeManager.cs +++ b/src/Microsoft.AspNetCore.SignalR.Redis/RedisHubLifetimeManager.cs @@ -110,7 +110,7 @@ namespace Microsoft.AspNetCore.SignalR.Redis // BAD using (var ms = new MemoryStream()) { - await invocationAdapter.WriteInvocationDescriptorAsync(message, ms); + await invocationAdapter.WriteMessageAsync(message, ms); await _bus.PublishAsync(channel, ms.ToArray()); } diff --git a/src/Microsoft.AspNetCore.SignalR/DefaultHubLifetimeManager.cs b/src/Microsoft.AspNetCore.SignalR/DefaultHubLifetimeManager.cs index 2cb1ede9eb..932a1bc665 100644 --- a/src/Microsoft.AspNetCore.SignalR/DefaultHubLifetimeManager.cs +++ b/src/Microsoft.AspNetCore.SignalR/DefaultHubLifetimeManager.cs @@ -69,7 +69,7 @@ namespace Microsoft.AspNetCore.SignalR var invocationAdapter = _registry.GetInvocationAdapter(connection.Metadata.Get("formatType")); - tasks.Add(invocationAdapter.WriteInvocationDescriptorAsync(message, connection.Channel.GetStream())); + tasks.Add(invocationAdapter.WriteMessageAsync(message, connection.Channel.GetStream())); } return Task.WhenAll(tasks); @@ -87,7 +87,7 @@ namespace Microsoft.AspNetCore.SignalR Arguments = args }; - return invocationAdapter.WriteInvocationDescriptorAsync(message, connection.Channel.GetStream()); + return invocationAdapter.WriteMessageAsync(message, connection.Channel.GetStream()); } public override Task InvokeGroupAsync(string groupName, string methodName, object[] args) diff --git a/src/Microsoft.AspNetCore.SignalR/HubEndPoint.cs b/src/Microsoft.AspNetCore.SignalR/HubEndPoint.cs index 598c293163..5af668ba62 100644 --- a/src/Microsoft.AspNetCore.SignalR/HubEndPoint.cs +++ b/src/Microsoft.AspNetCore.SignalR/HubEndPoint.cs @@ -25,7 +25,7 @@ namespace Microsoft.AspNetCore.SignalR } } - public class HubEndPoint : EndPoint where THub : Hub + public class HubEndPoint : EndPoint, IInvocationBinder where THub : Hub { private readonly Dictionary>> _callbacks = new Dictionary>>(StringComparer.OrdinalIgnoreCase); @@ -102,14 +102,8 @@ namespace Microsoft.AspNetCore.SignalR while (true) { - var invocationDescriptor = - await invocationAdapter.ReadInvocationDescriptorAsync( - stream, methodName => - { - Type[] types; - // TODO: null or throw? - return _paramTypes.TryGetValue(methodName, out types) ? types : null; - }); + // TODO: Handle receiving InvocationResultDescriptor + var invocationDescriptor = await invocationAdapter.ReadMessageAsync(stream, this) as InvocationDescriptor; // Is there a better way of detecting that a connection was closed? if (invocationDescriptor == null) @@ -140,7 +134,7 @@ namespace Microsoft.AspNetCore.SignalR _logger.LogError("Unknown hub method '{method}'", invocationDescriptor.Method); } - await invocationAdapter.WriteInvocationResultAsync(result, stream); + await invocationAdapter.WriteMessageAsync(result, stream); } } @@ -240,5 +234,17 @@ namespace Microsoft.AspNetCore.SignalR // TODO: Add more checks return m.IsPublic; } + + Type IInvocationBinder.GetReturnType(string invocationId) + { + return typeof(object); + } + + Type[] IInvocationBinder.GetParameterTypes(string methodName) + { + Type[] types; + // TODO: null or throw? + return _paramTypes.TryGetValue(methodName, out types) ? types : null; + } } } diff --git a/src/Microsoft.AspNetCore.SignalR/IInvocationAdapter.cs b/src/Microsoft.AspNetCore.SignalR/IInvocationAdapter.cs deleted file mode 100644 index 0cfa09aa89..0000000000 --- a/src/Microsoft.AspNetCore.SignalR/IInvocationAdapter.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.IO; -using System.Threading.Tasks; - -namespace Microsoft.AspNetCore.SignalR -{ - public interface IInvocationAdapter - { - Task ReadInvocationDescriptorAsync(Stream stream, Func getParams); - - Task WriteInvocationResultAsync(InvocationResultDescriptor resultDescriptor, Stream stream); - - Task WriteInvocationDescriptorAsync(InvocationDescriptor invocationDescriptor, Stream stream); - } -} diff --git a/src/Microsoft.AspNetCore.SignalR/JsonNetInvocationAdapter.cs b/src/Microsoft.AspNetCore.SignalR/JsonNetInvocationAdapter.cs deleted file mode 100644 index 4657f25697..0000000000 --- a/src/Microsoft.AspNetCore.SignalR/JsonNetInvocationAdapter.cs +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.IO; -using System.Reflection; -using System.Threading.Tasks; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace Microsoft.AspNetCore.SignalR -{ - public class JsonNetInvocationAdapter : IInvocationAdapter - { - private JsonSerializer _serializer = new JsonSerializer(); - - public JsonNetInvocationAdapter() - { - } - - public Task ReadInvocationDescriptorAsync(Stream stream, Func getParams) - { - var reader = new JsonTextReader(new StreamReader(stream)); - // REVIEW: Task.Run() - return Task.Run(() => - { - var jsonInvocation = _serializer.Deserialize(reader); - if (jsonInvocation == null) - { - return null; - } - - var invocation = new InvocationDescriptor - { - Id = jsonInvocation.Id, - Method = jsonInvocation.Method, - }; - - var paramTypes = getParams(jsonInvocation.Method); - invocation.Arguments = new object[paramTypes.Length]; - - for (int i = 0; i < paramTypes.Length; i++) - { - var paramType = paramTypes[i]; - invocation.Arguments[i] = jsonInvocation.Arguments[i].ToObject(paramType, _serializer); - } - - return invocation; - }); - } - - public Task WriteInvocationResultAsync(InvocationResultDescriptor resultDescriptor, Stream stream) - { - Write(resultDescriptor, stream); - return Task.FromResult(0); - } - - public Task WriteInvocationDescriptorAsync(InvocationDescriptor invocationDescriptor, Stream stream) - { - Write(invocationDescriptor, stream); - return Task.FromResult(0); - } - - private void Write(object value, Stream stream) - { - var writer = new JsonTextWriter(new StreamWriter(stream)); - _serializer.Serialize(writer, value); - writer.Flush(); - } - - private class JsonNetInvocationDescriptor - { - public string Id { get; set; } - - public string Method { get; set; } - - public JArray Arguments { get; set; } - } - } -} diff --git a/src/Microsoft.AspNetCore.SignalR/SignalRDependencyInjectionExtensions.cs b/src/Microsoft.AspNetCore.SignalR/SignalRDependencyInjectionExtensions.cs index 6991f13ddd..90f8035c19 100644 --- a/src/Microsoft.AspNetCore.SignalR/SignalRDependencyInjectionExtensions.cs +++ b/src/Microsoft.AspNetCore.SignalR/SignalRDependencyInjectionExtensions.cs @@ -19,6 +19,7 @@ namespace Microsoft.Extensions.DependencyInjection services.AddSingleton, SignalROptionsSetup>(); services.AddSingleton(); services.AddSingleton(); + services.AddRouting(); return new SignalRBuilder(services); } diff --git a/src/Microsoft.AspNetCore.SignalR/project.json b/src/Microsoft.AspNetCore.SignalR/project.json index 1fe3517653..e0aabd450e 100644 --- a/src/Microsoft.AspNetCore.SignalR/project.json +++ b/src/Microsoft.AspNetCore.SignalR/project.json @@ -25,12 +25,12 @@ "Microsoft.AspNetCore.Sockets": { "target": "project" }, + "Microsoft.AspNetCore.SignalR.Common": "1.0.0-*", "Microsoft.Extensions.TaskCache.Sources": { "version": "1.2.0-*", "type": "build" }, - "NETStandard.Library": "1.6.1-*", - "Newtonsoft.Json": "9.0.1" + "NETStandard.Library": "1.6.1-*" }, "frameworks": { diff --git a/src/Microsoft.AspNetCore.Sockets.Client/BufferContent.cs b/src/Microsoft.AspNetCore.Sockets.Client/BufferContent.cs new file mode 100644 index 0000000000..f4171f5220 --- /dev/null +++ b/src/Microsoft.AspNetCore.Sockets.Client/BufferContent.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using System.IO.Pipelines; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Sockets.Client +{ + internal class ReadableBufferContent : HttpContent + { + private ReadableBuffer _buffer; + + public ReadableBufferContent(ReadableBuffer buffer) + { + _buffer = buffer; + } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) => _buffer.CopyToAsync(stream); + + protected override bool TryComputeLength(out long length) + { + length = _buffer.Length; + return true; + } + } +} diff --git a/src/Microsoft.AspNetCore.Sockets.Client/Connection.cs b/src/Microsoft.AspNetCore.Sockets.Client/Connection.cs new file mode 100644 index 0000000000..0b2cbe850b --- /dev/null +++ b/src/Microsoft.AspNetCore.Sockets.Client/Connection.cs @@ -0,0 +1,117 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO.Pipelines; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Sockets.Client +{ + public class Connection : IPipelineConnection + { + private IPipelineConnection _consumerPipe; + private ITransport _transport; + private readonly ILogger _logger; + + public Uri Url { get; } + + // TODO: Review. This is really only designed to be used from ConnectAsync + private Connection(Uri url, ITransport transport, IPipelineConnection consumerPipe, ILogger logger) + { + Url = url; + + _logger = logger; + _transport = transport; + _consumerPipe = consumerPipe; + + _consumerPipe.Output.Writing.ContinueWith(t => + { + if (t.IsFaulted) + { + _consumerPipe.Input.Complete(t.Exception); + } + + return t; + }); + } + + public IPipelineReader Input => _consumerPipe.Input; + public IPipelineWriter Output => _consumerPipe.Output; + + public void Dispose() + { + _consumerPipe.Dispose(); + _transport.Dispose(); + } + + // TODO: More overloads. PipelineFactory should be optional but someone needs to dispose the pool, if we're OK with it being the GC, then this is easy. + public static Task ConnectAsync(Uri url, ITransport transport, PipelineFactory pipelineFactory) => ConnectAsync(url, transport, new HttpClient(), pipelineFactory, NullLoggerFactory.Instance); + public static Task ConnectAsync(Uri url, ITransport transport, PipelineFactory pipelineFactory, ILoggerFactory loggerFactory) => ConnectAsync(url, transport, new HttpClient(), pipelineFactory, loggerFactory); + public static Task ConnectAsync(Uri url, ITransport transport, HttpClient httpClient, PipelineFactory pipelineFactory) => ConnectAsync(url, transport, httpClient, pipelineFactory, NullLoggerFactory.Instance); + + public static async Task ConnectAsync(Uri url, ITransport transport, HttpClient httpClient, PipelineFactory pipelineFactory, ILoggerFactory loggerFactory) + { + if (url == null) + { + throw new ArgumentNullException(nameof(url)); + } + + if (transport == null) + { + throw new ArgumentNullException(nameof(transport)); + } + + if (httpClient == null) + { + throw new ArgumentNullException(nameof(httpClient)); + } + + if (pipelineFactory == null) + { + throw new ArgumentNullException(nameof(pipelineFactory)); + } + + if (loggerFactory == null) + { + throw new ArgumentNullException(nameof(loggerFactory)); + } + + var logger = loggerFactory.CreateLogger(); + var getIdUrl = Utils.AppendPath(url, "getid"); + + string connectionId; + try + { + // Get a connection ID from the server + logger.LogDebug("Reserving Connection Id from: {0}", getIdUrl); + connectionId = await httpClient.GetStringAsync(getIdUrl); + logger.LogDebug("Reserved Connection Id: {0}", connectionId); + } + catch (Exception ex) + { + logger.LogError("Failed to start connection. Error getting connection id from '{0}': {1}", getIdUrl, ex); + throw; + } + + var connectedUrl = Utils.AppendQueryString(url, "id=" + connectionId); + + var pair = pipelineFactory.CreatePipelinePair(); + + // Start the transport, giving it one end of the pipeline + try + { + await transport.StartAsync(connectedUrl, pair.Item1); + } + catch (Exception ex) + { + logger.LogError("Failed to start connection. Error starting transport '{0}': {1}", transport.GetType().Name, ex); + throw; + } + + // Create the connection, giving it the other end of the pipeline + return new Connection(url, transport, pair.Item2, logger); + } + } +} diff --git a/src/Microsoft.AspNetCore.Sockets.Client/ITransport.cs b/src/Microsoft.AspNetCore.Sockets.Client/ITransport.cs new file mode 100644 index 0000000000..498c036fca --- /dev/null +++ b/src/Microsoft.AspNetCore.Sockets.Client/ITransport.cs @@ -0,0 +1,11 @@ +using System; +using System.IO.Pipelines; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Sockets.Client +{ + public interface ITransport : IDisposable + { + Task StartAsync(Uri url, IPipelineConnection pipeline); + } +} diff --git a/src/Microsoft.AspNetCore.Sockets.Client/LongPollingTransport.cs b/src/Microsoft.AspNetCore.Sockets.Client/LongPollingTransport.cs new file mode 100644 index 0000000000..aca67c9cd6 --- /dev/null +++ b/src/Microsoft.AspNetCore.Sockets.Client/LongPollingTransport.cs @@ -0,0 +1,139 @@ +using System; +using System.IO.Pipelines; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Sockets.Client +{ + public class LongPollingTransport : ITransport + { + private static readonly string DefaultUserAgent = "Microsoft.AspNetCore.SignalR.Client/0.0.0"; + private static readonly ProductInfoHeaderValue DefaultUserAgentHeader = ProductInfoHeaderValue.Parse(DefaultUserAgent); + + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly CancellationTokenSource _senderCts = new CancellationTokenSource(); + private readonly CancellationTokenSource _pollCts = new CancellationTokenSource(); + + private IPipelineConnection _pipeline; + private Task _sender; + private Task _poller; + + public Task Running { get; private set; } + + public LongPollingTransport(HttpClient httpClient, ILoggerFactory loggerFactory) + { + _httpClient = httpClient; + _logger = loggerFactory.CreateLogger(); + } + + public void Dispose() + { + _senderCts.Cancel(); + _pollCts.Cancel(); + _pipeline?.Dispose(); + } + + public Task StartAsync(Uri url, IPipelineConnection pipeline) + { + _pipeline = pipeline; + + // Schedule shutdown of the poller when the output is closed + pipeline.Output.Writing.ContinueWith(_ => + { + _pollCts.Cancel(); + return TaskCache.CompletedTask; + }); + + // Start sending and polling + _sender = SendMessages(Utils.AppendPath(url, "send"), _senderCts.Token); + _poller = Poll(Utils.AppendPath(url, "poll"), _pollCts.Token); + Running = Task.WhenAll(_sender, _poller); + + return TaskCache.CompletedTask; + } + + private async Task Poll(Uri pollUrl, CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + var request = new HttpRequestMessage(HttpMethod.Get, pollUrl); + request.Headers.UserAgent.Add(DefaultUserAgentHeader); + + var response = await _httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + if (response.StatusCode == HttpStatusCode.NoContent || cancellationToken.IsCancellationRequested) + { + // Transport closed or polling stopped, we're done + break; + } + else + { + // Write the data to the output + var buffer = _pipeline.Output.Alloc(); + var stream = new WriteableBufferStream(buffer); + await response.Content.CopyToAsync(stream); + await buffer.FlushAsync(); + } + } + + // Polling complete + _pipeline.Output.Complete(); + } + catch (Exception ex) + { + // Shut down the output pipeline and log + _logger.LogError("Error while polling '{0}': {1}", pollUrl, ex); + _pipeline.Output.Complete(ex); + } + } + + private async Task SendMessages(Uri sendUrl, CancellationToken cancellationToken) + { + using (cancellationToken.Register(() => _pipeline.Input.Complete())) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + var result = await _pipeline.Input.ReadAsync(); + var buffer = result.Buffer; + if (buffer.IsEmpty || result.IsCompleted) + { + // No more data to send + break; + } + + // Create a message to send + var message = new HttpRequestMessage(HttpMethod.Post, sendUrl); + message.Headers.UserAgent.Add(DefaultUserAgentHeader); + message.Content = new ReadableBufferContent(buffer); + + // Send it + var response = await _httpClient.SendAsync(message); + response.EnsureSuccessStatusCode(); + + _pipeline.Input.Advance(buffer.End); + } + + // Sending complete + _pipeline.Input.Complete(); + } + catch (Exception ex) + { + // Shut down the input pipeline and log + _logger.LogError("Error while sending to '{0}': {1}", sendUrl, ex); + _pipeline.Input.Complete(ex); + } + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Sockets.Client/Microsoft.AspNetCore.Sockets.Client.xproj b/src/Microsoft.AspNetCore.Sockets.Client/Microsoft.AspNetCore.Sockets.Client.xproj new file mode 100644 index 0000000000..be598871ad --- /dev/null +++ b/src/Microsoft.AspNetCore.Sockets.Client/Microsoft.AspNetCore.Sockets.Client.xproj @@ -0,0 +1,21 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + + 623fd372-36de-41a9-a564-f6040d570dbd + Microsoft.AspNetCore.SignalR.Client + .\obj + .\bin\ + v4.5.2 + + + + 2.0 + + + diff --git a/src/Microsoft.AspNetCore.Sockets.Client/NullLoggerFactory.cs b/src/Microsoft.AspNetCore.Sockets.Client/NullLoggerFactory.cs new file mode 100644 index 0000000000..3259cf6aa7 --- /dev/null +++ b/src/Microsoft.AspNetCore.Sockets.Client/NullLoggerFactory.cs @@ -0,0 +1,28 @@ +using System; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.AspNetCore.Sockets.Client +{ + internal class NullLoggerFactory : ILoggerFactory + { + public static readonly ILoggerFactory Instance = new NullLoggerFactory(); + + private NullLoggerFactory() + { + } + + public void AddProvider(ILoggerProvider provider) + { + } + + public ILogger CreateLogger(string categoryName) + { + return NullLogger.Instance; + } + + public void Dispose() + { + } + } +} diff --git a/src/Microsoft.AspNetCore.Sockets.Client/PipelineConnection.cs b/src/Microsoft.AspNetCore.Sockets.Client/PipelineConnection.cs new file mode 100644 index 0000000000..adf279e500 --- /dev/null +++ b/src/Microsoft.AspNetCore.Sockets.Client/PipelineConnection.cs @@ -0,0 +1,22 @@ +using System.IO.Pipelines; + +namespace Microsoft.AspNetCore.Sockets.Client +{ + internal class PipelineConnection : IPipelineConnection + { + public IPipelineReader Input { get; } + public IPipelineWriter Output { get; } + + public PipelineConnection(IPipelineReader input, IPipelineWriter output) + { + Input = input; + Output = output; + } + + public void Dispose() + { + Input.Complete(); + Output.Complete(); + } + } +} diff --git a/src/Microsoft.AspNetCore.Sockets.Client/PipelineFactoryExtensions.cs b/src/Microsoft.AspNetCore.Sockets.Client/PipelineFactoryExtensions.cs new file mode 100644 index 0000000000..cd8da543c6 --- /dev/null +++ b/src/Microsoft.AspNetCore.Sockets.Client/PipelineFactoryExtensions.cs @@ -0,0 +1,29 @@ +using System; +using System.IO.Pipelines; + +namespace Microsoft.AspNetCore.Sockets.Client +{ + // TODO: Move to System.IO.Pipelines + public static class PipelineFactoryExtensions + { + // TODO: Use a named tuple? Though there aren't really good names for these ... client/server? left/right? + public static Tuple CreatePipelinePair(this PipelineFactory self) + { + // Create a pair of pipelines for "Server" and "Client" + var clientToServer = self.Create(); + var serverToClient = self.Create(); + + // "Server" reads from clientToServer and writes to serverToClient + var server = new PipelineConnection( + input: clientToServer, + output: serverToClient); + + // "Client" reads from serverToClient and writes to clientToServer + var client = new PipelineConnection( + input: serverToClient, + output: clientToServer); + + return Tuple.Create((IPipelineConnection)server, (IPipelineConnection)client); + } + } +} diff --git a/src/Microsoft.AspNetCore.Sockets.Client/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.Sockets.Client/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..32dcddfc57 --- /dev/null +++ b/src/Microsoft.AspNetCore.Sockets.Client/Properties/AssemblyInfo.cs @@ -0,0 +1,11 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Reflection; +using System.Resources; + +[assembly: AssemblyMetadata("Serviceable", "True")] +[assembly: NeutralResourcesLanguage("en-us")] +[assembly: AssemblyCompany("Microsoft Corporation.")] +[assembly: AssemblyCopyright("© Microsoft Corporation. All rights reserved.")] +[assembly: AssemblyProduct("Microsoft ASP.NET Core")] \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Sockets.Client/Utils.cs b/src/Microsoft.AspNetCore.Sockets.Client/Utils.cs new file mode 100644 index 0000000000..034918bd18 --- /dev/null +++ b/src/Microsoft.AspNetCore.Sockets.Client/Utils.cs @@ -0,0 +1,34 @@ +using System; + +namespace Microsoft.AspNetCore.Sockets.Client +{ + internal static class Utils + { + public static Uri AppendPath(Uri url, string path) + { + var builder = new UriBuilder(url); + if (!builder.Path.EndsWith("/")) + { + builder.Path += "/"; + } + builder.Path += path; + return builder.Uri; + } + + internal static Uri AppendQueryString(Uri url, string qs) + { + if (string.IsNullOrEmpty(qs)) + { + return url; + } + + var builder = new UriBuilder(url); + if (!string.IsNullOrEmpty(builder.Query)) + { + builder.Query += "&"; + } + builder.Query += qs; + return builder.Uri; + } + } +} diff --git a/src/Microsoft.AspNetCore.Sockets.Client/project.json b/src/Microsoft.AspNetCore.Sockets.Client/project.json new file mode 100644 index 0000000000..5b71ba6926 --- /dev/null +++ b/src/Microsoft.AspNetCore.Sockets.Client/project.json @@ -0,0 +1,38 @@ +{ + "version": "1.0.0-*", + "description": "Client for ASP.NET Core SignalR", + + "packOptions": { + "repository": { + "type": "git", + "url": "git://github.com/aspnet/signalr" + }, + "tags": [ + "aspnetcore", + "signalr" + ] + }, + "buildOptions": { + "warningsAsErrors": true, + "keyFile": "../../tools/Key.snk", + "nowarn": [ + "CS1591" + ], + "xmlDoc": true + }, + + "dependencies": { + "NETStandard.Library": "1.6.1-*", + "System.IO.Pipelines": "0.1.0-*", + "Microsoft.Extensions.Logging.Abstractions": "1.2.0-*", + "Microsoft.Extensions.TaskCache.Sources": { + "type": "build", + "version": "1.2.0-*" + } + }, + + "frameworks": { + "netstandard1.3": {}, + "net451": {} + } +} diff --git a/src/Microsoft.AspNetCore.Sockets/ConnectionManager.cs b/src/Microsoft.AspNetCore.Sockets/ConnectionManager.cs index 3eceb0c6d3..9cd18da4bd 100644 --- a/src/Microsoft.AspNetCore.Sockets/ConnectionManager.cs +++ b/src/Microsoft.AspNetCore.Sockets/ConnectionManager.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Concurrent; +using System.Diagnostics; using System.IO.Pipelines; using System.Threading; diff --git a/src/Microsoft.Extensions.WebSockets.Internal/WebSocketCloseResult.cs b/src/Microsoft.Extensions.WebSockets.Internal/WebSocketCloseResult.cs index b44bd8e231..18d134ab8c 100644 --- a/src/Microsoft.Extensions.WebSockets.Internal/WebSocketCloseResult.cs +++ b/src/Microsoft.Extensions.WebSockets.Internal/WebSocketCloseResult.cs @@ -71,8 +71,8 @@ namespace Microsoft.Extensions.WebSockets.Internal buffer.WriteBigEndian((ushort)Status); if (!string.IsNullOrEmpty(Description)) { - buffer.Append(Description, TextEncoding.Utf8); + buffer.Append(Description, EncodingData.InvariantUtf8.TextEncoding); } } } -} \ No newline at end of file +} diff --git a/test/Microsoft.AspNetCore.SignalR.Client.FunctionalTests/HubConnectionTests.cs b/test/Microsoft.AspNetCore.SignalR.Client.FunctionalTests/HubConnectionTests.cs new file mode 100644 index 0000000000..52d1748c42 --- /dev/null +++ b/test/Microsoft.AspNetCore.SignalR.Client.FunctionalTests/HubConnectionTests.cs @@ -0,0 +1,130 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO.Pipelines; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Sockets.Client; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Microsoft.AspNetCore.SignalR.Client.FunctionalTests +{ + public class HubConnectionTests : IDisposable + { + private readonly TestServer _testServer; + + public HubConnectionTests() + { + var webHostBuilder = new WebHostBuilder(). + ConfigureServices(services => + { + services.AddSignalR(); + }) + .Configure(app => + { + app.UseSignalR(routes => + { + routes.MapHub("/hubs"); + }); + }); + _testServer = new TestServer(webHostBuilder); + } + + [Fact] + public async Task CheckFixedMessage() + { + var loggerFactory = new LoggerFactory(); + + using (var httpClient = _testServer.CreateClient()) + using (var pipelineFactory = new PipelineFactory()) + { + var transport = new LongPollingTransport(httpClient, loggerFactory); + using (var connection = await HubConnection.ConnectAsync(new Uri("http://test/hubs"), new JsonNetInvocationAdapter(), transport, httpClient, pipelineFactory, loggerFactory)) + { + //TODO: Get rid of this. This is to prevent "No channel" failures due to sends occuring before the first poll. + await Task.Delay(500); + var result = await connection.Invoke("HelloWorld"); + + Assert.Equal("Hello World!", result); + } + } + } + + [Fact] + public async Task CanSendAndReceiveMessage() + { + var loggerFactory = new LoggerFactory(); + const string originalMessage = "SignalR"; + + using (var httpClient = _testServer.CreateClient()) + using (var pipelineFactory = new PipelineFactory()) + { + var transport = new LongPollingTransport(httpClient, loggerFactory); + using (var connection = await HubConnection.ConnectAsync(new Uri("http://test/hubs"), new JsonNetInvocationAdapter(), transport, httpClient, pipelineFactory, loggerFactory)) + { + //TODO: Get rid of this. This is to prevent "No channel" failures due to sends occuring before the first poll. + await Task.Delay(500); + var result = await connection.Invoke("Echo", originalMessage); + + Assert.Equal(originalMessage, result); + } + } + } + + [Fact] + public async Task CanInvokeClientMethodFromServer() + { + var loggerFactory = new LoggerFactory(); + const string originalMessage = "SignalR"; + + using (var httpClient = _testServer.CreateClient()) + using (var pipelineFactory = new PipelineFactory()) + { + var transport = new LongPollingTransport(httpClient, loggerFactory); + using (var connection = await HubConnection.ConnectAsync(new Uri("http://test/hubs"), new JsonNetInvocationAdapter(), transport, httpClient, pipelineFactory, loggerFactory)) + { + var tcs = new TaskCompletionSource(); + connection.On("Echo", new[] { typeof(string) }, a => + { + tcs.TrySetResult((string)a[0]); + }); + + //TODO: Get rid of this. This is to prevent "No channel" failures due to sends occuring before the first poll. + await Task.Delay(500); + await connection.Invoke("CallEcho", originalMessage); + var completed = await Task.WhenAny(Task.Delay(2000), tcs.Task); + Assert.True(completed == tcs.Task, "Receive timed out!"); + Assert.Equal(originalMessage, tcs.Task.Result); + } + } + } + + public void Dispose() + { + _testServer.Dispose(); + } + + public class TestHub : Hub + { + public string HelloWorld() + { + return "Hello World!"; + } + + public string Echo(string message) + { + return message; + } + + public async Task CallEcho(string message) + { + await Clients.Client(Context.ConnectionId).InvokeAsync("Echo", message); + } + } + } +} diff --git a/test/Microsoft.AspNetCore.SignalR.Client.FunctionalTests/Microsoft.AspNetCore.SignalR.Client.FunctionalTests.xproj b/test/Microsoft.AspNetCore.SignalR.Client.FunctionalTests/Microsoft.AspNetCore.SignalR.Client.FunctionalTests.xproj new file mode 100644 index 0000000000..424f18bad9 --- /dev/null +++ b/test/Microsoft.AspNetCore.SignalR.Client.FunctionalTests/Microsoft.AspNetCore.SignalR.Client.FunctionalTests.xproj @@ -0,0 +1,20 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + 455b68d2-c5b6-4bf4-a685-964b07afaaf8 + .\obj + .\bin\ + + + 2.0 + + + + + + \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.SignalR.Client.FunctionalTests/Properties/AssemblyInfo.cs b/test/Microsoft.AspNetCore.SignalR.Client.FunctionalTests/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..9c5704d6eb --- /dev/null +++ b/test/Microsoft.AspNetCore.SignalR.Client.FunctionalTests/Properties/AssemblyInfo.cs @@ -0,0 +1,19 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Microsoft.AspNetCore.SignalR.Client.FunctionalTests")] +[assembly: AssemblyTrademark("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("455b68d2-c5b6-4bf4-a685-964b07afaaf8")] diff --git a/test/Microsoft.AspNetCore.SignalR.Client.FunctionalTests/project.json b/test/Microsoft.AspNetCore.SignalR.Client.FunctionalTests/project.json new file mode 100644 index 0000000000..6b0fec3e9b --- /dev/null +++ b/test/Microsoft.AspNetCore.SignalR.Client.FunctionalTests/project.json @@ -0,0 +1,29 @@ +{ + "buildOptions": { + "warningsAsErrors": true + }, + + "dependencies": { + "dotnet-test-xunit": "2.2.0-*", + "Microsoft.AspNetCore.Http": "1.2.0-*", + "Microsoft.AspNetCore.Sockets": "0.1.0-*", + "Microsoft.AspNetCore.Hosting": "1.2.0-*", + "Microsoft.AspNetCore.Server.Kestrel": "1.2.0-*", + "Microsoft.AspNetCore.SignalR": "1.0.0-*", + "Microsoft.AspNetCore.SignalR.Client": "1.0.0-*", + "xunit": "2.2.0-*", + "Microsoft.AspNetCore.TestHost": "1.2.0-*" + }, + "frameworks": { + "netcoreapp1.1": { + "dependencies": { + "Microsoft.NETCore.App": { + "version": "1.1.0-*", + "type": "platform" + } + } + }, + "net451": {} + }, + "testRunner": "xunit" +} diff --git a/test/Microsoft.AspNetCore.Sockets.Client.Tests/Microsoft.AspNetCore.Sockets.Client.Tests.xproj b/test/Microsoft.AspNetCore.Sockets.Client.Tests/Microsoft.AspNetCore.Sockets.Client.Tests.xproj new file mode 100644 index 0000000000..0291384bd2 --- /dev/null +++ b/test/Microsoft.AspNetCore.Sockets.Client.Tests/Microsoft.AspNetCore.Sockets.Client.Tests.xproj @@ -0,0 +1,19 @@ + + + + 14.0.25420 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + b19c15a5-f5ea-4ca7-936b-1166abee35c4 + Microsoft.AspNetCore.Sockets.Client.Tests + .\obj + .\bin\ + + + + 2.0 + + + \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Sockets.Client.Tests/Properties/AssemblyInfo.cs b/test/Microsoft.AspNetCore.Sockets.Client.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..6e3a1648a1 --- /dev/null +++ b/test/Microsoft.AspNetCore.Sockets.Client.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,19 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Microsoft.AspNetCore.SignalR.Client.Tests")] +[assembly: AssemblyTrademark("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("40f805d4-068c-4def-aa51-77419b9c70ac")] diff --git a/test/Microsoft.AspNetCore.Sockets.Client.Tests/project.json b/test/Microsoft.AspNetCore.Sockets.Client.Tests/project.json new file mode 100644 index 0000000000..85dab3a244 --- /dev/null +++ b/test/Microsoft.AspNetCore.Sockets.Client.Tests/project.json @@ -0,0 +1,22 @@ +{ + "buildOptions": { + "warningsAsErrors": true + }, + + "dependencies": { + "dotnet-test-xunit": "2.2.0-*", + "xunit": "2.2.0-*" + }, + "frameworks": { + "netcoreapp1.1": { + "dependencies": { + "Microsoft.NETCore.App": { + "version": "1.1.0-*", + "type": "platform" + } + } + }, + "net451": {} + }, + "testRunner": "xunit" +}