diff --git a/Package.swift b/Package.swift index c062e88..8ca6fef 100644 --- a/Package.swift +++ b/Package.swift @@ -43,6 +43,10 @@ var traits: Set = [ name: "HTTP3", description: "Enables HTTP/3 support" ), + .trait( + name: "UnstableHTTPDatagrams", + description: "Enables support for reading and writing unreliable HTTP datagrams" + ), ] let defaultTraits: Set = ["Configuration"] diff --git a/README.md b/README.md index 160e703..7193bf0 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ Available traits: - **`Configuration`** (default): Enables initializing `NIOHTTPServerConfiguration` from a `swift-configuration` `ConfigProvider`. - **`HTTP3`**: Enables HTTP/3 support. +- **`UnstableHTTPDatagrams`**: Enables support for reading and writing unreliable HTTP datagrams. Note that the `HTTP3` + trait must be enabled alongside. ## HTTP/3 support diff --git a/Sources/NIOHTTPServer/Datagrams/ConnectUDPExample.swift b/Sources/NIOHTTPServer/Datagrams/ConnectUDPExample.swift new file mode 100644 index 0000000..27c39e9 --- /dev/null +++ b/Sources/NIOHTTPServer/Datagrams/ConnectUDPExample.swift @@ -0,0 +1,173 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if HTTP3 && UnstableHTTPDatagrams + +import BasicContainers +import HTTPAPIs +import NIOCore +import NIOHTTPTypes +import NetworkTypes + +@available(anyAppleOS 26.0, *) +func connectUDPExample( + request: HTTPRequest, + context: NIOHTTPServer.ConnectionContext, + reader: consuming sending NIOHTTPServer.Reader, + responseSender: consuming sending NIOHTTPServer.ResponseSender +) async throws { + guard ConnectUDPHelper.isValidConnectUDPRequest(request, version: context.httpVersion) else { + return try await responseSender.sendAndFinish(.init(status: .forbidden)) + } + + var disconnectedResponseSender = Disconnected(value: Optional(responseSender)) + + try await reader.withDatagramReader { streamReader, maybeDatagramReader in + let responseSender = disconnectedResponseSender.swap(newValue: nil)! + + // The unreliable datagram transport will not be available if the underlying transport does not support + // unreliable datagrams, like in HTTP/1.1 and HTTP/2 over TCP, or also over HTTP/3 when support for datagrams is + // not negotiated, i.e. we (the server) either sent or received the `SETTINGS_H3_DATAGRAM` setting with value 0. + // + // Since this example wants to showcase the unreliable datagram reader/writer APIs, we just return early if the + // unreliable datagram transport is not available. However, note that in these cases, it is still possible to + // perform CONNECT-UDP by exchanging data through the Capsule protocol over the request/response reader/writer. + guard var datagramReader = maybeDatagramReader else { + return try await responseSender.sendAndFinish(.init(status: .notImplemented)) + } + + // Store any bytes we read before sending the response so we can send them to the target. + var pendingToTarget: [UInt8] = [] + + var streamReader = streamReader + try await streamReader.read { buffer, _ in + for index in buffer.indices { pendingToTarget.append(buffer[index]) } + } + + try await datagramReader.read { buffer, _ in + for index in buffer.indices { pendingToTarget.append(buffer[index]) } + } + + // Hold the readers until the tunnel is established. + var disconnectedStreamReader = Disconnected(value: Optional(streamReader)) + var disconnectedDatagramReader = Disconnected(value: Optional(datagramReader)) + + // Now accept the request and access the datagram writer through the response writer. + let writer = try await responseSender.send(ConnectUDPHelper.makeSuccessResponse(version: context.httpVersion)) + try await writer.withDatagramWriter { streamWriter, datagramWriter in + var disconnectedStreamWriter = Disconnected(value: Optional(streamWriter)) + var disconnectedDatagramWriter = Disconnected(value: datagramWriter) + + await withThrowingTaskGroup { group in + var unwrappedStreamWriter = disconnectedStreamWriter.swap(newValue: nil)! + var unwrappedStreamReader = disconnectedStreamReader.swap(newValue: nil)! + + // Write to the reliable stream. + group.addTask { + var emptyBuffer = UniqueArray() + try await unwrappedStreamWriter.write(buffer: &emptyBuffer) + } + + if var unwrappedDatagramWriter = disconnectedDatagramWriter.swap(newValue: nil) { + // Write to the unreliable stream. + group.addTask { + var emptyBuffer = UniqueArray() + try await unwrappedDatagramWriter.write(buffer: &emptyBuffer) + } + } + + // Read from the reliable stream. + group.addTask { + try await unwrappedStreamReader.read { _, _ in + () + } + } + + if var unwrappedDatagramReader = disconnectedDatagramReader.swap(newValue: nil) { + // Read from the unreliable stream. + group.addTask { + try await unwrappedDatagramReader.read { _, _ in + () + } + } + } + } + } + } +} + +@available(anyAppleOS 26.0, *) +enum ConnectUDPHelper { + /// Validate that `request` corresponds to a valid CONNECT-UDP request. + static func isValidConnectUDPRequest(_ request: HTTPRequest, version: NIOHTTPServer.HTTPVersion) -> Bool { + guard request.method == .connect else { + return false + } + + switch version { + case .plaintextHTTP1_1, .http1_1: + let hasConnectionUpgrade = request.headerFields[.connection]?.lowercased() == "upgrade" + let hasUpgradeConnectUDP = request.headerFields[.upgrade] == "connect-udp" + + guard hasConnectionUpgrade, hasUpgradeConnectUDP else { + return false + } + + case .http2: + guard request.extendedConnectProtocol == "connect-udp" else { + return false + } + + #if HTTP3 + case .http3: + guard request.extendedConnectProtocol == "connect-udp" else { + return false + } + #endif + } + + return true + } + + /// Returns a success response to accept the tunnel. + static func makeSuccessResponse(version: NIOHTTPServer.HTTPVersion) -> HTTPResponse { + switch version { + case .plaintextHTTP1_1, .http1_1: + HTTPResponse( + status: .switchingProtocols, + headerFields: [ + .connection: "Upgrade", + .upgrade: "connect-udp", + .capsuleProtocol: "?1", + ] + ) + + case .http2: + HTTPResponse(status: .ok, headerFields: [.capsuleProtocol: "?1"]) + + #if HTTP3 + case .http3: + HTTPResponse(status: .ok, headerFields: [.capsuleProtocol: "?1"]) + #endif + } + } +} + +extension HTTPField.Name { + static var capsuleProtocol: Self { + Self("Capsule-Protocol")! + } +} + +#endif // HTTP3 && UnstableHTTPDatagrams diff --git a/Sources/NIOHTTPServer/Datagrams/NIOHTTPServer+Datagrams.swift b/Sources/NIOHTTPServer/Datagrams/NIOHTTPServer+Datagrams.swift new file mode 100644 index 0000000..fa117b4 --- /dev/null +++ b/Sources/NIOHTTPServer/Datagrams/NIOHTTPServer+Datagrams.swift @@ -0,0 +1,93 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if HTTP3 && UnstableHTTPDatagrams + +public import BasicContainers +public import HTTPAPIs +import NIOCore +import NIOHTTPTypes +import Synchronization + +/// Errors from reading/writing on the unreliable datagram. +@available(anyAppleOS 26.0, *) +public enum DatagramsError: Error, Sendable { + /// The unreliable datagram transport is not yet implemented. + case notImplemented +} + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServer { + /// A reader for the unreliable datagram stream. + public struct DatagramReader: AsyncReader, ~Copyable { + public typealias ReadElement = UInt8 + public typealias Buffer = UniqueArray + public typealias ReadFailure = any Error + public typealias FinalElement = Void + + public mutating func read( + body: (inout Buffer, consuming FinalElement?) async throws(Failure) -> Return + ) async throws(EitherError) -> Return { + // TODO: The datagram transport is not yet implemented. + throw .first(DatagramsError.notImplemented) + } + } + + /// A writer for the unreliable datagram stream. + public struct DatagramWriter: CallerAsyncWriter, ~Copyable { + public typealias WriteElement = UInt8 + public typealias WriteFailure = any Error + public typealias FinalElement = Void + + public mutating func write & ~Copyable>( + buffer: inout Buffer + ) async throws where Buffer.Element: ~Copyable { + // TODO: The datagram transport is not yet implemented. + throw DatagramsError.notImplemented + } + + public consuming func finish & ~Copyable>( + buffer: inout Buffer, + finalElement: consuming Void + ) async throws where Buffer.Element: ~Copyable { + // TODO: The datagram transport is not yet implemented. + throw DatagramsError.notImplemented + } + } +} + +@available(*, unavailable) +extension NIOHTTPServer.DatagramReader: Sendable {} + +@available(*, unavailable) +extension NIOHTTPServer.DatagramWriter: Sendable {} + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServer { + struct StreamFinish: ~Copyable { + let writer: NIOAsyncChannelOutboundWriter + let state: NIOHTTPServer.ResponseSender.WriterState + + consuming func finish() async throws { + // Check if `finish` has not already been fired. + let shouldClose = self.state.wrapped.withLock { !$0.finishedWriting } + guard shouldClose else { return } + + try await self.writer.write(.end(nil)) + self.state.wrapped.withLock { $0.finishedWriting = true } + } + } +} + +#endif // HTTP3 && UnstableHTTPDatagrams diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift b/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift index 1758109..d9c412c 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift @@ -66,6 +66,25 @@ extension NIOHTTPServer { self.peerCertificateChainFuture = peerCertificateChainFuture } + #if HTTP3 && UnstableHTTPDatagrams + /// Whether this connection supports unreliable datagrams. + public var supportsUnreliableDatagrams: Bool { + switch self.httpVersion { + #if HTTP3 + case .http3: + // TODO: Until `swift-nio-http3` exposes a mechanism for retrieving the unreliable datagram reader and + // writer, we return `false` here. Additionally, note that even over HTTP/3, it is not always guaranteed + // that the unreliable datagram reader/writer is available; the support must be negotiated with the peer + // through the `SETTINGS_H3_DATAGRAM` setting. + false + #endif + + case .plaintextHTTP1_1, .http1_1, .http2: + false + } + } + #endif + /// The peer's validated certificate chain. Returns `nil` if a custom /// verification callback was not set when configuring mTLS in the /// server configuration, or if the custom verification callback did not diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+RequestContext.swift b/Sources/NIOHTTPServer/NIOHTTPServer+RequestContext.swift index bb8eb70..bafbd64 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+RequestContext.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+RequestContext.swift @@ -31,6 +31,13 @@ extension NIOHTTPServer { init(connectionContext: ConnectionContext) { self.connectionContext = connectionContext } + + #if HTTP3 && UnstableHTTPDatagrams + /// Whether this connection supports unreliable datagrams. + public var supportsUnreliableDatagrams: Bool { + self.connectionContext.supportsUnreliableDatagrams + } + #endif } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index 37c0bf2..6554908 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -343,14 +343,22 @@ public struct NIOHTTPServer: HTTPServer { let readerState = Reader.ReaderState(iterator: iterator) let writerState = ResponseSender.WriterState() + #if HTTP3 && UnstableHTTPDatagrams + // TODO: `swift-nio-http3` currently does not provide APIs for reading/writing bytes on the unreliable datagram + // stream. This is why we currently pass `nil` to the `datagramReader` and `datagramWriter` arguments. + let requestReader = Reader(readerState: readerState, datagramReader: nil) + let responseSender = ResponseSender(writer: outbound, writerState: writerState, datagramWriter: nil) + #else + let requestReader = Reader(readerState: readerState) + let responseSender = ResponseSender(writer: outbound, writerState: writerState) + #endif + do { try await handler.handle( request: request, requestContext: RequestContext(connectionContext: context), - reader: Reader( - readerState: readerState - ), - responseSender: ResponseSender(writer: outbound, writerState: writerState) + reader: requestReader, + responseSender: responseSender ) } catch { logger.error("Error thrown while handling request: \(error)") diff --git a/Sources/NIOHTTPServer/NIOHTTPServerReader.swift b/Sources/NIOHTTPServer/NIOHTTPServerReader.swift index 2cfb2c9..2a2ca9b 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServerReader.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServerReader.swift @@ -69,14 +69,31 @@ extension NIOHTTPServer { /// (while keeping its capacity) at the start of every read. private var buffer: UniqueArray - /// Initializes a new request body reader, taking the iterator from the - /// shared `ReaderState`. + /// Initializes a new request body reader, taking the iterator from the shared `ReaderState`. init(readerState: ReaderState) { self.state = readerState self.iterator = readerState.takeIterator() self.buffer = UniqueArray() } + #if HTTP3 && UnstableHTTPDatagrams + /// The unreliable datagram reader, present when the underlying transport is capable of reading/writing + /// unreliable datagrams. + private var datagramReader: Disconnected? + + /// Initializes a new request body reader that can also vend an unreliable datagram reader if the underlying + /// transport supports unreliable datagrams. + init( + readerState: ReaderState, + datagramReader: consuming Disconnected? = nil + ) { + self.state = readerState + self.iterator = readerState.takeIterator() + self.buffer = UniqueArray() + self.datagramReader = datagramReader + } + #endif + public mutating func read( body: (inout Buffer, consuming HTTPFields??) async throws(Failure) -> Return ) async throws(EitherError) -> Return { @@ -121,3 +138,29 @@ extension NIOHTTPServer { @available(*, unavailable) extension NIOHTTPServer.Reader: Sendable {} + +#if HTTP3 && UnstableHTTPDatagrams +@available(anyAppleOS 26.0, *) +extension NIOHTTPServer.Reader { + /// Vends this request body reader and the unreliable datagram reader to the provided `body` closure. + /// + /// - Parameter body: A closure that receives the request body reader and the datagram reader. The datagram reader + /// is `nil` when the underlying transport does not support unreliable datagrams. + /// + /// - Note: Both readers are passed as `consuming sending`. This means that `body` can use both readers in separate + /// tasks. + public consuming func withDatagramReader( + _ body: (consuming sending Self, consuming sending NIOHTTPServer.DatagramReader?) async throws -> Void + ) async throws { + // Move the iterator back to `ReaderState` so the new reader we create can use it. + nonisolated(unsafe) let iterator = self.iterator.take() + self.state.wrapped.withLock { state in + _ = unsafe state.iterator.swap(newValue: iterator) + } + let streamReader = NIOHTTPServer.Reader(readerState: self.state, datagramReader: nil) + let datagramReader = self.datagramReader.take()?.take() + + try await body(streamReader, datagramReader) + } +} +#endif // HTTP3 && UnstableHTTPDatagrams diff --git a/Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift b/Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift index 2e7d898..d0f158f 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift @@ -22,6 +22,31 @@ extension NIOHTTPServer { let writer: NIOAsyncChannelOutboundWriter let writerState: WriterState + // Initializes a new response sender. + init( + writer: NIOAsyncChannelOutboundWriter, + writerState: WriterState + ) { + self.writer = writer + self.writerState = writerState + } + + #if HTTP3 && UnstableHTTPDatagrams + private var datagramWriter: Disconnected? + + /// Initializes a response sender that can also vend an unreliable datagram writer if the underlying transport + /// supports unreliable datagrams. + init( + writer: NIOAsyncChannelOutboundWriter, + writerState: WriterState, + datagramWriter: consuming Disconnected? = nil + ) { + self.writer = writer + self.writerState = writerState + self.datagramWriter = datagramWriter + } + #endif + public mutating func sendInformational(_ response: HTTPResponse) async throws { precondition(response.status.kind == .informational) try await self.writer.write(.head(response)) @@ -30,7 +55,12 @@ extension NIOHTTPServer { public consuming func send(_ response: HTTPResponse) async throws -> Writer { precondition(response.status.kind != .informational) try await self.writer.write(.head(response)) + + #if HTTP3 && UnstableHTTPDatagrams + return Writer(writer: self.writer, writerState: self.writerState, datagramWriter: self.datagramWriter) + #else return Writer(writer: self.writer, writerState: self.writerState) + #endif } } } @@ -57,6 +87,11 @@ extension NIOHTTPServer.ResponseSender { let writerState: WriterState + #if HTTP3 && UnstableHTTPDatagrams + /// The unreliable datagram writer, present when the underlying transport supports unreliable datagrams. + var datagramWriter: Disconnected? + #endif + public mutating func write( buffer: inout some RangeReplaceableContainer & ~Copyable ) async throws(WriteFailure) { @@ -107,6 +142,41 @@ extension NIOHTTPServer.ResponseSender { try await self.writer.write(.end(finalElement)) self.writerState.wrapped.withLock { $0.finishedWriting = true } } + + #if HTTP3 && UnstableHTTPDatagrams + /// Vends this reliable stream writer and the unreliable datagram writer to the provided `body` closure, and + /// finishes the reliable stream afterwards, only if not already finished by the caller in the `body` closure. + /// + /// - Parameter body: A closure that receives the reliable stream writer and the datagram writer. The datagram + /// writer is `nil` when the underlying transport does not support unreliable datagrams. + /// + /// - Note: Both writers are passed as `consuming sending`. This means that `body` can use both writers in + /// separate tasks. + public consuming func withDatagramWriter( + _ body: (consuming sending Self, consuming sending NIOHTTPServer.DatagramWriter?) async throws -> Void + ) async throws { + let streamFinish = NIOHTTPServer.StreamFinish(writer: self.writer, state: self.writerState) + + let datagramWriter = self.datagramWriter.take()?.take() + + let streamWriter = NIOHTTPServer.ResponseSender.Writer( + writer: self.writer, + writerState: self.writerState, + datagramWriter: nil + ) + + do { + try await body(streamWriter, datagramWriter) + } catch { + try? await streamFinish.finish() + // TODO: Also finish the datagram stream once the datagram writer API is known. + throw error + } + + try await streamFinish.finish() + // TODO: Also finish the datagram stream once the datagram writer API is known. + } + #endif // HTTP3 && UnstableHTTPDatagrams } } diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerReaderTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerReaderTests.swift index 2546664..7d6324b 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerReaderTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerReaderTests.swift @@ -204,4 +204,67 @@ struct NIOHTTPServerReaderTests { } } } + + #if HTTP3 && UnstableHTTPDatagrams + @Test("withDatagramReader vends no datagram reader when not available") + @available(anyAppleOS 26.0, *) + func withDatagramReaderVendsNilWhenNotAvailable() async throws { + let (stream, source) = NIOAsyncChannelInboundStream.makeTestingStream() + source.yield(.body(ByteBuffer(bytes: [1, 2, 3]))) + source.yield(.end(nil)) + source.finish() + + let reader = NIOHTTPServer.Reader(readerState: .init(iterator: stream.makeAsyncIterator())) + + var collected: [UInt8] = [] + try await reader.withDatagramReader { requestBodyReader, datagramReader in + if case .some = datagramReader { + Issue.record("Unexpectedly received a datagram reader.") + } + + // The request body reader should still be usable. + var requestBodyReader = requestBodyReader + try await requestBodyReader.read { buffer, _ in + for index in buffer.indices { collected.append(buffer[index]) } + } + } + + #expect(collected == [1, 2, 3]) + } + + @Test("withDatagramReader vends a request body and datagram reader") + @available(anyAppleOS 26.0, *) + func withDatagramReaderVendsRequestAndDatagramReader() async throws { + let (stream, source) = NIOAsyncChannelInboundStream.makeTestingStream() + source.yield(.body(ByteBuffer(bytes: [1, 2, 3]))) + source.yield(.end(nil)) + source.finish() + + let reader = NIOHTTPServer.Reader( + readerState: .init(iterator: stream.makeAsyncIterator()), + datagramReader: Disconnected(value: NIOHTTPServer.DatagramReader()) + ) + + var collected: [UInt8] = [] + try await reader.withDatagramReader { requestBodyReader, datagramReader in + var requestBodyReader = requestBodyReader + try await requestBodyReader.read { buffer, _ in + for index in buffer.indices { collected.append(buffer[index]) } + } + + guard var datagramReader = datagramReader else { + Issue.record("Expected a datagram reader but received `nil`.") + return + } + + // TODO: The underlying unreliable datagrams transport is not yet implemented. + let error = try await #require(throws: EitherError.self) { + try await datagramReader.read { _, _ in } + } + try #require(throws: DatagramsError.notImplemented) { try error.unwrap() } + } + + #expect(collected == [1, 2, 3]) + } + #endif // HTTP3 && UnstableHTTPDatagrams } diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerWriterTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerWriterTests.swift index 6ed44da..936e4a8 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerWriterTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerWriterTests.swift @@ -81,6 +81,100 @@ struct NIOHTTPServerWriterTests { let trailer = try #require(await responseIterator.next()) #expect(trailer == .end(self.trailerSampleTwo)) } + + #if HTTP3 && UnstableHTTPDatagrams + @Test("withDatagramWriter vends no datagram writer when not available") + @available(anyAppleOS 26.0, *) + func withDatagramWriterVendsNilWhenNotAvailable() async throws { + let (outboundWriter, sink) = NIOAsyncChannelOutboundWriter.makeTestingWriter() + let sender = NIOHTTPServer.ResponseSender( + writer: outboundWriter, + writerState: .init(), + datagramWriter: nil + ) + + let writer = try await sender.send(.init(status: .ok)) + + try await writer.withDatagramWriter { responseBodyWriter, datagramWriter in + if case .some = datagramWriter { + Issue.record("Unexpectedly received a datagram writer.") + } + + // The response body writer should still be usable. + var responseBodyWriter = responseBodyWriter + var testBuffer = UniqueArray(repeating: 5, count: 10) + try await responseBodyWriter.write(buffer: &testBuffer) + } + + var responseIterator = sink.makeAsyncIterator() + let head = try #require(await responseIterator.next()) + let body = try #require(await responseIterator.next()) + let end = try #require(await responseIterator.next()) + + #expect(head == .head(.init(status: .ok))) + #expect(body == .body(.init(repeating: 5, count: 10))) + #expect(end == .end(nil)) + } + + @Test("withDatagramWriter vends a response body and datagram writer") + @available(anyAppleOS 26.0, *) + func withDatagramWriterVendsResponseAndDatagramWriter() async throws { + let (outboundWriter, sink) = NIOAsyncChannelOutboundWriter.makeTestingWriter() + let sender = NIOHTTPServer.ResponseSender( + writer: outboundWriter, + writerState: .init(), + datagramWriter: Disconnected(value: NIOHTTPServer.DatagramWriter()) + ) + + let writer = try await sender.send(.init(status: .ok)) + try await writer.withDatagramWriter { responseBodyWriter, datagramWriter in + var responseBodyWriter = responseBodyWriter + var testBuffer = UniqueArray(repeating: 5, count: 10) + try await responseBodyWriter.write(buffer: &testBuffer) + + guard var datagramWriter = datagramWriter else { + Issue.record("Expected a datagram writer but received `nil`.") + return + } + + // TODO: The underlying unreliable datagrams transport is not yet implemented. + await #expect(throws: DatagramsError.notImplemented) { + var emptyBuffer = UniqueArray() + try await datagramWriter.write(buffer: &emptyBuffer) + } + } + + var responseIterator = sink.makeAsyncIterator() + let head = try #require(await responseIterator.next()) + let body = try #require(await responseIterator.next()) + let end = try #require(await responseIterator.next()) + + #expect(head == .head(.init(status: .ok))) + #expect(body == .body(.init(repeating: 5, count: 10))) + #expect(end == .end(nil)) + } + + @Test("withDatagramWriter finishes the reliable stream after body is executed") + @available(anyAppleOS 26.0, *) + func withDatagramWriterFinishesStream() async throws { + let (outboundWriter, sink) = NIOAsyncChannelOutboundWriter.makeTestingWriter() + let sender = NIOHTTPServer.ResponseSender( + writer: outboundWriter, + writerState: .init(), + datagramWriter: Disconnected(value: NIOHTTPServer.DatagramWriter()) + ) + + let writer = try await sender.send(.init(status: .ok)) + try await writer.withDatagramWriter { _, _ in } + + var responseIterator = sink.makeAsyncIterator() + let head = try #require(await responseIterator.next()) + let end = try #require(await responseIterator.next()) + + #expect(head == .head(.init(status: .ok))) + #expect(end == .end(nil)) + } + #endif // HTTP3 && UnstableHTTPDatagrams } extension HTTPField.Name {