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
142 changes: 142 additions & 0 deletions Sources/HTTPServer/NIOHTTPServer+ListeningAddress.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift HTTP Server open source project
//
// Copyright (c) 2025 Apple Inc. and the Swift HTTP Server project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import NIOConcurrencyHelpers
import NIOCore
import NIOPosix

enum ListeningAddressError: CustomStringConvertible, Error {
case addressOrPortNotAvailable
case unsupportedAddressType
case serverClosed

var description: String {
switch self {
case .addressOrPortNotAvailable:
return "Unable to retrieve the bound address or port from the underlying socket"
case .unsupportedAddressType:
return "Unsupported address type: only IPv4 and IPv6 are supported"
case .serverClosed:
return """
There is no listening address bound for this server: there may have been an error which caused the server to close, or it may have shut down.
"""
}
}
}

@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *)
extension NIOHTTPServer {
func addressBound(_ address: NIOCore.SocketAddress?) throws {
switch self.listeningAddressState.withLockedValue({ $0.addressBound(address) }) {
case .succeedPromise(let promise, let boundAddress):
promise.succeed(boundAddress)
case .failPromise(let promise, let error):
promise.fail(error)
}
}

/// The address the server is listening from.
///
/// It is an `async` property because it will only return once the address has been successfully bound.
///
/// - Throws: An error will be thrown if the address could not be bound or is not bound any longer because the
/// server isn't listening anymore.
public var listeningAddress: SocketAddress {
get async throws {
try await self.listeningAddressState
.withLockedValue { try $0.listeningAddressFuture }
.get()
}
}
}

@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *)
extension NIOHTTPServer {
enum State {
case idle(EventLoopPromise<SocketAddress>)
case listening(EventLoopFuture<SocketAddress>)
case closedOrInvalidAddress(ListeningAddressError)

var listeningAddressFuture: EventLoopFuture<SocketAddress> {
get throws {
switch self {
case .idle(let eventLoopPromise):
return eventLoopPromise.futureResult
case .listening(let eventLoopFuture):
return eventLoopFuture
case .closedOrInvalidAddress(let error):
throw error
}
}
}

enum OnBound {
case succeedPromise(_ promise: EventLoopPromise<SocketAddress>, address: SocketAddress)
case failPromise(_ promise: EventLoopPromise<SocketAddress>, error: ListeningAddressError)
}

mutating func addressBound(_ address: NIOCore.SocketAddress?) -> OnBound {
switch self {
case .idle(let listeningAddressPromise):
do {
let socketAddress = try SocketAddress(address)
self = .listening(listeningAddressPromise.futureResult)
return .succeedPromise(listeningAddressPromise, address: socketAddress)
} catch {
self = .closedOrInvalidAddress(error)
return .failPromise(listeningAddressPromise, error: error)
}

case .listening, .closedOrInvalidAddress:
fatalError("Invalid state: addressBound should only be called once and when in idle state")
}
}

enum OnClose {
case failPromise(_ promise: EventLoopPromise<SocketAddress>, error: ListeningAddressError)
case doNothing
}

mutating func close() -> OnClose {
switch self {
case .idle(let listeningAddressPromise):
self = .closedOrInvalidAddress(.serverClosed)
return .failPromise(listeningAddressPromise, error: .serverClosed)

case .listening:
self = .closedOrInvalidAddress(.serverClosed)
return .doNothing

case .closedOrInvalidAddress:
return .doNothing
}
}
}
}

extension SocketAddress {
fileprivate init(_ address: NIOCore.SocketAddress?) throws(ListeningAddressError) {
guard let address, let port = address.port else {
throw ListeningAddressError.addressOrPortNotAvailable
}

switch address {
case .v4(let ipv4Address):
self.init(base: .ipv4(.init(host: ipv4Address.host, port: port)))
case .v6(let ipv6Address):
self.init(base: .ipv6(.init(host: ipv6Address.host, port: port)))
case .unixDomainSocket:
throw ListeningAddressError.unsupportedAddressType
}
}
}
24 changes: 22 additions & 2 deletions Sources/HTTPServer/NIOHTTPServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
//
//===----------------------------------------------------------------------===//

public import Logging
import HTTPTypes
public import Logging
import NIOCertificateReloading
import NIOConcurrencyHelpers
import NIOCore
import NIOHTTP1
import NIOHTTP2
Expand All @@ -22,9 +23,9 @@ import NIOHTTPTypesHTTP1
import NIOHTTPTypesHTTP2
import NIOPosix
import NIOSSL
import X509
import SwiftASN1
import Synchronization
import X509

