Skip to content

Refactor NIOHTTPServerConfiguration#67

Open
aryan-25 wants to merge 5 commits intoswift-server:mainfrom
aryan-25:config-refactor
Open

Refactor NIOHTTPServerConfiguration#67
aryan-25 wants to merge 5 commits intoswift-server:mainfrom
aryan-25:config-refactor

Conversation

@aryan-25
Copy link
Collaborator

Motivation:

Currently, NIOHTTPServerConfiguration's transportSecurity property implicitly determines the HTTP versions served by NIOHTTPServer:

  • If transportSecurity is .plaintext, an HTTP/1.1 channel handler (over plaintext) is set up.
  • If transportSecurity is .tls or .mTLS (or their certificate-reloading variants), an ALPN channel handler configured with both HTTP/1.1 and HTTP/2 handlers is set up.

As a consequence of this tight coupling, it is currently not possible to configure the server to only serve HTTP/1.1 over TLS.

Modifications:

  • Added a new supportedHTTPVersions field (Set<HTTPVersion>) to NIOHTTPServerConfiguration. This, along with the transportSecurity value, allows for the following configurations to be represented:

    transportSecurity supportedHTTPVersions Result
    .plaintext [.http1_1] HTTP/1.1 over plaintext
    .tls / .mTLS [.http1_1] HTTP/1.1 over (m)TLS
    .tls / .mTLS [.http2(...)] HTTP/2 over (m)TLS
    .tls / .mTLS [.http1_1, .http2(...)] HTTP/1.1 or HTTP/2 over (m)TLS, negotiated via ALPN.
  • Simplified TransportSecurity to three options (plaintext, tls, mTLS):

    • TLS credentials (certificate chain and private key) are nested inside the tls and mTLS cases in a TLSCredentials type which has inMemory, reloading, and pemFile backings.
    • Similarly, mTLS trust configuration is represented through a new MTLSTrustConfiguration type which has systemDefaults, inMemory, pemFile, and customCertificateVerificationCallback backings.
  • Updated NIOHTTPServer based on the changes to the configuration type.

  • Updated the swift-configuration integration to reflect the new structure.

Result:

The HTTP versions supported by NIOHTTPServer can now be configured explicitly through the supportedHTTPVersions property on NIOHTTPServerConfiguration, rather than being implicitly determined by the transport security configuration.

@aryan-25 aryan-25 added the ⚠️ semver/major Breaks existing public API. label Mar 11, 2026
@FranzBusch
Copy link
Contributor

One configuration that this (and the current) configuration don't allow is to configure a single server with HTTP/1 over plaintext and then HTTP/2 over TLS for example. Not sure if we need to support it but might be worth thinking about if we need to allow a per-connection configuration somehow.

@gjcairo gjcairo self-requested a review March 16, 2026 12:54
@gjcairo
Copy link
Collaborator

gjcairo commented Mar 16, 2026

@FranzBusch we considered this, but I'm not sure. Looking at Go's http package and Rust's axum (which I believe is widely used to run servers), as far as I can tell, you need to start separate servers for serving TLS and plaintext endpoints.

In Go, http offers ListenAndServe and ListenAndServeTLS:

http.ListenAndServe(":8080", plaintextHandler)
http.ListenAndServeTLS(":443", "cert.pem", "key.pem", secureHandler)

For Axum it's similar: you either start plaintext servers via axum::serve, or you need to use axum_server::bind_rustls(address, tlsConfig).serve(...) (see docs).

Given this, I think it makes sense to have to create separate servers for plaintext vs TLS connections:

let plaintextServer = NIOHTTPServer(configuration: .init(transportSecurity: .plaintext))
let tlsServer = NIOHTTPServer(configuration: .init(transportSecurity: .tls(...)))

try await withThrowingDiscardingTaskGroup { group in
    group.addTask {
        try await plaintextServer.serve { request, requestContext, requestBodyAndTrailers, responseSender in
            // ...
        }
    }
    group.addTask {
        try await tlsServer.serve { request, requestContext, requestBodyAndTrailers, responseSender in
            // ...
        }
    }
}

)

if versions.isEmpty {
fatalError("Invalid configuration: at least one supported HTTP version must be specified.")
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we should be more consistent about throwing vs fatal-erroring. We throw errors in other scenarios, so we should probably do the same here.

Comment on lines +261 to +267
fatalError(
"Only HTTP/1.1 can be served over plaintext. transportSecurity must be set to (m)TLS for serving HTTP/2."
)
}

if supportedHTTPVersions.isEmpty {
fatalError("Invalid configuration: at least one supported HTTP version must be specified.")
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we should throw errors here instead of fatal erroring. We should try to keep fatal errors for logic errors / theoretically unreachable states. However we could reach these states by user error (i.e. having invalid configs) so we should probably throw instead.

let snapshot = config.snapshot()

let error = #expect(throws: Error.self) {
let configError = try #require(throws: Error.self) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Have we got some more specific error type that gets thrown?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Unfortunately not, as ConfigError is not public.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

⚠️ semver/major Breaks existing public API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants