diff --git a/samples/ClientSample/RawSample.cs b/samples/ClientSample/RawSample.cs index 9f4ca35aba..e7a1124087 100644 --- a/samples/ClientSample/RawSample.cs +++ b/samples/ClientSample/RawSample.cs @@ -2,13 +2,15 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Diagnostics; +using System.Linq; using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Sockets; using Microsoft.AspNetCore.Sockets.Client; using Microsoft.Extensions.Logging; -using Microsoft.AspNetCore.Sockets; namespace ClientSample { @@ -16,6 +18,14 @@ namespace ClientSample { public static async Task MainAsync(string[] args) { + if(args.Contains("--debug")) + { + Console.WriteLine($"Ready for debugger to attach. Process ID: {Process.GetCurrentProcess().Id}"); + Console.Write("Press ENTER to Continue"); + Console.ReadLine(); + args = args.Except(new[] { "--debug" }).ToArray(); + } + var baseUrl = "http://localhost:5000/chat"; if (args.Length > 0) { diff --git a/samples/SocketsSample/SocketsSample.csproj b/samples/SocketsSample/SocketsSample.csproj index 41d5720908..67ca283a99 100644 --- a/samples/SocketsSample/SocketsSample.csproj +++ b/samples/SocketsSample/SocketsSample.csproj @@ -24,4 +24,4 @@ DestinationFolder="$(MSBuildThisFileDirectory)wwwroot\lib\signalr-client" /> - \ No newline at end of file + diff --git a/samples/SocketsSample/wwwroot/index.html b/samples/SocketsSample/wwwroot/index.html index 01220c3cd2..3fa89d97e0 100644 --- a/samples/SocketsSample/wwwroot/index.html +++ b/samples/SocketsSample/wwwroot/index.html @@ -7,15 +7,15 @@

ASP.NET Sockets

ASP.NET SignalR (Hubs)

diff --git a/specs/TransportProtocols.md b/specs/TransportProtocols.md index 2994e9718c..de0c07ff0a 100644 --- a/specs/TransportProtocols.md +++ b/specs/TransportProtocols.md @@ -124,7 +124,7 @@ If the `connectionId` parameter is missing, a `400 Bad Request` response is retu ### Text-based encoding (`supportsBinary` = `false` or not present) -The body will be formatted as below and encoded in UTF-8. The `Content-Type` response header is set to `application/vnd.microsoft.aspnet.endpoint-messages.v1+text`. Identifiers in square brackets `[]` indicate fields defined below, and parenthesis `()` indicate grouping. +The body will be formatted as below and encoded in UTF-8. The `Content-Type` response header is set to `application/vnd.microsoft.aspnetcore.endpoint-messages.v1+text`. Identifiers in square brackets `[]` indicate fields defined below, and parenthesis `()` indicate grouping. ``` T([Length]:[Type]:[Body];)([Length]:[Type]:[Body];)... continues until end of the response body ... @@ -164,7 +164,7 @@ This transport will buffer incomplete frames sent by the server until the full m In JavaScript/Browser clients, this encoding requires XHR2 (or similar HTTP request functionality which allows binary data) and TypedArray support. -The body is encoded as follows. The `Content-Type` response header is set to `application/vnd.microsoft.aspnet.endpoint-messages.v1+binary`. Identifiers in square brackets `[]` indicate fields defined below, and parenthesis `()` indicate grouping. Other symbols indicate ASCII-encoded text in the stream +The body is encoded as follows. The `Content-Type` response header is set to `application/vnd.microsoft.aspnetcore.endpoint-messages.v1+binary`. Identifiers in square brackets `[]` indicate fields defined below, and parenthesis `()` indicate grouping. Other symbols indicate ASCII-encoded text in the stream ``` B([Length][Type][Body])([Length][Type][Fin][Body])... continues until end of the response body ... diff --git a/src/Microsoft.AspNetCore.SignalR.Client.TS/Formatters.ts b/src/Microsoft.AspNetCore.SignalR.Client.TS/Formatters.ts new file mode 100644 index 0000000000..a079e03088 --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.Client.TS/Formatters.ts @@ -0,0 +1,130 @@ +import { Message, MessageType } from './Message'; + +let knownTypes = { + "T": MessageType.Text, + "B": MessageType.Binary, + "C": MessageType.Close, + "E": MessageType.Error +}; + +function splitAt(input: string, searchString: string, position: number): [string, number] { + let index = input.indexOf(searchString, position); + if (index < 0) { + return [input.substr(position), input.length]; + } + let left = input.substring(position, index); + return [left, index + searchString.length]; +} + +export namespace ServerSentEventsFormat { + export function parse(input: string): Message { + // The SSE protocol is pretty simple. We just look at the first line for the type, and then process the remainder. + // Binary messages require Base64-decoding and ArrayBuffer support, just like in the other formats below + + if (input.length == 0) { + throw "Message is missing header"; + } + + let [header, offset] = splitAt(input, "\n", 0); + let payload = input.substring(offset); + + // Just in case the header used CRLF as the line separator, carve it off + if (header.endsWith('\r')) { + header = header.substr(0, header.length - 1); + } + + // Parse the header + var messageType = knownTypes[header]; + if (messageType === undefined) { + throw "Unknown type value: '" + header + "'"; + } + + if (messageType == MessageType.Binary) { + // We need to decode and put in an ArrayBuffer. Throw for now + // This will require our own Base64-decoder because the browser + // built-in one only decodes to strings and throws if invalid UTF-8 + // characters are found. + throw "TODO: Support for binary messages"; + } + + // Create the message + return new Message(messageType, payload); + } +} + +export namespace TextMessageFormat { + const InvalidPayloadError = new Error("Invalid text message payload"); + const LengthRegex = /^[0-9]+$/; + + function hasSpace(input: string, offset: number, length: number): boolean { + let requiredLength = offset + length; + return input.length >= requiredLength; + } + + function parseMessage(input: string, position: number): [number, Message] { + var offset = position; + + // Read the length + var [lenStr, offset] = splitAt(input, ":", offset); + + // parseInt is too leniant, we need a strict check to see if the string is an int + + if (!LengthRegex.test(lenStr)) { + throw `Invalid length: '${lenStr}'`; + } + let length = Number.parseInt(lenStr); + + // Required space is: 3 (type flag, ":", ";") + length (payload len) + if (!hasSpace(input, offset, 3 + length)) { + throw "Message is incomplete"; + } + + // Read the type + var [typeStr, offset] = splitAt(input, ":", offset); + + // Parse the type + var messageType = knownTypes[typeStr]; + if (messageType === undefined) { + throw "Unknown type value: '" + typeStr + "'"; + } + + // Read the payload + var payload = input.substr(offset, length); + offset += length; + + // Verify the final trailing character + if (input[offset] != ';') { + throw "Message missing trailer character"; + } + offset += 1; + + if (messageType == MessageType.Binary) { + // We need to decode and put in an ArrayBuffer. Throw for now + // This will require our own Base64-decoder because the browser + // built-in one only decodes to strings and throws if invalid UTF-8 + // characters are found. + throw "TODO: Support for binary messages"; + } + + return [offset, new Message(messageType, payload)]; + } + + export function parse(input: string): Message[] { + if (input.length == 0) { + return [] + } + + if (input[0] != 'T') { + throw `Unsupported message format: '${input[0]}'`; + } + + let messages = []; + var offset = 1; + while (offset < input.length) { + let message; + [offset, message] = parseMessage(input, offset); + messages.push(message); + } + return messages; + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.SignalR.Client.TS/Message.ts b/src/Microsoft.AspNetCore.SignalR.Client.TS/Message.ts new file mode 100644 index 0000000000..32fe97f862 --- /dev/null +++ b/src/Microsoft.AspNetCore.SignalR.Client.TS/Message.ts @@ -0,0 +1,16 @@ +export enum MessageType { + Text, + Binary, + Close, + Error +} + +export class Message { + public type: MessageType; + public content: ArrayBuffer | string; + + constructor(type: MessageType, content: ArrayBuffer | string) { + this.type = type; + this.content = content; + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.SignalR.Client.TS/Transports.ts b/src/Microsoft.AspNetCore.SignalR.Client.TS/Transports.ts index 56b2d767e3..6635867109 100644 --- a/src/Microsoft.AspNetCore.SignalR.Client.TS/Transports.ts +++ b/src/Microsoft.AspNetCore.SignalR.Client.TS/Transports.ts @@ -1,5 +1,6 @@ import { DataReceived, ErrorHandler } from "./Common" import { IHttpClient } from "./HttpClient" +import * as Formatters from "./Formatters"; export interface ITransport { connect(url: string, queryString: string): Promise; @@ -73,7 +74,7 @@ export class ServerSentEventsTransport implements ITransport { private queryString: string; private httpClient: IHttpClient; - constructor(httpClient :IHttpClient) { + constructor(httpClient: IHttpClient) { this.httpClient = httpClient; } @@ -92,7 +93,19 @@ export class ServerSentEventsTransport implements ITransport { try { eventSource.onmessage = (e: MessageEvent) => { if (this.onDataReceived) { - this.onDataReceived(e.data); + // Parse the message + let message; + try { + message = Formatters.ServerSentEventsFormat.parse(e.data); + } catch (error) { + if (this.onError) { + this.onError(error); + } + return; + } + + // TODO: pass the whole message object along + this.onDataReceived(message.content); } }; @@ -138,7 +151,7 @@ export class LongPollingTransport implements ITransport { private pollXhr: XMLHttpRequest; private shouldPoll: boolean; - constructor(httpClient :IHttpClient) { + constructor(httpClient: IHttpClient) { this.httpClient = httpClient; } @@ -160,7 +173,21 @@ export class LongPollingTransport implements ITransport { pollXhr.onload = () => { if (pollXhr.status == 200) { if (this.onDataReceived) { - this.onDataReceived(pollXhr.response); + // Parse the messages + let messages; + try { + messages = Formatters.TextMessageFormat.parse(pollXhr.response); + } catch (error) { + if (this.onError) { + this.onError(error); + } + return; + } + + messages.forEach((message) => { + // TODO: pass the whole message object along + this.onDataReceived(message.content) + }); } this.poll(url); } diff --git a/src/Microsoft.AspNetCore.Sockets.Client/LongPollingTransport.cs b/src/Microsoft.AspNetCore.Sockets.Client/LongPollingTransport.cs index 5957db308e..5fe7d57cfd 100644 --- a/src/Microsoft.AspNetCore.Sockets.Client/LongPollingTransport.cs +++ b/src/Microsoft.AspNetCore.Sockets.Client/LongPollingTransport.cs @@ -2,14 +2,15 @@ // 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.IO; -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.AspNetCore.Sockets.Client.Internal; +using Microsoft.AspNetCore.Sockets.Formatters; using Microsoft.Extensions.Internal; using Microsoft.Extensions.Logging; @@ -84,15 +85,17 @@ namespace Microsoft.AspNetCore.Sockets.Client } else { - var ms = new MemoryStream(); - await response.Content.CopyToAsync(ms); - var message = new Message(ms.ToArray(), MessageType.Text); + // Read the whole payload + var payload = await response.Content.ReadAsByteArrayAsync(); - while (await _application.Output.WaitToWriteAsync(cancellationToken)) + foreach (var message in ReadMessages(payload)) { - if (_application.Output.TryWrite(message)) + while (!_application.Output.TryWrite(message)) { - break; + if (cancellationToken.IsCancellationRequested || !await _application.Output.WaitToWriteAsync(cancellationToken)) + { + return; + } } } } @@ -114,6 +117,28 @@ namespace Microsoft.AspNetCore.Sockets.Client } } + private IEnumerable ReadMessages(ReadOnlySpan payload) + { + if (payload.Length == 0) + { + yield break; + } + + var messageFormat = MessageFormatter.GetFormat(payload[0]); + payload = payload.Slice(1); + + while (payload.Length > 0) + { + if (!MessageFormatter.TryParseMessage(payload, messageFormat, out var message, out var consumed)) + { + throw new InvalidDataException("Invalid message payload from server"); + } + + payload = payload.Slice(consumed); + yield return message; + } + } + private async Task SendMessages(Uri sendUrl, CancellationToken cancellationToken) { try diff --git a/src/Microsoft.AspNetCore.Sockets.Common/Formatters/MessageFormatter.cs b/src/Microsoft.AspNetCore.Sockets.Common/Formatters/MessageFormatter.cs index cb75eafef6..c8a4ee6229 100644 --- a/src/Microsoft.AspNetCore.Sockets.Common/Formatters/MessageFormatter.cs +++ b/src/Microsoft.AspNetCore.Sockets.Common/Formatters/MessageFormatter.cs @@ -7,14 +7,20 @@ namespace Microsoft.AspNetCore.Sockets.Formatters { public static class MessageFormatter { + public static readonly byte TextFormatIndicator = (byte)'T'; + public static readonly byte BinaryFormatIndicator = (byte)'B'; + + public static readonly string TextContentType = "application/vnd.microsoft.aspnetcore.endpoint-messages.v1+text"; + public static readonly string BinaryContentType = "application/vnd.microsoft.aspnetcore.endpoint-messages.v1+binary"; + public static bool TryFormatMessage(Message message, Span buffer, MessageFormat format, out int bytesWritten) { if (!message.EndOfMessage) { - // This is a truely exceptional condition since we EXPECT callers to have already + // This is truly an exceptional condition since we EXPECT callers to have already // buffered incomplete messages and synthesized the correct, complete message before // giving it to us. Hence we throw, instead of returning false. - throw new InvalidOperationException("Cannot format message where endOfMessage is false using this format"); + throw new ArgumentException("Cannot format message where endOfMessage is false using this format", nameof(message)); } return format == MessageFormat.Text ? @@ -28,5 +34,46 @@ namespace Microsoft.AspNetCore.Sockets.Formatters TextMessageFormatter.TryParseMessage(buffer, out message, out bytesConsumed) : BinaryMessageFormatter.TryParseMessage(buffer, out message, out bytesConsumed); } + + public static string GetContentType(MessageFormat messageFormat) + { + switch (messageFormat) + { + case MessageFormat.Text: return TextContentType; + case MessageFormat.Binary: return BinaryContentType; + default: throw new ArgumentException($"Invalid message format: {messageFormat}", nameof(messageFormat)); + } + } + + public static byte GetFormatIndicator(MessageFormat messageFormat) + { + switch (messageFormat) + { + case MessageFormat.Text: return TextFormatIndicator; + case MessageFormat.Binary: return BinaryFormatIndicator; + default: throw new ArgumentException($"Invalid message format: {messageFormat}", nameof(messageFormat)); + } + } + + public static MessageFormat GetFormat(byte formatIndicator) + { + // Can't use switch because our "constants" are not consts, they're "static readonly" (which is good, because they are public) + if (formatIndicator == TextFormatIndicator) + { + return MessageFormat.Text; + } + + if (formatIndicator == BinaryFormatIndicator) + { + return MessageFormat.Binary; + } + + throw new ArgumentException($"Invalid message format: 0x{formatIndicator:X}", nameof(formatIndicator)); + } + + public static bool TryParseMessage(ReadOnlySpan payload, object messageFormat) + { + throw new NotImplementedException(); + } } } diff --git a/src/Microsoft.AspNetCore.Sockets/Transports/LongPollingTransport.cs b/src/Microsoft.AspNetCore.Sockets/Transports/LongPollingTransport.cs index 4fe57bb88a..58bd2c4f0b 100644 --- a/src/Microsoft.AspNetCore.Sockets/Transports/LongPollingTransport.cs +++ b/src/Microsoft.AspNetCore.Sockets/Transports/LongPollingTransport.cs @@ -7,12 +7,16 @@ using System.Threading; using System.Threading.Tasks; using System.Threading.Tasks.Channels; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Sockets.Formatters; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Sockets.Transports { public class LongPollingTransport : IHttpTransport { + // REVIEW: This size? + internal const int MaxBufferSize = 4096; + public static readonly string Name = "longPolling"; private readonly ReadableChannel _application; private readonly ILogger _logger; @@ -34,13 +38,44 @@ namespace Microsoft.AspNetCore.Sockets.Transports return; } - Message message; - if (_application.TryRead(out message)) + // TODO: Add support for binary protocol + var messageFormat = MessageFormat.Text; + context.Response.ContentType = MessageFormatter.GetContentType(messageFormat); + + var writer = context.Response.Body.AsPipelineWriter(); + var alloc = writer.Alloc(minimumSize: 1); + alloc.WriteBigEndian(MessageFormatter.GetFormatIndicator(messageFormat)); + + while (_application.TryRead(out var message)) { + var buffer = alloc.Memory.Span; + _logger.LogDebug("Writing {0} byte message to response", message.Payload.Length); - context.Response.ContentLength = message.Payload.Length; - await context.Response.Body.WriteAsync(message.Payload, 0, message.Payload.Length); + + // Try to format the message + if (!MessageFormatter.TryFormatMessage(message, buffer, messageFormat, out var written)) + { + // We need to expand the buffer + // REVIEW: I'm not sure I fully understand the "right" pattern here... + alloc.Ensure(MaxBufferSize); + buffer = alloc.Memory.Span; + + // Try one more time + if (!MessageFormatter.TryFormatMessage(message, buffer, messageFormat, out written)) + { + // Message too large + throw new InvalidOperationException($"Message is too large to write. Maximum allowed message size is: {MaxBufferSize}"); + } + } + + // Update the buffer and commit + alloc.Advance(written); + alloc.Commit(); + alloc = writer.Alloc(); + buffer = alloc.Memory.Span; } + + await alloc.FlushAsync(); } catch (OperationCanceledException) { diff --git a/src/Microsoft.AspNetCore.Sockets/Transports/ServerSentEventsTransport.cs b/src/Microsoft.AspNetCore.Sockets/Transports/ServerSentEventsTransport.cs index 0d3c7a13ae..7ebacb6e56 100644 --- a/src/Microsoft.AspNetCore.Sockets/Transports/ServerSentEventsTransport.cs +++ b/src/Microsoft.AspNetCore.Sockets/Transports/ServerSentEventsTransport.cs @@ -2,10 +2,12 @@ // 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; using System.Threading.Tasks; using System.Threading.Tasks.Channels; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Sockets.Formatters; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Sockets.Transports @@ -27,16 +29,37 @@ namespace Microsoft.AspNetCore.Sockets.Transports context.Response.ContentType = "text/event-stream"; context.Response.Headers["Cache-Control"] = "no-cache"; context.Response.Headers["Content-Encoding"] = "identity"; + await context.Response.Body.FlushAsync(); + var pipe = context.Response.Body.AsPipelineWriter(); + try { while (await _application.WaitToReadAsync(token)) { + var buffer = pipe.Alloc(); while (_application.TryRead(out var message)) { - await Send(context, message); + if (!ServerSentEventsMessageFormatter.TryFormatMessage(message, buffer.Memory.Span, out var written)) + { + // We need to expand the buffer + // REVIEW: I'm not sure I fully understand the "right" pattern here... + buffer.Ensure(LongPollingTransport.MaxBufferSize); + + // Try one more time + if (!ServerSentEventsMessageFormatter.TryFormatMessage(message, buffer.Memory.Span, out written)) + { + // Message too large + throw new InvalidOperationException($"Message is too large to write. Maximum allowed message size is: {LongPollingTransport.MaxBufferSize}"); + } + } + buffer.Advance(written); + buffer.Commit(); + buffer = pipe.Alloc(); } + + await buffer.FlushAsync(); } } catch (OperationCanceledException) @@ -44,26 +67,5 @@ namespace Microsoft.AspNetCore.Sockets.Transports // Closed connection } } - - private async Task Send(HttpContext context, Message message) - { - // TODO: Pooled buffers - // 8 = 6(data: ) + 2 (\n\n) - _logger.LogDebug("Sending {0} byte message to Server-Sent Events client", message.Payload.Length); - var buffer = new byte[8 + message.Payload.Length]; - var at = 0; - buffer[at++] = (byte)'d'; - buffer[at++] = (byte)'a'; - buffer[at++] = (byte)'t'; - buffer[at++] = (byte)'a'; - buffer[at++] = (byte)':'; - buffer[at++] = (byte)' '; - message.Payload.CopyTo(new Span(buffer, at, message.Payload.Length)); - at += message.Payload.Length; - buffer[at++] = (byte)'\n'; - buffer[at++] = (byte)'\n'; - await context.Response.Body.WriteAsync(buffer, 0, at); - await context.Response.Body.FlushAsync(); - } } } diff --git a/test/Microsoft.AspNetCore.Client.SignalR.TS.Tests/Formatters.spec.ts b/test/Microsoft.AspNetCore.Client.SignalR.TS.Tests/Formatters.spec.ts new file mode 100644 index 0000000000..be8bbf200f --- /dev/null +++ b/test/Microsoft.AspNetCore.Client.SignalR.TS.Tests/Formatters.spec.ts @@ -0,0 +1,65 @@ +import { TextMessageFormat, ServerSentEventsFormat } from "../../src/Microsoft.AspNetCore.SignalR.Client.TS/Formatters" +import { Message, MessageType } from "../../src/Microsoft.AspNetCore.SignalR.Client.TS/Message"; + +describe("Text Message Formatter", () => { + it("should return empty array on empty input", () => { + let messages = TextMessageFormat.parse(""); + expect(messages).toEqual([]); + }); + ([ + ["T0:T:;", [new Message(MessageType.Text, "")]], + ["T0:C:;", [new Message(MessageType.Close, "")]], + ["T0:E:;", [new Message(MessageType.Error, "")]], + ["T5:T:Hello;", [new Message(MessageType.Text, "Hello")]], + ["T5:T:Hello;5:C:World;5:E:Error;", [new Message(MessageType.Text, "Hello"), new Message(MessageType.Close, "World"), new Message(MessageType.Error, "Error")]], + ] as [[string, Message[]]]).forEach(([payload, expected_messages]) => { + it(`should parse '${payload}' correctly`, () => { + let messages = TextMessageFormat.parse(payload); + expect(messages).toEqual(expected_messages); + }) + }); + + ([ + ["TABC", "Invalid length: 'ABC'"], + ["X1:T:A", "Unsupported message format: 'X'"], + ["T1:T:A;12ab34:", "Invalid length: '12ab34'"], + ["T1:T:A;1:asdf:", "Unknown type value: 'asdf'"], + ["T1:T:A;1::", "Message is incomplete"], + ["T1:T:A;1:AB:", "Message is incomplete"], + ["T1:T:A;5:T:A", "Message is incomplete"], + ["T1:T:A;5:T:AB", "Message is incomplete"], + ["T1:T:A;5:T:ABCDE", "Message is incomplete"], + ["T1:T:A;5:X:ABCDE", "Message is incomplete"], + ["T1:T:A;5:T:ABCDEF", "Message missing trailer character"], + ] as [[string, string]]).forEach(([payload, expected_error]) => { + it(`should fail to parse '${payload}'`, () => { + expect(() => TextMessageFormat.parse(payload)).toThrow(expected_error); + }); + }); +}); + +describe("Server-Sent Events Formatter", () => { + ([ + ["", "Message is missing header"], + ["A", "Unknown type value: 'A'"], + ["BOO\r\nBlarg", "Unknown type value: 'BOO'"] + ] as [string, string][]).forEach(([payload, expected_error]) => { + it(`should fail to parse '${payload}`, () => { + expect(() => ServerSentEventsFormat.parse(payload)).toThrow(expected_error); + }); + }); + + ([ + ["T\r\nTest", new Message(MessageType.Text, "Test")], + ["C\r\nTest", new Message(MessageType.Close, "Test")], + ["E\r\nTest", new Message(MessageType.Error, "Test")], + ["T", new Message(MessageType.Text, "")], + ["T\r\n", new Message(MessageType.Text, "")], + ["T\r\nFoo\r\nBar", new Message(MessageType.Text, "Foo\r\nBar")] + ] as [string, Message][]).forEach(([payload, expected_message]) => { + it(`should parse '${payload}' correctly`, () => { + let message = ServerSentEventsFormat.parse(payload); + expect(message).toEqual(expected_message); + }); + }); +}); \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Sockets.Client.Tests/ConnectionTests.cs b/test/Microsoft.AspNetCore.Sockets.Client.Tests/ConnectionTests.cs index 80b4c4863a..0d47df2017 100644 --- a/test/Microsoft.AspNetCore.Sockets.Client.Tests/ConnectionTests.cs +++ b/test/Microsoft.AspNetCore.Sockets.Client.Tests/ConnectionTests.cs @@ -364,7 +364,7 @@ namespace Microsoft.AspNetCore.Sockets.Client.Tests var content = string.Empty; if (request.RequestUri.AbsolutePath.EndsWith("/poll")) { - content = "42"; + content = "T2:T:42;"; } return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(content) }; }); diff --git a/test/Microsoft.AspNetCore.Sockets.Common.Tests/Formatters/BinaryMessageFormatterTests.cs b/test/Microsoft.AspNetCore.Sockets.Common.Tests/Formatters/BinaryMessageFormatterTests.cs index 9a6f4ceeb2..07830a33c0 100644 --- a/test/Microsoft.AspNetCore.Sockets.Common.Tests/Formatters/BinaryMessageFormatterTests.cs +++ b/test/Microsoft.AspNetCore.Sockets.Common.Tests/Formatters/BinaryMessageFormatterTests.cs @@ -88,9 +88,10 @@ namespace Microsoft.AspNetCore.Sockets.Formatters.Tests public void WriteInvalidMessages() { var message = new Message(new byte[0], MessageType.Binary, endOfMessage: false); - var ex = Assert.Throws(() => + var ex = Assert.Throws(() => MessageFormatter.TryFormatMessage(message, Span.Empty, MessageFormat.Binary, out var written)); - Assert.Equal("Cannot format message where endOfMessage is false using this format", ex.Message); + Assert.Equal($"Cannot format message where endOfMessage is false using this format{Environment.NewLine}Parameter name: message", ex.Message); + Assert.Equal("message", ex.ParamName); } [Theory] diff --git a/test/Microsoft.AspNetCore.Sockets.Common.Tests/Formatters/TextMessageFormatterTests.cs b/test/Microsoft.AspNetCore.Sockets.Common.Tests/Formatters/TextMessageFormatterTests.cs index 0f67c4c89f..becc3479d6 100644 --- a/test/Microsoft.AspNetCore.Sockets.Common.Tests/Formatters/TextMessageFormatterTests.cs +++ b/test/Microsoft.AspNetCore.Sockets.Common.Tests/Formatters/TextMessageFormatterTests.cs @@ -75,9 +75,10 @@ namespace Microsoft.AspNetCore.Sockets.Formatters.Tests public void WriteInvalidMessages() { var message = new Message(new byte[0], MessageType.Binary, endOfMessage: false); - var ex = Assert.Throws(() => + var ex = Assert.Throws(() => MessageFormatter.TryFormatMessage(message, Span.Empty, MessageFormat.Text, out var written)); - Assert.Equal("Cannot format message where endOfMessage is false using this format", ex.Message); + Assert.Equal($"Cannot format message where endOfMessage is false using this format{Environment.NewLine}Parameter name: message", ex.Message); + Assert.Equal("message", ex.ParamName); } [Theory] diff --git a/test/Microsoft.AspNetCore.Sockets.Tests/LongPollingTests.cs b/test/Microsoft.AspNetCore.Sockets.Tests/LongPollingTests.cs index 119f539ebc..a5830dbfa3 100644 --- a/test/Microsoft.AspNetCore.Sockets.Tests/LongPollingTests.cs +++ b/test/Microsoft.AspNetCore.Sockets.Tests/LongPollingTests.cs @@ -48,7 +48,37 @@ namespace Microsoft.AspNetCore.Sockets.Tests await poll.ProcessRequestAsync(context, context.RequestAborted); Assert.Equal(200, context.Response.StatusCode); - Assert.Equal("Hello World", Encoding.UTF8.GetString(ms.ToArray())); + Assert.Equal("T11:T:Hello World;", Encoding.UTF8.GetString(ms.ToArray())); + } + + [Fact] + public async Task MultipleFramesSentAsSingleResponse() + { + var channel = Channel.CreateUnbounded(); + var context = new DefaultHttpContext(); + var poll = new LongPollingTransport(channel, new LoggerFactory()); + var ms = new MemoryStream(); + context.Response.Body = ms; + + await channel.Out.WriteAsync(new Message( + Encoding.UTF8.GetBytes("Hello"), + MessageType.Text, + endOfMessage: true)); + await channel.Out.WriteAsync(new Message( + Encoding.UTF8.GetBytes(" "), + MessageType.Text, + endOfMessage: true)); + await channel.Out.WriteAsync(new Message( + Encoding.UTF8.GetBytes("World"), + MessageType.Text, + endOfMessage: true)); + + Assert.True(channel.Out.TryComplete()); + + await poll.ProcessRequestAsync(context, context.RequestAborted); + + Assert.Equal(200, context.Response.StatusCode); + Assert.Equal("T5:T:Hello;1:T: ;5:T:World;", Encoding.UTF8.GetString(ms.ToArray())); } } } diff --git a/test/Microsoft.AspNetCore.Sockets.Tests/ServerSentEventsTests.cs b/test/Microsoft.AspNetCore.Sockets.Tests/ServerSentEventsTests.cs index f66adaf308..ae4a0b99b3 100644 --- a/test/Microsoft.AspNetCore.Sockets.Tests/ServerSentEventsTests.cs +++ b/test/Microsoft.AspNetCore.Sockets.Tests/ServerSentEventsTests.cs @@ -30,8 +30,11 @@ namespace Microsoft.AspNetCore.Sockets.Tests Assert.Equal("no-cache", context.Response.Headers["Cache-Control"]); } - [Fact] - public async Task SSEAddsAppropriateFraming() + [Theory] + [InlineData("Hello World", "data: T\r\ndata: Hello World\r\n\r\n")] + [InlineData("Hello\nWorld", "data: T\r\ndata: Hello\r\ndata: World\r\n\r\n")] + [InlineData("Hello\r\nWorld", "data: T\r\ndata: Hello\r\ndata: World\r\n\r\n")] + public async Task SSEAddsAppropriateFraming(string message, string expected) { var channel = Channel.CreateUnbounded(); var context = new DefaultHttpContext(); @@ -40,7 +43,7 @@ namespace Microsoft.AspNetCore.Sockets.Tests context.Response.Body = ms; await channel.Out.WriteAsync(new Message( - Encoding.UTF8.GetBytes("Hello World"), + Encoding.UTF8.GetBytes(message), MessageType.Text, endOfMessage: true)); @@ -48,7 +51,6 @@ namespace Microsoft.AspNetCore.Sockets.Tests await sse.ProcessRequestAsync(context, context.RequestAborted); - var expected = "data: Hello World\n\n"; Assert.Equal(expected, Encoding.UTF8.GetString(ms.ToArray())); } }