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.5 on Xcode 13.2+ or Linux to build. It runs on iOS 13+, tvOS 13+ or macOS 10.15+.
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.3.1")),
Usage
Start the server by providing a port number:
import FlyingFox
let server = HTTPServer(port: 8080)
try await server.start()
The server runs within the the current task. To stop the server, cancel the task:
let task = Task { try await server.start() }
task.cancel()
Handlers
Handlers can be added to the server by implementing HTTPHandler
:
public protocol HTTPHandler: Sendable {
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.
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
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 if RoutedHTTPHandler
is unable to handle the request with any of its registered handlers. HTTP 404
is returned as the response.
HTTPRoute
Routes allow requests to be identified by HTTPMethod
and path and can be pattern matched against requests:
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
, so literals will be automatically converted to HTTPRoute
:
let route: HTTPRoute = "/hello/world"
Routes can include specific methods 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
And can 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
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
AsyncSocket / PollingSocketPool
Internally, FlyingFox uses standard BSD sockets configured with the flag O_NONBLOCK
. When data is unavailable for a socket (EWOULDBLOCK
) the task is suspended using the current AsyncSocketPool
until data is available:
protocol AsyncSocketPool {
func suspend(untilReady socket: Socket) async throws
}
PollingSocketPool
is currently the only pool available. It uses a continuous loop of poll(2)
/ Task.yield()
to check all sockets awaiting data at a supplied interval. All sockets share the same pool.
Command line app
An example command line app FlyingFoxCLI is available here.
Credits
FlyingFox is primarily the work of Simon Whitty.