Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ var traits: Set<Trait> = [
name: "HTTP3",
description: "Enables HTTP/3 support"
),
.trait(
name: "UnstableHTTPDatagrams",
description: "Enables support for reading and writing unreliable HTTP datagrams"
),
]

let defaultTraits: Set<String> = ["Configuration"]
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
190 changes: 190 additions & 0 deletions Sources/NIOHTTPServer/Datagrams/ConnectUDPExample.swift
Original file line number Diff line number Diff line change
@@ -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<UInt8>()
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<UInt8>()
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<Value: ~Copyable> {
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
76 changes: 76 additions & 0 deletions Sources/NIOHTTPServer/Datagrams/NIOHTTPServer+Datagrams.swift
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the element be a datagram instead of a byte?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be, although I wonder if we want our own type here or if (at least for now) we'd be okay reusing NIOH3's Datagram type. I'll keep this as is right now and we can decide when we integrate with NIOH3.

public typealias Buffer = UniqueArray<UInt8>
public typealias ReadFailure = any Error
public typealias FinalElement = Void

public mutating func read<Return: ~Copyable, Failure: Error>(
body: (inout Buffer, consuming FinalElement?) async throws(Failure) -> Return
) async throws(EitherError<ReadFailure, Failure>) -> 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<Buffer: RangeReplaceableContainer<WriteElement> & ~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<Buffer: RangeReplaceableContainer<WriteElement> & ~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
16 changes: 12 additions & 4 deletions Sources/NIOHTTPServer/NIOHTTPServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
33 changes: 31 additions & 2 deletions Sources/NIOHTTPServer/NIOHTTPServerReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,31 @@ extension NIOHTTPServer {
/// (while keeping its capacity) at the start of every read.
private var buffer: UniqueArray<UInt8>

/// 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<UInt8>()
}

#if HTTP3 && UnstableHTTPDatagrams
/// The unreliable datagram reader, present when the underlying transport is capable of reading/writing
/// unreliable datagrams.
private var datagramReader: Disconnected<NIOHTTPServer.DatagramReader?>?

/// 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<UInt8>()
self.datagramReader = Disconnected(value: datagramReader)
}
#endif

public mutating func read<Return: ~Copyable, Failure: Error>(
body: (inout Buffer, consuming HTTPFields??) async throws(Failure) -> Return
) async throws(EitherError<ReadFailure, Failure>) -> Return {
Expand Down Expand Up @@ -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
Loading
Loading