Build Codecov Platforms Swift 5.5 License Twitter

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.

(Full list of contributors)

GitHub

View Github