emergetools/flyingfox
**FlyingFox** is a lightweight HTTP server built using [Swift Concurrency](https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html). The server uses non blocking BSD sockets, handling each connection in a concurrent child [Task](https://developer.apple.com/documentation/
Handlers
Handlers can be added to the server by implementing HTTPHandler:
protocol HTTPHandler {
func handleRequest(_ request: HTTPRequest) async throws -> HTTPResponse
}Routes can be added to the server delegating requests to a handler:
await server.appendRoute("/hello", to: handler)They can also be added to closures:
await server.appendRoute("/hello") { request in
try await Task.sleep(nanoseconds: 1_000_000_000)
return HTTPResponse(statusCode: .ok)
}Incoming requests are routed to the handler of the first matching route.
Handlers can throw HTTPUnhandledError if after inspecting the request, they cannot handle it. The next matching route is then used.
Requests that do not match any handled route receive HTTP 404.
FileHTTPHandler
Requests can be routed to static files with FileHTTPHandler:
await server.appendRoute("GET /mock", to: .file(named: "mock.json"))FileHTTPHandler will return HTTP 404 if the file does not exist.
DirectoryHTTPHandler
Requests can be routed to static files within a directory with DirectoryHTTPHandler:
await server.appendRoute("GET /mock/*", to: .directory(subPath: "Stubs", serverPath: "mock"))
// GET /mock/fish/index.html ----> Stubs/fish/index.htmlDirectoryHTTPHandler will return HTTP 404 if a file does not exist.
ProxyHTTPHandler
Requests can be proxied via a base URL:
await server.appendRoute("GET *", to: .proxy(via: "https://pie.dev"))
// GET /get?fish=chips ----> GET https://pie.dev/get?fish=chipsRedirectHTTPHandler
Requests can be redirected to a URL:
await server.appendRoute("GET /fish/*", to: .redirect(to: "https://pie.dev/get"))
// GET /fish/chips ---> HTTP 301
// Location: https://pie.dev/getWebSocketHTTPHandler
Requests can be routed to a websocket by providing a WSMessageHandler where a pair of AsyncStream<WSMessage> are exchanged:
await server.appendRoute("GET /socket", to: .webSocket(EchoWSMessageHandler()))
protocol WSMessageHandler {
func makeMessages(for client: AsyncStream<WSMessage>) async throws -> AsyncStream<WSMessage>
}
enum WSMessage {
case text(String)
case data(Data)
}Raw WebSocket frames can also be provided.
RoutedHTTPHandler
Multiple handlers can be grouped with requests and matched against HTTPRoute using RoutedHTTPHandler.
var routes = RoutedHTTPHandler()
routes.appendRoute("GET /fish/chips", to: .file(named: "chips.json"))
routes.appendRoute("GET /fish/mushy_peas", to: .file(named: "mushy_peas.json"))
await server.appendRoute(for: "GET /fish/*", to: routes)HTTPUnhandledError is thrown when it's unable to handle the request with any of its registered handlers.
Routes
HTTPRoute is designed to be pattern matched against HTTPRequest, allowing requests to be identified by some or all of its properties.
let route = HTTPRoute("/hello/world")
route ~= HTTPRequest(method: .GET, path: "/hello/world") // true
route ~= HTTPRequest(method: .POST, path: "/hello/world") // true
route ~= HTTPRequest(method: .GET, path: "/hello/") // falseRoutes are ExpressibleByStringLiteral allowing literals to be automatically converted to HTTPRoute:
let route: HTTPRoute = "/hello/world"Routes can include a specific method to match against:
let route = HTTPRoute("GET /hello/world")
route ~= HTTPRequest(method: .GET, path: "/hello/world") // true
route ~= HTTPRequest(method: .POST, path: "/hello/world") // falseThey can also use wildcards within the path:
let route = HTTPRoute("GET /hello/*/world")
route ~= HTTPRequest(method: .GET, path: "/hello/fish/world") // true
route ~= HTTPRequest(method: .GET, path: "/hello/dog/world") // true
route ~= HTTPRequest(method: .GET, path: "/hello/fish/sea") // falseTrailing wildcards match all trailing path components:
let route = HTTPRoute("/hello/*")
route ~= HTTPRequest(method: .GET, path: "/hello/fish/world") // true
route ~= HTTPRequest(method: .GET, path: "/hello/dog/world") // true
route ~= HTTPRequest(method: .POST, path: "/hello/fish/deep/blue/sea") // trueSpecific query items can be matched:
let route = HTTPRoute("/hello?time=morning")
route ~= HTTPRequest(method: .GET, path: "/hello?time=morning") // true
route ~= HTTPRequest(method: .GET, path: "/hello?count=one&time=morning") // true
route ~= HTTPRequest(method: .GET, path: "/hello") // false
route ~= HTTPRequest(method: .GET, path: "/hello?time=afternoon") // falseQuery item values can include wildcards:
let route = HTTPRoute("/hello?time=*")
route ~= HTTPRequest(method: .GET, path: "/hello?time=morning") // true
route ~= HTTPRequest(method: .GET, path: "/hello?time=afternoon") // true
route ~= HTTPRequest(method: .GET, path: "/hello") // falseHTTP headers can be matched:
let route = HTTPRoute("*", headers: [.contentType: "application/json"])
route ~= HTTPRequest(headers: [.contentType: "application/json"]) // true
route ~= HTTPRequest(headers: [.contentType: "application/xml"]) // falseHeader values can be wildcards:
let route = HTTPRoute("*", headers: [.authorization: "*"])
route ~= HTTPRequest(headers: [.authorization: "abc"]) // true
route ~= HTTPRequest(headers: [.authorization: "xyz"]) // true
route ~= HTTPRequest(headers: [:]) // falseBody patterns can be created to match the request body data:
public protocol HTTPBodyPattern: Sendable {
func evaluate(_ body: Data) -> Bool
}Darwin platforms can pattern match a JSON body with an NSPredicate:
let route = HTTPRoute("POST *", body: .json(where: "food == 'fish'")){"side": "chips", "food": "fish"}WebSockets
HTTPResponse can switch the connection to the WebSocket protocol by provding a WSHandler within the response payload.
protocol WSHandler {
func makeFrames(for client: AsyncThrowingStream<WSFrame, Error>) async throws -> AsyncStream<WSFrame>
}WSHandler facilitates the exchange of a pair AsyncStream<WSFrame> containing the raw websocket frames sent over the connection. While powerful, it is more convenient to exchange streams of messages via WebSocketHTTPHandler.
Preview Macro Handler
The branch preview/macro contains an experimental preview implementation where handlers can annotate functions with routes:
@HTTPHandler
struct MyHandler {
@HTTPRoute("/ping")
func ping() { }
@HTTPRoute("/pong")
func getPong(_ request: HTTPRequest) -> HTTPResponse {
HTTPResponse(statusCode: .accepted)
}
@JSONRoute("POST /account")
func createAccount(body: AccountRequest) -> AccountResponse {
AccountResponse(id: UUID(), balance: body.balance)
}
}
let server = HTTPServer(port: 80, handler: MyHandler())
try await server.start()The annotations are implemented via SE-0389 Attached Macros available in Swift 5.9 and later.
Read more here.
FlyingSocks
Internally, FlyingFox uses a thin wrapper around standard BSD sockets. The FlyingSocks module provides a cross platform async interface to these sockets;
import FlyingSocks
let socket = try await AsyncSocket.connected(to: .inet(ip4: "192.168.0.100", port: 80))
try await socket.write(Data([0x01, 0x02, 0x03]))
try socket.close()Socket
Socket wraps a file descriptor and provides a Swift interface to common operations, throwing SocketError instead of returning error codes.
public enum SocketError: LocalizedError {
case blocked
case disconnected
case unsupportedAddress
case failed(type: String, errno: Int32, message: String)
}When data is unavailable for a socket and the EWOULDBLOCK errno is returned, then SocketError.blocked is thrown.
AsyncSocket
AsyncSocket simply wraps a Socket and provides an async interface. All async sockets are configured with the flag O_NONBLOCK, catching SocketError.blocked and then suspending the current task using an AsyncSocketPool. When data becomes available the task is resumed and AsyncSocket will retry the operation.
AsyncSocketPool
protocol AsyncSocketPool {
func prepare() async throws
func run() async throws
// Suspends current task until a socket is ready to read and/or write
func suspendSocket(_ socket: Socket, untilReadyFor events: Socket.Events) async throws
}SocketPool
SocketPool<Queue> is the default pool used within HTTPServer. It suspends and resume sockets using its generic EventQueue depending on the platform. Abstracting kqueue(2) on Darwin platforms and epoll(7) on Linux, the pool uses kernel events without the need to continuosly poll the waiting file descriptors.
Windows uses a queue backed by a continuous loop of poll(2) / Task.yield() to check all sockets awaiting data at a supplied interval.
SocketAddress
The sockaddr cluster of structures are grouped via conformance to SocketAddress
sockaddr_insockaddr_in6sockaddr_un
This allows HTTPServer to be started with any of these configured addresses:
// only listens on localhost 8080
let server = HTTPServer(address: .loopback(port: 8080))It can also be used with UNIX-domain addresses, allowing private IPC over a socket:
// only listens on Unix socket "Ants"
let server = HTTPServer(address: .unix(path: "Ants"))You can then netcat to the socket:
% nc -U AntsCommand line app
An example command line app FlyingFoxCLI is available here.
Credits
FlyingFox is primarily the work of Simon Whitty.
Package Metadata
Repository: emergetools/flyingfox
Default branch: main
README: README.md