openai/openai-dotnet
Publicmirrored from https://github.com/openai/openai-dotnetAvailable
src/Utility/System.Net.ServerSentEvents.cs
623lines · modeblame
674e0f77Stephen Toub2 years ago | 1 | // Licensed to the .NET Foundation under one or more agreements. |
| 2 | // The .NET Foundation licenses this file to you under the MIT license. | |
| 3 | | |
| 4 | // This file contains a source copy of: | |
| 5 | // https://github.com/dotnet/runtime/tree/2bd15868f12ace7cee9999af61d5c130b2603f04/src/libraries/System.Net.ServerSentEvents/src/System/Net/ServerSentEvents | |
| 6 | // Once the System.Net.ServerSentEvents package is available, this file should be removed and replaced with a package reference. | |
| 7 | // | |
| 8 | // The only changes made to this code from the original are: | |
| 9 | // - Enabled nullable reference types at file scope, and use a few null suppression operators to work around the lack of [NotNull] | |
| 10 | // - Put into a single file for ease of management (it should not be edited in this repo). | |
| 11 | // - Changed public types to be internal. | |
| 12 | // - Removed a use of a [NotNull] attribute to assist in netstandard2.0 compilation. | |
| 13 | // - Replaced a reference to a .resx string with an inline constant. | |
| 14 | | |
| 15 | #nullable enable | |
| 16 | | |
| 17 | using System.Buffers; | |
| 18 | using System.Collections.Generic; | |
| 19 | using System.Diagnostics; | |
| 20 | using System.Globalization; | |
| 21 | using System.IO; | |
| 22 | using System.Runtime.CompilerServices; | |
| 23 | using System.Text; | |
| 24 | using System.Threading.Tasks; | |
| 25 | using System.Threading; | |
| 26 | | |
| 27 | namespace System.Net.ServerSentEvents | |
| 28 | { | |
| 29 | /// <summary>Represents a server-sent event.</summary> | |
| 30 | /// <typeparam name="T">Specifies the type of data payload in the event.</typeparam> | |
| 31 | internal readonly struct SseItem<T> | |
| 32 | { | |
| 33 | /// <summary>Initializes the server-sent event.</summary> | |
| 34 | /// <param name="data">The event's payload.</param> | |
| 35 | /// <param name="eventType">The event's type.</param> | |
| 36 | public SseItem(T data, string eventType) | |
| 37 | { | |
| 38 | Data = data; | |
| 39 | EventType = eventType; | |
| 40 | } | |
| 41 | | |
| 42 | /// <summary>Gets the event's payload.</summary> | |
| 43 | public T Data { get; } | |
| 44 | | |
| 45 | /// <summary>Gets the event's type.</summary> | |
| 46 | public string EventType { get; } | |
| 47 | } | |
| 48 | | |
| 49 | /// <summary>Encapsulates a method for parsing the bytes payload of a server-sent event.</summary> | |
| 50 | /// <typeparam name="T">Specifies the type of the return value of the parser.</typeparam> | |
| 51 | /// <param name="eventType">The event's type.</param> | |
| 52 | /// <param name="data">The event's payload bytes.</param> | |
| 53 | /// <returns>The parsed <typeparamref name="T"/>.</returns> | |
| 54 | internal delegate T SseItemParser<out T>(string eventType, ReadOnlySpan<byte> data); | |
| 55 | | |
| 56 | /// <summary>Provides a parser for parsing server-sent events.</summary> | |
| 57 | internal static class SseParser | |
| 58 | { | |
| 59 | /// <summary>The default <see cref="SseItem{T}.EventType"/> ("message") for an event that did not explicitly specify a type.</summary> | |
| 60 | public const string EventTypeDefault = "message"; | |
| 61 | | |
| 62 | /// <summary>Creates a parser for parsing a <paramref name="sseStream"/> of server-sent events into a sequence of <see cref="SseItem{String}"/> values.</summary> | |
| 63 | /// <param name="sseStream">The stream containing the data to parse.</param> | |
| 64 | /// <returns> | |
| 65 | /// The enumerable of strings, which may be enumerated synchronously or asynchronously. The strings | |
| 66 | /// are decoded from the UTF8-encoded bytes of the payload of each event. | |
| 67 | /// </returns> | |
| 68 | /// <exception cref="ArgumentNullException"><paramref name="sseStream"/> is null.</exception> | |
| 69 | /// <remarks> | |
| 70 | /// This overload has behavior equivalent to calling <see cref="Create{T}(Stream, SseItemParser{T})"/> with a delegate | |
| 71 | /// that decodes the data of each event using <see cref="Encoding.UTF8"/>'s GetString method. | |
| 72 | /// </remarks> | |
| 73 | public static SseParser<string> Create(Stream sseStream) => | |
| 74 | Create(sseStream, static (_, bytes) => Utf8GetString(bytes)); | |
| 75 | | |
| 76 | /// <summary>Creates a parser for parsing a <paramref name="sseStream"/> of server-sent events into a sequence of <see cref="SseItem{T}"/> values.</summary> | |
| 77 | /// <typeparam name="T">Specifies the type of data in each event.</typeparam> | |
| 78 | /// <param name="sseStream">The stream containing the data to parse.</param> | |
| 79 | /// <param name="itemParser">The parser to use to transform each payload of bytes into a data element.</param> | |
| 80 | /// <returns>The enumerable, which may be enumerated synchronously or asynchronously.</returns> | |
| 81 | /// <exception cref="ArgumentNullException"><paramref name="sseStream"/> is null.</exception> | |
| 82 | /// <exception cref="ArgumentNullException"><paramref name="itemParser"/> is null.</exception> | |
| 83 | public static SseParser<T> Create<T>(Stream sseStream, SseItemParser<T> itemParser) => | |
| 84 | new SseParser<T>( | |
| 85 | sseStream ?? throw new ArgumentNullException(nameof(sseStream)), | |
| 86 | itemParser ?? throw new ArgumentNullException(nameof(itemParser))); | |
| 87 | | |
| 88 | /// <summary>Encoding.UTF8.GetString(bytes)</summary> | |
| 89 | internal static string Utf8GetString(ReadOnlySpan<byte> bytes) | |
| 90 | { | |
| 91 | #if NET | |
| 92 | return Encoding.UTF8.GetString(bytes); | |
| 93 | #else | |
| 94 | unsafe | |
| 95 | { | |
| 96 | fixed (byte* ptr = bytes) | |
| 97 | { | |
| 98 | return ptr is null ? | |
| 99 | string.Empty : | |
| 100 | Encoding.UTF8.GetString(ptr, bytes.Length); | |
| 101 | } | |
| 102 | } | |
| 103 | #endif | |
| 104 | } | |
| 105 | } | |
| 106 | | |
| 107 | /// <summary>Provides a parser for server-sent events information.</summary> | |
| 108 | /// <typeparam name="T">Specifies the type of data parsed from an event.</typeparam> | |
| 109 | internal sealed class SseParser<T> | |
| 110 | { | |
| 111 | // For reference: | |
| 112 | // Specification: https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events | |
| 113 | | |
| 114 | /// <summary>Carriage Return.</summary> | |
| 115 | private const byte CR = (byte)'\r'; | |
| 116 | /// <summary>Line Feed.</summary> | |
| 117 | private const byte LF = (byte)'\n'; | |
| 118 | /// <summary>Carriage Return Line Feed.</summary> | |
| 119 | private static ReadOnlySpan<byte> CRLF => "\r\n"u8; | |
| 120 | | |
| 121 | /// <summary>The default size of an ArrayPool buffer to rent.</summary> | |
| 122 | /// <remarks>Larger size used by default to minimize number of reads. Smaller size used in debug to stress growth/shifting logic.</remarks> | |
| 123 | private const int DefaultArrayPoolRentSize = | |
| 124 | #if DEBUG | |
| 125 | 16; | |
| 126 | #else | |
| 127 | 1024; | |
| 128 | #endif | |
| 129 | | |
| 130 | /// <summary>The stream to be parsed.</summary> | |
| 131 | private readonly Stream _stream; | |
| 132 | /// <summary>The parser delegate used to transform bytes into a <typeparamref name="T"/>.</summary> | |
| 133 | private readonly SseItemParser<T> _itemParser; | |
| 134 | | |
| 135 | /// <summary>Indicates whether the enumerable has already been used for enumeration.</summary> | |
| 136 | private int _used; | |
| 137 | | |
| 138 | /// <summary>Buffer, either empty or rented, containing the data being read from the stream while looking for the next line.</summary> | |
| 139 | private byte[] _lineBuffer = []; | |
| 140 | /// <summary>The starting offset of valid data in <see cref="_lineBuffer"/>.</summary> | |
| 141 | private int _lineOffset; | |
| 142 | /// <summary>The length of valid data in <see cref="_lineBuffer"/>, starting from <see cref="_lineOffset"/>.</summary> | |
| 143 | private int _lineLength; | |
| 144 | /// <summary>The index in <see cref="_lineBuffer"/> where a newline ('\r', '\n', or "\r\n") was found.</summary> | |
| 145 | private int _newlineIndex; | |
| 146 | /// <summary>The index in <see cref="_lineBuffer"/> of characters already checked for newlines.</summary> | |
| 147 | /// <remarks> | |
| 148 | /// This is to avoid O(LineLength^2) behavior in the rare case where we have long lines that are built-up over multiple reads. | |
| 149 | /// We want to avoid re-checking the same characters we've already checked over and over again. | |
| 150 | /// </remarks> | |
| 151 | private int _lastSearchedForNewline; | |
| 152 | /// <summary>Set when eof has been reached in the stream.</summary> | |
| 153 | private bool _eof; | |
| 154 | | |
| 155 | /// <summary>Rented buffer containing buffered data for the next event.</summary> | |
| 156 | private byte[]? _dataBuffer; | |
| 157 | /// <summary>The length of valid data in <see cref="_dataBuffer"/>, starting from index 0.</summary> | |
| 158 | private int _dataLength; | |
| 159 | /// <summary>Whether data has been appended to <see cref="_dataBuffer"/>.</summary> | |
| 160 | /// <remarks>This can be different than <see cref="_dataLength"/> != 0 if empty data was appended.</remarks> | |
| 161 | private bool _dataAppended; | |
| 162 | | |
| 163 | /// <summary>The event type for the next event.</summary> | |
| 164 | private string _eventType = SseParser.EventTypeDefault; | |
| 165 | | |
| 166 | /// <summary>Initialize the enumerable.</summary> | |
| 167 | /// <param name="stream">The stream to parse.</param> | |
| 168 | /// <param name="itemParser">The function to use to parse payload bytes into a <typeparamref name="T"/>.</param> | |
| 169 | internal SseParser(Stream stream, SseItemParser<T> itemParser) | |
| 170 | { | |
| 171 | _stream = stream; | |
| 172 | _itemParser = itemParser; | |
| 173 | } | |
| 174 | | |
| 175 | /// <summary>Gets an enumerable of the server-sent events from this parser.</summary> | |
| 176 | /// <exception cref="InvalidOperationException">The parser has already been enumerated. Such an exception may propagate out of a call to <see cref="IEnumerator.MoveNext"/>.</exception> | |
| 177 | public IEnumerable<SseItem<T>> Enumerate() | |
| 178 | { | |
| 179 | // Validate that the parser is only used for one enumeration. | |
| 180 | ThrowIfNotFirstEnumeration(); | |
| 181 | | |
| 182 | // Rent a line buffer. This will grow as needed. The line buffer is what's passed to the stream, | |
| 183 | // so we want it to be large enough to reduce the number of reads we need to do when data is | |
| 184 | // arriving quickly. (In debug, we use a smaller buffer to stress the growth and shifting logic.) | |
| 185 | _lineBuffer = ArrayPool<byte>.Shared.Rent(DefaultArrayPoolRentSize); | |
| 186 | try | |
| 187 | { | |
| 188 | // Spec: "Event streams in this format must always be encoded as UTF-8". | |
| 189 | // Skip a UTF8 BOM if it exists at the beginning of the stream. (The BOM is defined as optional in the SSE grammar.) | |
| 190 | while (FillLineBuffer() != 0 && _lineLength < Utf8Bom.Length) ; | |
| 191 | SkipBomIfPresent(); | |
| 192 | | |
| 193 | // Process all events in the stream. | |
| 194 | while (true) | |
| 195 | { | |
| 196 | // See if there's a complete line in data already read from the stream. Lines are permitted to | |
| 197 | // end with CR, LF, or CRLF. Look for all of them and if we find one, process the line. However, | |
| 198 | // if we only find a CR and it's at the end of the read data, don't process it now, as we want | |
| 199 | // to process it together with an LF that might immediately follow, rather than treating them | |
| 200 | // as two separate characters, in which case we'd incorrectly process the CR as a line by itself. | |
| 201 | GetNextSearchOffsetAndLength(out int searchOffset, out int searchLength); | |
| 202 | _newlineIndex = _lineBuffer.AsSpan(searchOffset, searchLength).IndexOfAny(CR, LF); | |
| 203 | if (_newlineIndex >= 0) | |
| 204 | { | |
| 205 | _lastSearchedForNewline = -1; | |
| 206 | _newlineIndex += searchOffset; | |
| 207 | if (_lineBuffer[_newlineIndex] is LF || // the newline is LF | |
| 208 | _newlineIndex - _lineOffset + 1 < _lineLength || // we must have CR and we have whatever comes after it | |
| 209 | _eof) // if we get here, we know we have a CR at the end of the buffer, so it's definitely the whole newline if we've hit EOF | |
| 210 | { | |
| 211 | // Process the line. | |
| 212 | if (ProcessLine(out SseItem<T> sseItem, out int advance)) | |
| 213 | { | |
| 214 | yield return sseItem; | |
| 215 | } | |
| 216 | | |
| 217 | // Move past the line. | |
| 218 | _lineOffset += advance; | |
| 219 | _lineLength -= advance; | |
| 220 | continue; | |
| 221 | } | |
| 222 | } | |
| 223 | else | |
| 224 | { | |
| 225 | // Record the last position searched for a newline. The next time we search, | |
| 226 | // we'll search from here rather than from _lineOffset, in order to avoid searching | |
| 227 | // the same characters again. | |
| 228 | _lastSearchedForNewline = _lineOffset + _lineLength; | |
| 229 | } | |
| 230 | | |
| 231 | // We've processed everything in the buffer we currently can, so if we've already read EOF, we're done. | |
| 232 | if (_eof) | |
| 233 | { | |
| 234 | // Spec: "Once the end of the file is reached, any pending data must be discarded. (If the file ends in the middle of an | |
| 235 | // event, before the final empty line, the incomplete event is not dispatched.)" | |
| 236 | break; | |
| 237 | } | |
| 238 | | |
| 239 | // Read more data into the buffer. | |
| 240 | FillLineBuffer(); | |
| 241 | } | |
| 242 | } | |
| 243 | finally | |
| 244 | { | |
| 245 | ArrayPool<byte>.Shared.Return(_lineBuffer); | |
| 246 | if (_dataBuffer is not null) | |
| 247 | { | |
| 248 | ArrayPool<byte>.Shared.Return(_dataBuffer); | |
| 249 | } | |
| 250 | } | |
| 251 | } | |
| 252 | | |
| 253 | /// <summary>Gets an asynchronous enumerable of the server-sent events from this parser.</summary> | |
| 254 | /// <param name="cancellationToken">The cancellation token to use to cancel the enumeration.</param> | |
| 255 | /// <exception cref="InvalidOperationException">The parser has already been enumerated. Such an exception may propagate out of a call to <see cref="IAsyncEnumerator{T}.MoveNextAsync"/>.</exception> | |
| 256 | /// <exception cref="OperationCanceledException">The enumeration was canceled. Such an exception may propagate out of a call to <see cref="IAsyncEnumerator{T}.MoveNextAsync"/>.</exception> | |
| 257 | public async IAsyncEnumerable<SseItem<T>> EnumerateAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) | |
| 258 | { | |
| 259 | // Validate that the parser is only used for one enumeration. | |
| 260 | ThrowIfNotFirstEnumeration(); | |
| 261 | | |
| 262 | // Rent a line buffer. This will grow as needed. The line buffer is what's passed to the stream, | |
| 263 | // so we want it to be large enough to reduce the number of reads we need to do when data is | |
| 264 | // arriving quickly. (In debug, we use a smaller buffer to stress the growth and shifting logic.) | |
| 265 | _lineBuffer = ArrayPool<byte>.Shared.Rent(DefaultArrayPoolRentSize); | |
| 266 | try | |
| 267 | { | |
| 268 | // Spec: "Event streams in this format must always be encoded as UTF-8". | |
| 269 | // Skip a UTF8 BOM if it exists at the beginning of the stream. (The BOM is defined as optional in the SSE grammar.) | |
| 270 | while (await FillLineBufferAsync(cancellationToken).ConfigureAwait(false) != 0 && _lineLength < Utf8Bom.Length) ; | |
| 271 | SkipBomIfPresent(); | |
| 272 | | |
| 273 | // Process all events in the stream. | |
| 274 | while (true) | |
| 275 | { | |
| 276 | // See if there's a complete line in data already read from the stream. Lines are permitted to | |
| 277 | // end with CR, LF, or CRLF. Look for all of them and if we find one, process the line. However, | |
| 278 | // if we only find a CR and it's at the end of the read data, don't process it now, as we want | |
| 279 | // to process it together with an LF that might immediately follow, rather than treating them | |
| 280 | // as two separate characters, in which case we'd incorrectly process the CR as a line by itself. | |
| 281 | GetNextSearchOffsetAndLength(out int searchOffset, out int searchLength); | |
| 282 | _newlineIndex = _lineBuffer.AsSpan(searchOffset, searchLength).IndexOfAny(CR, LF); | |
| 283 | if (_newlineIndex >= 0) | |
| 284 | { | |
| 285 | _lastSearchedForNewline = -1; | |
| 286 | _newlineIndex += searchOffset; | |
| 287 | if (_lineBuffer[_newlineIndex] is LF || // newline is LF | |
| 288 | _newlineIndex - _lineOffset + 1 < _lineLength || // newline is CR, and we have whatever comes after it | |
| 289 | _eof) // if we get here, we know we have a CR at the end of the buffer, so it's definitely the whole newline if we've hit EOF | |
| 290 | { | |
| 291 | // Process the line. | |
| 292 | if (ProcessLine(out SseItem<T> sseItem, out int advance)) | |
| 293 | { | |
| 294 | yield return sseItem; | |
| 295 | } | |
| 296 | | |
| 297 | // Move past the line. | |
| 298 | _lineOffset += advance; | |
| 299 | _lineLength -= advance; | |
| 300 | continue; | |
| 301 | } | |
| 302 | } | |
| 303 | else | |
| 304 | { | |
| 305 | // Record the last position searched for a newline. The next time we search, | |
| 306 | // we'll search from here rather than from _lineOffset, in order to avoid searching | |
| 307 | // the same characters again. | |
| 308 | _lastSearchedForNewline = searchOffset + searchLength; | |
| 309 | } | |
| 310 | | |
| 311 | // We've processed everything in the buffer we currently can, so if we've already read EOF, we're done. | |
| 312 | if (_eof) | |
| 313 | { | |
| 314 | // Spec: "Once the end of the file is reached, any pending data must be discarded. (If the file ends in the middle of an | |
| 315 | // event, before the final empty line, the incomplete event is not dispatched.)" | |
| 316 | break; | |
| 317 | } | |
| 318 | | |
| 319 | // Read more data into the buffer. | |
| 320 | await FillLineBufferAsync(cancellationToken).ConfigureAwait(false); | |
| 321 | } | |
| 322 | } | |
| 323 | finally | |
| 324 | { | |
| 325 | ArrayPool<byte>.Shared.Return(_lineBuffer); | |
| 326 | if (_dataBuffer is not null) | |
| 327 | { | |
| 328 | ArrayPool<byte>.Shared.Return(_dataBuffer); | |
| 329 | } | |
| 330 | } | |
| 331 | } | |
| 332 | | |
| 333 | /// <summary>Gets the next index and length with which to perform a newline search.</summary> | |
| 334 | private void GetNextSearchOffsetAndLength(out int searchOffset, out int searchLength) | |
| 335 | { | |
| 336 | if (_lastSearchedForNewline > _lineOffset) | |
| 337 | { | |
| 338 | searchOffset = _lastSearchedForNewline; | |
| 339 | searchLength = _lineLength - (_lastSearchedForNewline - _lineOffset); | |
| 340 | } | |
| 341 | else | |
| 342 | { | |
| 343 | searchOffset = _lineOffset; | |
| 344 | searchLength = _lineLength; | |
| 345 | } | |
| 346 | | |
| 347 | Debug.Assert(searchOffset >= _lineOffset, $"{searchOffset}, {_lineLength}"); | |
| 348 | Debug.Assert(searchOffset <= _lineOffset + _lineLength, $"{searchOffset}, {_lineOffset}, {_lineLength}"); | |
| 349 | Debug.Assert(searchOffset <= _lineBuffer.Length, $"{searchOffset}, {_lineBuffer.Length}"); | |
| 350 | | |
| 351 | Debug.Assert(searchLength >= 0, $"{searchLength}"); | |
| 352 | Debug.Assert(searchLength <= _lineLength, $"{searchLength}, {_lineLength}"); | |
| 353 | } | |
| 354 | | |
| 355 | private int GetNewLineLength() | |
| 356 | { | |
| 357 | Debug.Assert(_newlineIndex - _lineOffset < _lineLength, "Expected to be positioned at a non-empty newline"); | |
| 358 | return _lineBuffer.AsSpan(_newlineIndex, _lineLength - (_newlineIndex - _lineOffset)).StartsWith(CRLF) ? 2 : 1; | |
| 359 | } | |
| 360 | | |
| 361 | /// <summary> | |
| 362 | /// If there's no room remaining in the line buffer, either shifts the contents | |
| 363 | /// left or grows the buffer in order to make room for the next read. | |
| 364 | /// </summary> | |
| 365 | private void ShiftOrGrowLineBufferIfNecessary() | |
| 366 | { | |
| 367 | // If data we've read is butting up against the end of the buffer and | |
| 368 | // it's not taking up the entire buffer, slide what's there down to | |
| 369 | // the beginning, making room to read more data into the buffer (since | |
| 370 | // there's no newline in the data that's there). Otherwise, if the whole | |
| 371 | // buffer is full, grow the buffer to accommodate more data, since, again, | |
| 372 | // what's there doesn't contain a newline and thus a line is longer than | |
| 373 | // the current buffer accommodates. | |
| 374 | if (_lineOffset + _lineLength == _lineBuffer.Length) | |
| 375 | { | |
| 376 | if (_lineOffset != 0) | |
| 377 | { | |
| 378 | _lineBuffer.AsSpan(_lineOffset, _lineLength).CopyTo(_lineBuffer); | |
| 379 | if (_lastSearchedForNewline >= 0) | |
| 380 | { | |
| 381 | _lastSearchedForNewline -= _lineOffset; | |
| 382 | } | |
| 383 | _lineOffset = 0; | |
| 384 | } | |
| 385 | else if (_lineLength == _lineBuffer.Length) | |
| 386 | { | |
| 387 | GrowBuffer(ref _lineBuffer!, _lineBuffer.Length * 2); | |
| 388 | } | |
| 389 | } | |
| 390 | } | |
| 391 | | |
| 392 | /// <summary>Processes a complete line from the SSE stream.</summary> | |
| 393 | /// <param name="sseItem">The parsed item if the method returns true.</param> | |
| 394 | /// <param name="advance">How many characters to advance in the line buffer.</param> | |
| 395 | /// <returns>true if an SSE item was successfully parsed; otherwise, false.</returns> | |
| 396 | private bool ProcessLine(out SseItem<T> sseItem, out int advance) | |
| 397 | { | |
| 398 | ReadOnlySpan<byte> line = _lineBuffer.AsSpan(_lineOffset, _newlineIndex - _lineOffset); | |
| 399 | | |
| 400 | // Spec: "If the line is empty (a blank line) Dispatch the event" | |
| 401 | if (line.IsEmpty) | |
| 402 | { | |
| 403 | advance = GetNewLineLength(); | |
| 404 | | |
| 405 | if (_dataAppended) | |
| 406 | { | |
| 407 | sseItem = new SseItem<T>(_itemParser(_eventType, _dataBuffer.AsSpan(0, _dataLength)), _eventType); | |
| 408 | _eventType = SseParser.EventTypeDefault; | |
| 409 | _dataLength = 0; | |
| 410 | _dataAppended = false; | |
| 411 | return true; | |
| 412 | } | |
| 413 | | |
| 414 | sseItem = default; | |
| 415 | return false; | |
| 416 | } | |
| 417 | | |
| 418 | // Find the colon separating the field name and value. | |
| 419 | int colonPos = line.IndexOf((byte)':'); | |
| 420 | ReadOnlySpan<byte> fieldName; | |
| 421 | ReadOnlySpan<byte> fieldValue; | |
| 422 | if (colonPos >= 0) | |
| 423 | { | |
| 424 | // Spec: "Collect the characters on the line before the first U+003A COLON character (:), and let field be that string." | |
| 425 | fieldName = line.Slice(0, colonPos); | |
| 426 | | |
| 427 | // Spec: "Collect the characters on the line after the first U+003A COLON character (:), and let value be that string. | |
| 428 | // If value starts with a U+0020 SPACE character, remove it from value." | |
| 429 | fieldValue = line.Slice(colonPos + 1); | |
| 430 | if (!fieldValue.IsEmpty && fieldValue[0] == (byte)' ') | |
| 431 | { | |
| 432 | fieldValue = fieldValue.Slice(1); | |
| 433 | } | |
| 434 | } | |
| 435 | else | |
| 436 | { | |
| 437 | // Spec: "using the whole line as the field name, and the empty string as the field value." | |
| 438 | fieldName = line; | |
| 439 | fieldValue = []; | |
| 440 | } | |
| 441 | | |
| 442 | if (fieldName.SequenceEqual("data"u8)) | |
| 443 | { | |
| 444 | // Spec: "Append the field value to the data buffer, then append a single U+000A LINE FEED (LF) character to the data buffer." | |
| 445 | // Spec: "If the data buffer's last character is a U+000A LINE FEED (LF) character, then remove the last character from the data buffer." | |
| 446 | | |
| 447 | // If there's nothing currently in the data buffer and we can easily detect that this line is immediately followed by | |
| 448 | // an empty line, we can optimize it to just handle the data directly from the line buffer, rather than first copying | |
| 449 | // into the data buffer and dispatching from there. | |
| 450 | if (!_dataAppended) | |
| 451 | { | |
| 452 | int newlineLength = GetNewLineLength(); | |
| 453 | ReadOnlySpan<byte> remainder = _lineBuffer.AsSpan(_newlineIndex + newlineLength, _lineLength - line.Length - newlineLength); | |
| 454 | if (!remainder.IsEmpty && | |
| 455 | (remainder[0] is LF || (remainder[0] is CR && remainder.Length > 1))) | |
| 456 | { | |
| 457 | advance = line.Length + newlineLength + (remainder.StartsWith(CRLF) ? 2 : 1); | |
| 458 | sseItem = new SseItem<T>(_itemParser(_eventType, fieldValue), _eventType); | |
| 459 | _eventType = SseParser.EventTypeDefault; | |
| 460 | return true; | |
| 461 | } | |
| 462 | } | |
| 463 | | |
| 464 | // We need to copy the data from the data buffer to the line buffer. Make sure there's enough room. | |
| 465 | if (_dataBuffer is null || _dataLength + _lineLength + 1 > _dataBuffer.Length) | |
| 466 | { | |
| 467 | GrowBuffer(ref _dataBuffer, _dataLength + _lineLength + 1); | |
| 468 | } | |
| 469 | | |
| 470 | // Append a newline if there's already content in the buffer. | |
| 471 | // Then copy the field value to the data buffer | |
| 472 | if (_dataAppended) | |
| 473 | { | |
| 474 | _dataBuffer![_dataLength++] = LF; | |
| 475 | } | |
| 476 | fieldValue.CopyTo(_dataBuffer.AsSpan(_dataLength)); | |
| 477 | _dataLength += fieldValue.Length; | |
| 478 | _dataAppended = true; | |
| 479 | } | |
| 480 | else if (fieldName.SequenceEqual("event"u8)) | |
| 481 | { | |
| 482 | // Spec: "Set the event type buffer to field value." | |
| 483 | _eventType = SseParser.Utf8GetString(fieldValue); | |
| 484 | } | |
| 485 | else if (fieldName.SequenceEqual("id"u8)) | |
| 486 | { | |
| 487 | // Spec: "If the field value does not contain U+0000 NULL, then set the last event ID buffer to the field value. Otherwise, ignore the field." | |
| 488 | if (fieldValue.IndexOf((byte)'\0') < 0) | |
| 489 | { | |
| 490 | // Note that fieldValue might be empty, in which case LastEventId will naturally be reset to the empty string. This is per spec. | |
| 491 | LastEventId = SseParser.Utf8GetString(fieldValue); | |
| 492 | } | |
| 493 | } | |
| 494 | else if (fieldName.SequenceEqual("retry"u8)) | |
| 495 | { | |
| 496 | // Spec: "If the field value consists of only ASCII digits, then interpret the field value as an integer in base ten, | |
| 497 | // and set the event stream's reconnection time to that integer. Otherwise, ignore the field." | |
| 498 | if (long.TryParse( | |
| 499 | #if NET7_0_OR_GREATER | |
| 500 | fieldValue, | |
| 501 | #else | |
| 502 | SseParser.Utf8GetString(fieldValue), | |
| 503 | #endif | |
| 504 | NumberStyles.None, CultureInfo.InvariantCulture, out long milliseconds)) | |
| 505 | { | |
| 506 | ReconnectionInterval = TimeSpan.FromMilliseconds(milliseconds); | |
| 507 | } | |
| 508 | } | |
| 509 | else | |
| 510 | { | |
| 511 | // We'll end up here if the line starts with a colon, producing an empty field name, or if the field name is otherwise unrecognized. | |
| 512 | // Spec: "If the line starts with a U+003A COLON character (:) Ignore the line." | |
| 513 | // Spec: "Otherwise, The field is ignored" | |
| 514 | } | |
| 515 | | |
| 516 | advance = line.Length + GetNewLineLength(); | |
| 517 | sseItem = default; | |
| 518 | return false; | |
| 519 | } | |
| 520 | | |
| 521 | /// <summary>Gets the last event ID.</summary> | |
| 522 | /// <remarks>This value is updated any time a new last event ID is parsed. It is not reset between SSE items.</remarks> | |
| 523 | public string LastEventId { get; private set; } = string.Empty; // Spec: "must be initialized to the empty string" | |
| 524 | | |
| 525 | /// <summary>Gets the reconnection interval.</summary> | |
| 526 | /// <remarks> | |
| 527 | /// If no retry event was received, this defaults to <see cref="Timeout.InfiniteTimeSpan"/>, and it will only | |
| 528 | /// ever be <see cref="Timeout.InfiniteTimeSpan"/> in that situation. If a client wishes to retry, the server-sent | |
| 529 | /// events specification states that the interval may then be decided by the client implementation and should be a | |
| 530 | /// few seconds. | |
| 531 | /// </remarks> | |
| 532 | public TimeSpan ReconnectionInterval { get; private set; } = Timeout.InfiniteTimeSpan; | |
| 533 | | |
| 534 | /// <summary>Transitions the object to a used state, throwing if it's already been used.</summary> | |
| 535 | private void ThrowIfNotFirstEnumeration() | |
| 536 | { | |
| 537 | if (Interlocked.Exchange(ref _used, 1) != 0) | |
| 538 | { | |
| 539 | throw new InvalidOperationException("The enumerable may be enumerated only once."); | |
| 540 | } | |
| 541 | } | |
| 542 | | |
| 543 | /// <summary>Reads data from the stream into the line buffer.</summary> | |
| 544 | private int FillLineBuffer() | |
| 545 | { | |
| 546 | ShiftOrGrowLineBufferIfNecessary(); | |
| 547 | | |
| 548 | int offset = _lineOffset + _lineLength; | |
| 549 | int bytesRead = _stream.Read( | |
| 550 | #if NET | |
| 551 | _lineBuffer.AsSpan(offset)); | |
| 552 | #else | |
| 553 | _lineBuffer, offset, _lineBuffer.Length - offset); | |
| 554 | #endif | |
| 555 | | |
| 556 | if (bytesRead > 0) | |
| 557 | { | |
| 558 | _lineLength += bytesRead; | |
| 559 | } | |
| 560 | else | |
| 561 | { | |
| 562 | _eof = true; | |
| 563 | bytesRead = 0; | |
| 564 | } | |
| 565 | | |
| 566 | return bytesRead; | |
| 567 | } | |
| 568 | | |
| 569 | /// <summary>Reads data asynchronously from the stream into the line buffer.</summary> | |
| 570 | private async ValueTask<int> FillLineBufferAsync(CancellationToken cancellationToken) | |
| 571 | { | |
| 572 | ShiftOrGrowLineBufferIfNecessary(); | |
| 573 | | |
| 574 | int offset = _lineOffset + _lineLength; | |
| 575 | int bytesRead = await | |
| 576 | #if NET | |
| 577 | _stream.ReadAsync(_lineBuffer.AsMemory(offset), cancellationToken) | |
| 578 | #else | |
| 579 | new ValueTask<int>(_stream.ReadAsync(_lineBuffer, offset, _lineBuffer.Length - offset, cancellationToken)) | |
| 580 | #endif | |
| 581 | .ConfigureAwait(false); | |
| 582 | | |
| 583 | if (bytesRead > 0) | |
| 584 | { | |
| 585 | _lineLength += bytesRead; | |
| 586 | } | |
| 587 | else | |
| 588 | { | |
| 589 | _eof = true; | |
| 590 | bytesRead = 0; | |
| 591 | } | |
| 592 | | |
| 593 | return bytesRead; | |
| 594 | } | |
| 595 | | |
| 596 | /// <summary>Gets the UTF8 BOM.</summary> | |
| 597 | private static ReadOnlySpan<byte> Utf8Bom => [0xEF, 0xBB, 0xBF]; | |
| 598 | | |
| 599 | /// <summary>Called at the beginning of processing to skip over an optional UTF8 byte order mark.</summary> | |
| 600 | private void SkipBomIfPresent() | |
| 601 | { | |
| 602 | Debug.Assert(_lineOffset == 0, $"Expected _lineOffset == 0, got {_lineOffset}"); | |
| 603 | | |
| 604 | if (_lineBuffer.AsSpan(0, _lineLength).StartsWith(Utf8Bom)) | |
| 605 | { | |
| 606 | _lineOffset += 3; | |
| 607 | _lineLength -= 3; | |
| 608 | } | |
| 609 | } | |
| 610 | | |
| 611 | /// <summary>Grows the buffer, returning the existing one to the ArrayPool and renting an ArrayPool replacement.</summary> | |
| 612 | private static void GrowBuffer(ref byte[]? buffer, int minimumLength) | |
| 613 | { | |
| 614 | byte[]? toReturn = buffer; | |
| 615 | buffer = ArrayPool<byte>.Shared.Rent(Math.Max(minimumLength, DefaultArrayPoolRentSize)); | |
| 616 | if (toReturn is not null) | |
| 617 | { | |
| 618 | Array.Copy(toReturn, buffer, toReturn.Length); | |
| 619 | ArrayPool<byte>.Shared.Return(toReturn); | |
| 620 | } | |
| 621 | } | |
| 622 | } | |
| 623 | } |