FlyingFox
Lightweight, HTTP server written in Swift using async/await.
Install / Use
/learn @swhitty/FlyingFoxREADME
Introduction
FlyingFox is a lightweight HTTP server built using Swift Concurrency. The server uses non blocking BSD sockets, handling each connection in a concurrent child Task. When a socket is blocked with no data, tasks are suspended using the shared AsyncSocketPool.
Installation
FlyingFox can be installed by using Swift Package Manager.
Note: FlyingFox requires Swift 5.10 on Xcode 15.4+. It runs on iOS 13+, tvOS 13+, watchOS 8+, macOS 10.15+ and Linux. Android and Windows 10 support is experimental.
To install using Swift Package Manager, add this to the dependencies: section in your Package.swift file:
.package(url: "https://github.com/swhitty/FlyingFox.git", .upToNextMajor(from: "0.26.0"))
Usage
Start the server by providing a port number:
import FlyingFox
let server = HTTPServer(port: 80)
try await server.run()
The server runs within the the current task. To stop the server, cancel the task terminating all connections immediatley:
let task = Task { try await server.run() }
task.cancel()
Gracefully shutdown the server after all existing requests complete, otherwise forcefully closing after a timeout:
await server.stop(timeout: 3)
Wait until the server is listening and ready for connections:
try await server.waitUntilListening()
Retrieve the current listening address:
await server.listeningAddress
Note: iOS will hangup the listening socket when an app is suspended in the background. Once the app returns to the foreground,
HTTPServer.run()detects this, throwingSocketError.disconnected. The server must then be started once more.
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.
Range requests are supported, responding with HTTP 206 Partial Content allowing for efficient streaming of media content:
await server.appendRoute("GET,HEAD /jaws", to: .file(named: "jaws.m4v"))
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.html
DirectoryHTTPHandler 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=chips
RedirectHTTPHandler
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/get
Or dynamically redirected via a base URL:
await server.appendRoute("GET /fish/*", to: .redirect(via: "https://pie.dev"))
// GET /fish/chips ---> HTTP 301
// Location: https://pie.dev/fish/chips
Providing a serverPath allows for the removal of a prefix before redirecting:
await server.appendRoute("GET /fish/*", to: .redirect(via: "https://pie.dev", serverPath: "/fish"))
// GET /fish/chips ---> HTTP 301
// Location: https://pie.dev/chips
WebSocketHTTPHandler
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)
case close(WSCloseCode)
}
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/") // false
Routes 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") // false
They 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") // false
Routes can include parameters that match like wildcards allowing handlers to extract the value from the request.
let route = HTTPRoute("GET /hello/:beast/world")
let beast = request.routeParameters["beast"]
Trailing 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") // true
Specific 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") // false
Query 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") // false
HTTP headers can be matched:
let route = HTTPRoute("*", headers: [.contentType: "application/json"])
route ~= HTTPRequest(headers: [.contentType: "application/json"]) // true
route ~= HTTPRequest(headers: [.contentType: "application/xml"]) // false
Header values can be wildcards:
let route = HTTPRoute("*", headers: [.authorization: "*"])
route ~= HTTPRequest(headers: [.authorization: "abc"]) // true
route ~= HTTPRequest(headers: [.authorization: "xyz"]) // true
route ~= HTTPRequest(headers: [:]) // false
Body patterns can be created to match the request body data:
public protocol HTTPBodyPattern: Sendable {
func evaluate(_ body: Data) -> Bool
}
JSON request bodies can be matched using a JSONPath expression:
let route = HTTPRoute(
"POST *",
jsonBody: { $0["$.food"] == "fish" }
)
{"side": "chips", "food": "