/// A generic HTTP server that can handle incoming HTTP requests.
///
Expand Down Expand Up @@ -82,6 +83,8 @@ public struct NIOHTTPServer: HTTPServerProtocol {
private let logger: Logger
private let configuration: HTTPServerConfiguration

var listeningAddressState: NIOLockedValueBox<State>

/// Create a new ``HTTPServer`` implemented over `SwiftNIO`.
/// - Parameters:
/// - logger: A logger instance for recording server events and debugging information.
Expand All @@ -92,6 +95,10 @@ public struct NIOHTTPServer: HTTPServerProtocol {
) {
self.logger = logger
self.configuration = configuration

// TODO: If we allow users to pass in an event loop, use that instead of the singleton MTELG.
let eventLoopGroup: MultiThreadedEventLoopGroup = .singletonMultiThreadedEventLoopGroup
self.listeningAddressState = .init(.idle(eventLoopGroup.any().makePromise()))
}

/// Starts an HTTP server with the specified request handler.
Expand Down Expand Up @@ -133,6 +140,15 @@ public struct NIOHTTPServer: HTTPServerProtocol {
/// )
/// ```
public func serve(handler: some HTTPServerRequestHandler<RequestReader, ResponseWriter>) async throws {
defer {
switch self.listeningAddressState.withLockedValue({ $0.close() }) {
case .failPromise(let promise, let error):
promise.fail(error)
case .doNothing:
()
}
}

let asyncChannelConfiguration: NIOAsyncChannel<HTTPRequestPart, HTTPResponsePart>.Configuration
switch self.configuration.backpressureStrategy.backing {
case .watermark(let low, let high):
Expand Down Expand Up @@ -305,6 +321,8 @@ public struct NIOHTTPServer: HTTPServerProtocol {
}
}

try self.addressBound(serverChannel.channel.localAddress)

try await withThrowingDiscardingTaskGroup { group in
try await serverChannel.executeThenClose { inbound in
for try await http1Channel in inbound {
Expand Down Expand Up @@ -367,6 +385,8 @@ public struct NIOHTTPServer: HTTPServerProtocol {
}
}

try self.addressBound(serverChannel.channel.localAddress)

try await withThrowingDiscardingTaskGroup { group in
try await serverChannel.executeThenClose { inbound in
for try await upgradeResult in inbound {
Expand Down
88 changes: 88 additions & 0 deletions Sources/HTTPServer/SocketAddress.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift HTTP Server open source project
//
// Copyright (c) 2025 Apple Inc. and the Swift HTTP Server project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import NIOCore
Copy link
Contributor

Choose a reason for hiding this comment

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

We don't need the NIO import here right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Sorry, I missed this. Fixed in #34.


/// Represents an IPv4 address.
public struct IPv4: Hashable, Sendable {
Copy link
Contributor

Choose a reason for hiding this comment

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

I am a bit hesitant to add these types to this package. This is probably something that is generally useful and might require some API design on the HTTPServer protocol over in the other repo. WDYT @gjcairo?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah, I don't think this should be part of the NIO implementation only. So yes it would make sense to move them to the other repo. Would you be okay with having these here for now, and I can open a PR on the other repo as well? Then once we land them there we can remove them from here alongside the other abstract pieces.

Also: would SocketAddress be something that's part of the currency types we'd like to have?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah it is okay to land them here for now but let's file an issue on the other repo. Ideally SocketAddress or something similar is a shared currency type.

Copy link
Collaborator

Choose a reason for hiding this comment

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

/// The resolved host address.
public var host: String
/// The port to connect to.
public var port: Int

/// Creates a new IPv4 address.
///
/// - Parameters:
/// - host: Resolved host address.
/// - port: Port to connect to.
public init(host: String, port: Int) {
self.host = host
self.port = port
}
}

/// Represents an IPv6 address.
public struct IPv6: Hashable, Sendable {
/// The resolved host address.
public var host: String
/// The port to connect to.
public var port: Int

/// Creates a new IPv6 address.
///
/// - Parameters:
/// - host: Resolved host address.
/// - port: Port to connect to.
public init(host: String, port: Int) {
self.host = host
self.port = port
}
}

/// An address to which a socket may connect or bind to.
public struct SocketAddress: Hashable, Sendable {
enum Base: Hashable, Sendable {
case ipv4(IPv4)
case ipv6(IPv6)
}

let base: Base

/// Creates an IPv4 socket address.
public static func ipv4(host: String, port: Int) -> Self {
Self(base: .ipv4(.init(host: host, port: port)))
}

/// Creates an IPv6 socket address.
public static func ipv6(host: String, port: Int) -> Self {
Self(base: .ipv6(.init(host: host, port: port)))
}

/// Returns the address as an IPv4 address, if possible.
public var ipv4: IPv4? {
guard case .ipv4(let address) = self.base else {
return nil
}

return address
}

/// Returns the address as an IPv6 address, if possible.
public var ipv6: IPv6? {
guard case .ipv6(let address) = self.base else {
return nil
}

return address
}
}
57 changes: 57 additions & 0 deletions Tests/HTTPServerTests/NIOHTTPServerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift HTTP Server open source project
//
// Copyright (c) 2025 Apple Inc. and the Swift HTTP Server project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import HTTPTypes
import Logging
import NIOCore
import NIOHTTP1
import NIOHTTPTypes
import NIOPosix
import Testing

@testable import HTTPServer

#if canImport(Dispatch)
import Dispatch
#endif

@Suite
struct NIOHTTPServerTests {
@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *)
@Test("Obtain the listening address correctly")
func testListeningAddress() async throws {
let server = NIOHTTPServer(
logger: Logger(label: "Test"),
configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 1234))
)

try await withThrowingTaskGroup { group in
group.addTask {
try await server.serve { _, _, _, _ in }
}

let serverAddress = try await server.listeningAddress

let address = try #require(serverAddress.ipv4)
#expect(address.host == "127.0.0.1")
#expect(address.port == 1234)

group.cancelAll()
}

// Now that the server has shut down, try obtaining the listening address. This should result in an error.
await #expect(throws: ListeningAddressError.serverClosed) {
try await server.listeningAddress
}
}
}
Loading