FlyingFox Documentation

repository·main·Indexed 20 days ago

https://github.com/swhitty/flyingfox

A lightweight, high-performance HTTP server for Swift built with Swift Concurrency and non-blocking BSD sockets. It features an asynchronous socket pool, flexible routing with pattern matching and parameter extraction, and built-in handlers for files, directories, proxies, and redirects. The library supports WebSockets via high-level and low-level handlers, and provides FlyingFoxMacros for reducing boilerplate via @HTTPHandler and @JSONRoute annotations. It also includes FlyingSocks for cross-platform async socket communication.

Tokens
2.6K
Snippets
8
Records
11
Agent score
21%

What's inside FlyingFox

  1. Pattern match HTTPRoutes

    main

    An HTTPRoute can be used to identify requests by matching against various properties of an HTTPRequest using the ~= operator.

    Supported Matching Patterns:

    • Path Literals: HTTPRoute("/hello/world")
    • HTTP Methods: HTTPRoute("GET /hello/world")
    • Wildcards: HTTPRoute("GET /hello/*/world") or trailing wildcards HTTPRoute("/hello/*").
    • Query Items: HTTPRoute("/hello?time=morning") or query wildcards HTTPRoute("/hello?time=*").
    • Headers: HTTPRoute("*", headers: [.contentType: "application/json"]) or header wildcards HTTPRoute("*", headers: [.authorization: "*"]).
    • JSON Body: Match request bodies using a JSONPath expression via the jsonBody parameter.
    let route = HTTPRoute("GET /hello/*/world")
    let isMatch = route ~= HTTPRequest(method: .GET, path: "/hello/fish/world") // true
    
    // JSON Body matching
    let jsonRoute = HTTPRoute(
      "POST *",
      jsonBody: { $0["$.food"] == "fish" }
    )
  2. Start and stop an HTTPServer

    main

    Initialize an HTTPServer with a port and run it within a task.

    Lifecycle Management:

    • Start: Use try await server.run().
    • Wait for readiness: Use try await server.waitUntilListening() to ensure the server is ready before proceeding.
    • Immediate Stop: Cancel the task running the server to terminate all connections immediately.
    • Graceful Shutdown: Use await server.stop(timeout: X) to allow existing requests to complete before closing. The timeout parameter specifies how many seconds to wait before a forceful close.
    • Listening Address: Retrieve the current address via await server.listeningAddress.

    Note for iOS: If the app is suspended in the background, the listening socket may hang up. When the app returns to the foreground, server.run() will throw SocketError.disconnected, and you must restart the server.

    import FlyingFox
    
    let server = HTTPServer(port: 80)
    
    // Start the server in a task
    let task = Task { try await server.run() }
    
    // Wait until ready
    try await server.waitUntilListening()
    
    // Graceful shutdown
    await server.stop(timeout: 3)
  3. Use FlyingSocks for cross-platform async socket communication

    main

    The FlyingSocks module provides a cross-platform asynchronous interface to standard BSD sockets. It wraps low-level Socket operations into a modern Swift async/await interface.

    AsyncSocket is configured with the O_NONBLOCK flag. When data is unavailable and EWOULDBLOCK is encountered, it catches SocketError.blocked and suspends the current task using an AsyncSocketPool until the socket is ready.

    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()
  4. Synthesize HTTP handlers using FlyingFoxMacros

    main

    You can use the FlyingFoxMacros library to automatically generate an HTTPHandler by annotating a struct with @HTTPHandler and its methods with route macros. This reduces boilerplate for defining routes and handling request/response logic.

    Supported annotations:

    • @HTTPRoute("path"): Defines a basic route.
    • @JSONRoute("METHOD path"): Defines a route that automatically handles JSON encoding/decoding for request bodies and response bodies.

    Note: These are implemented via SE-0389 Attached Macros.

    import FlyingFox
    import FlyingFoxMacros
    
    @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.run()
  5. Install FlyingFox via Swift Package Manager

    main

    Add FlyingFox as a dependency in your Package.swift file.

    Requirements:

    • Swift 5.10+
    • Xcode 15.4+
    • Supported Platforms: iOS 13+, tvOS 13+, watchOS 8+, macOS 10.15+, and Linux. (Android and Windows 10 support is experimental).
    .package(url: "https://github.com/swhitty/FlyingFox.git", .upToNextMajor(from: "0.27.1"))
  6. Configure HTTPServer with different SocketAddress types

    main

    The HTTPServer can be initialized with various SocketAddress types, allowing it to listen on different network interfaces or via UNIX domain sockets for IPC.

    Supported address types:

    • .loopback(port: Int): Listens only on localhost.
    • .unix(path: String): Listens on a UNIX domain socket at the specified path.
    • Standard sockaddr clusters like sockaddr_in (IPv4), sockaddr_in6 (IPv6), and sockaddr_un (UNIX) are also supported via conformance.
    // Only listens on localhost 8080
    let server = HTTPServer(address: .loopback(port: 8080))
    
    // Only listens on Unix socket "Ants"
    let server = HTTPServer(address: .unix(path: "Ants"))
  7. Implement and add HTTPHandlers

    main

    You can handle requests by implementing the HTTPHandler protocol or by using closures.

    Protocol Implementation: Implement handleRequest(_:) to process an HTTPRequest and return an HTTPResponse.

    Closure Implementation: Use appendRoute to attach a closure directly to a path.

    Routing Logic:

    • Requests are routed to the handler of the first matching route.
    • If a handler cannot process a request, it can throw HTTPUnhandledError to allow the next matching route to attempt handling it.
    • Requests that match no routes receive an HTTP 404 response.
    // Protocol approach
    protocol HTTPHandler {
      func handleRequest(_ request: HTTPRequest) async throws -> HTTPResponse
    }
    
    // Closure approach
    await server.appendRoute("/hello") { request in
      return HTTPResponse(statusCode: .ok)
    }
  8. Extract Route Parameters

    main

    Routes can include named parameters using the : prefix in paths or query items. These values can be extracted from the request.routeParameters dictionary.

    Manual Extraction:

    // Route: "GET /creature/:name?type=:beast"
    let name = request.routeParameters["name"]
    let beast = request.routeParameters["beast"]

    Automatic Extraction: Parameters can be automatically mapped to closure arguments if the types conform to HTTPRouteParameterValue. Supported types include String, Int, Double, Bool, and custom types conforming to HTTPRouteParameterValue.

    enum Beast: String, HTTPRouteParameterValue {
      case fish, dog
    }
    
    // The closure arguments are automatically populated
    handler.appendRoute("GET /creature/:name?type=:beast") { (name: String, beast: Beast) -> HTTPResponse in
      return HTTPResponse(statusCode: .ok)
    }
  9. Implement WebSocket handling

    main

    WebSockets can be handled in two ways:

    1. WebSocketHTTPHandler (High-level): Uses a WSMessageHandler to exchange AsyncStream<WSMessage> pairs. This is the recommended approach for most use cases.

      • WSMessage can be .text(String), .data(Data), or .close(WSCloseCode).
    2. WSHandler (Low-level): Provides raw WSFrame streams via makeFrames(for:). Use this if you need to work directly with the WebSocket protocol frames.

    // High-level WSMessageHandler
    protocol WSMessageHandler {
      func makeMessages(for client: AsyncStream<WSMessage>) async throws -> AsyncStream<WSMessage>
    }
    
    // Usage
    await server.appendRoute("GET /socket", to: .webSocket(EchoWSMessageHandler()))
  10. Handle SocketError in FlyingSocks

    main

    The Socket type wraps a file descriptor and throws SocketError instead of returning error codes. Common errors include:

    • .blocked: Thrown when data is unavailable (EWOULDBLOCK).
    • .disconnected: The connection was lost.
    • .unsupportedAddress: The provided address is invalid.
    • .timeout(message: String): An operation timed out.
    • .failed(type: String, errno: Int32, message: String): A generic failure containing the error type, errno, and a message.
  11. Use built-in HTTPHandlers (File, Directory, Proxy, Redirect)

    main

    FlyingFox provides several specialized handlers for common tasks:

    • FileHTTPHandler: Serves static files. Supports Range requests for efficient media streaming (returns HTTP 206 Partial Content).

      • Example: await server.appendRoute("GET /mock", to: .file(named: "mock.json"))
    • DirectoryHTTPHandler: Serves files from a directory structure.

      • Example: await server.appendRoute("GET /mock/*", to: .directory(subPath: "Stubs", serverPath: "mock"))
    • ProxyHTTPHandler: Proxies requests to a base URL.

      • Example: await server.appendRoute("GET *", to: .proxy(via: "https://pie.dev"))
    • RedirectHTTPHandler: Redirects requests to a new URL.

      • Static: await server.appendRoute("GET /fish/*", to: .redirect(to: "https://pie.dev/get"))
      • Via Base URL: await server.appendRoute("GET /fish/*", to: .redirect(via: "https://pie.dev"))
      • With Prefix Removal: await server.appendRoute("GET /fish/*", to: .redirect(via: "https://pie.dev", serverPath: "/fish"))