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..8a6cd37 --- /dev/null +++ b/Sources/NIOHTTPServer/Datagrams/ConnectUDPExample.swift @@ -0,0 +1,190 @@ +//===----------------------------------------------------------------------===// +// +// 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 streamReader = reader + let maybeDatagramReader = streamReader.takeDatagramReader() + + // 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] = [] + + 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. + let streamReaderBox = RefBox(value: Disconnected(value: streamReader)) + let datagramReaderBox = RefBox(value: Disconnected(value: datagramReader)) + + // Now accept the request and access the datagram writer through the response writer. + var streamWriter = try await responseSender.send(ConnectUDPHelper.makeSuccessResponse(version: context.httpVersion)) + let datagramWriter = streamWriter.takeDatagramWriter() + + let streamWriterBox = RefBox(value: Disconnected(value: streamWriter)) + let datagramWriterBox = RefBox(value: Disconnected(value: datagramWriter)) + + await withThrowingTaskGroup { group in + var unwrappedStreamWriter = streamWriterBox.unbox().take() + var unwrappedStreamReader = streamReaderBox.unbox().take() + var unwrappedDatagramReader = datagramReaderBox.unbox().take() + + // Write to the reliable stream. + group.addTask { + var emptyBuffer = UniqueArray() + try await unwrappedStreamWriter.write(buffer: &emptyBuffer) + } + + var disconnectedDatagramWriter = datagramWriterBox.unbox() + 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 + () + } + } + + // Read from the unreliable stream. + group.addTask { + try await unwrappedDatagramReader.read { _, _ in + () + } + } + } +} + +/// A Copyable, Sendable box for transferring a ~Copyable value across escaping +/// closure boundaries. +/// +/// Store a ~Copyable value with ``init(value:)``, then retrieve it exactly once +/// with ``unbox()``. +// TODO: Remove RefBox once Swift gains "called once" closures (SE-0528 future direction). +// Until then, ~Copyable values cannot be captured by @escaping closures (like addTask), +// so this class provides a Copyable + Sendable wrapper for cross-task transfer. +final class RefBox { + private nonisolated(unsafe) var value: Value? + + public init(value: consuming Value) { + unsafe self.value = consume value + } + + public consuming func unbox() -> Value { + unsafe value.take()! + } +} +extension RefBox: Sendable where Value: Sendable & ~Copyable {} + +@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..1370b82 --- /dev/null +++ b/Sources/NIOHTTPServer/Datagrams/NIOHTTPServer+Datagrams.swift @@ -0,0 +1,76 @@ +//===----------------------------------------------------------------------===// +// +// 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 {} + +#endif // HTTP3 && UnstableHTTPDatagrams 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..76071c4 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 sending NIOHTTPServer.DatagramReader? = nil + ) { + self.state = readerState + self.iterator = readerState.takeIterator() + self.buffer = UniqueArray() + self.datagramReader = Disconnected(value: datagramReader) + } + #endif + public mutating func read( body: (inout Buffer, consuming HTTPFields??) async throws(Failure) -> Return ) async throws(EitherError) -> Return { @@ -121,3 +138,15 @@ extension NIOHTTPServer { @available(*, unavailable) extension NIOHTTPServer.Reader: Sendable {} + +#if HTTP3 && UnstableHTTPDatagrams +@available(anyAppleOS 26.0, *) +extension NIOHTTPServer.Reader { + /// Returns the unreliable datagram reader for this stream, if there is one. + /// + /// - Important: A reader will be returned only the first time this function is invoked. Any successive calls will yield `nil`. + public mutating func takeDatagramReader() -> sending NIOHTTPServer.DatagramReader? { + self.datagramReader?.swap(newValue: nil) + } +} +#endif // HTTP3 && UnstableHTTPDatagrams diff --git a/Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift b/Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift index 2e7d898..7e83b9f 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift @@ -22,15 +22,49 @@ 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 sending NIOHTTPServer.DatagramWriter? = nil + ) { + self.writer = writer + self.writerState = writerState + self.datagramWriter = Disconnected(value: datagramWriter) + } + #endif + public mutating func sendInformational(_ response: HTTPResponse) async throws { precondition(response.status.kind == .informational) try await self.writer.write(.head(response)) } - public consuming func send(_ response: HTTPResponse) async throws -> Writer { + public consuming func send(_ response: HTTPResponse) async throws -> sending 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 +91,29 @@ extension NIOHTTPServer.ResponseSender { let writerState: WriterState + init( + writer: NIOAsyncChannelOutboundWriter, + writerState: WriterState + ) { + self.writer = writer + self.writerState = writerState + } + + #if HTTP3 && UnstableHTTPDatagrams + /// The unreliable datagram writer, present when the underlying transport supports unreliable datagrams. + private var datagramWriter: Disconnected? + + init( + writer: NIOAsyncChannelOutboundWriter, + writerState: WriterState, + datagramWriter: consuming Disconnected? = nil + ) { + self.writer = writer + self.writerState = writerState + self.datagramWriter = datagramWriter + } + #endif + public mutating func write( buffer: inout some RangeReplaceableContainer & ~Copyable ) async throws(WriteFailure) { @@ -107,6 +164,16 @@ extension NIOHTTPServer.ResponseSender { try await self.writer.write(.end(finalElement)) self.writerState.wrapped.withLock { $0.finishedWriting = true } } + + #if HTTP3 && UnstableHTTPDatagrams + /// Returns the unreliable datagram writer for this stream, if there is one. + /// + /// - Important: A writer will be returned only the first time this function is invoked. + /// Any successive calls will yield `nil`. + public mutating func takeDatagramWriter() -> sending NIOHTTPServer.DatagramWriter? { + self.datagramWriter?.swap(newValue: nil) + } + #endif // HTTP3 && UnstableHTTPDatagrams } } diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerReaderTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerReaderTests.swift index 2546664..d5f3f9a 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerReaderTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerReaderTests.swift @@ -204,4 +204,65 @@ struct NIOHTTPServerReaderTests { } } } + + #if HTTP3 && UnstableHTTPDatagrams + @Test("takeDatagramReader vends no datagram reader when not available") + @available(anyAppleOS 26.0, *) + func takeDatagramReaderVendsNilWhenNotAvailable() async throws { + let (stream, source) = NIOAsyncChannelInboundStream.makeTestingStream() + source.yield(.body(ByteBuffer(bytes: [1, 2, 3]))) + source.yield(.end(nil)) + source.finish() + + var requestBodyReader = NIOHTTPServer.Reader(readerState: .init(iterator: stream.makeAsyncIterator())) + + let datagramReader = requestBodyReader.takeDatagramReader() + var collected: [UInt8] = [] + + if case .some = datagramReader { + Issue.record("Unexpectedly received a datagram reader.") + } + + // The request body reader should still be usable. + try await requestBodyReader.read { buffer, _ in + for index in buffer.indices { collected.append(buffer[index]) } + } + + #expect(collected == [1, 2, 3]) + } + + @Test("takeDatagramReader vends a request body and datagram reader") + @available(anyAppleOS 26.0, *) + func takeDatagramReaderVendsRequestAndDatagramReader() async throws { + let (stream, source) = NIOAsyncChannelInboundStream.makeTestingStream() + source.yield(.body(ByteBuffer(bytes: [1, 2, 3]))) + source.yield(.end(nil)) + source.finish() + + var requestBodyReader = NIOHTTPServer.Reader( + readerState: .init(iterator: stream.makeAsyncIterator()), + datagramReader: NIOHTTPServer.DatagramReader() + ) + + let datagramReader = requestBodyReader.takeDatagramReader() + var collected: [UInt8] = [] + + 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..d414f18 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerWriterTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerWriterTests.swift @@ -81,6 +81,76 @@ struct NIOHTTPServerWriterTests { let trailer = try #require(await responseIterator.next()) #expect(trailer == .end(self.trailerSampleTwo)) } + + #if HTTP3 && UnstableHTTPDatagrams + @Test("takeDatagramWriter vends no datagram writer when not available") + @available(anyAppleOS 26.0, *) + func takeDatagramWriterVendsNilWhenNotAvailable() async throws { + let (outboundWriter, sink) = NIOAsyncChannelOutboundWriter.makeTestingWriter() + let sender = NIOHTTPServer.ResponseSender( + writer: outboundWriter, + writerState: .init(), + datagramWriter: nil + ) + + var responseBodyWriter = try await sender.send(.init(status: .ok)) + let datagramWriter = responseBodyWriter.takeDatagramWriter() + + if case .some = datagramWriter { + Issue.record("Unexpectedly received a datagram writer.") + } + + // The response body writer should still be usable. + var testBuffer = UniqueArray(repeating: 5, count: 10) + try await responseBodyWriter.finish(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("takeDatagramWriter vends a response body and datagram writer") + @available(anyAppleOS 26.0, *) + func takeDatagramWriterVendsResponseAndDatagramWriter() async throws { + let (outboundWriter, sink) = NIOAsyncChannelOutboundWriter.makeTestingWriter() + let sender = NIOHTTPServer.ResponseSender( + writer: outboundWriter, + writerState: .init(), + datagramWriter: NIOHTTPServer.DatagramWriter() + ) + + var responseBodyWriter = try await sender.send(.init(status: .ok)) + let datagramWriter = responseBodyWriter.takeDatagramWriter() + + var testBuffer = UniqueArray(repeating: 5, count: 10) + try await responseBodyWriter.finish(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)) + } + #endif // HTTP3 && UnstableHTTPDatagrams } extension HTTPField.Name {