Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
fa06f9e
Fix Big-Endian decoding for 8-byte WebSocket frame lengths
phuccvx12 Apr 24, 2026
978bd49
Honor HTTP/1.1 persistent-connection default per RFC 9112 §9.3
ianegordon Apr 25, 2026
877f458
Decode chunked request bodies; reject invalid framing
ianegordon Apr 25, 2026
5326698
Cover chunked decoder error paths and chunk-ext parsing
ianegordon Apr 25, 2026
e173ffe
Merge pull request #207 from phuccvx12/fix/ws-length-endianness
swhitty Apr 25, 2026
832d092
Fix potential resource leaks in MessageFrameWSHandler stream management
phuccvx12 Apr 24, 2026
753b720
Simplify MessageFrameWSHandler outgoing message task management
phuccvx12 Apr 25, 2026
e501c65
Add tests for WSHandler outgoing messages
phuccvx12 Apr 25, 2026
4fcb6c3
Merge pull request #209 from ianegordon/ian/tvt-288-http11-keep-alive…
swhitty Apr 26, 2026
5295843
Merge pull request #210 from ianegordon/ian/tvt-287-chunked-request-b…
swhitty Apr 26, 2026
1d1f0a9
Add direct unit tests for HTTPChunkedTransferDecoder
ianegordon Apr 28, 2026
8fd1c5d
Compute ETag from (mtime, size) to avoid loading whole file per request
ianegordon Apr 28, 2026
e2f31ad
Merge pull request #211 from ianegordon/ian/chunked-decoder-tests
swhitty Apr 28, 2026
05b6b66
Merge pull request #212 from ianegordon/ian/tvt-290-filehttphandler-l…
swhitty Apr 28, 2026
ccb2dad
Stream DirectoryHTTPHandler responses via HTTPBodySequence(file:)
ianegordon Apr 28, 2026
c73d96f
Reject control frames with payload >125 bytes per RFC 6455 §5.5
ianegordon Apr 28, 2026
31e74af
Cover DirectoryHTTPHandler conditional-request paths
ianegordon Apr 28, 2026
1369671
Merge pull request #213 from ianegordon/ian/tvt-286-directoryhttphand…
swhitty May 16, 2026
524d267
Merge pull request #208 from phuccvx12/fix/ws-handler-stream-termination
swhitty May 16, 2026
de46fbb
Merge pull request #214 from ianegordon/ian/tvt-306-wsframevalidator-…
swhitty May 16, 2026
6238522
Buffer HTTP request bytes once per connection
lhoward May 17, 2026
c3bf9c1
Merge pull request #215 from PADL/lhoward/buffered-http-bytes
swhitty May 21, 2026
d06fd45
0.27.0
swhitty Jul 3, 2026
0a6f327
Swift 6.3
swhitty Jul 3, 2026
39fc7c1
Socket: treat read()/recv()==0 as EOF before consulting errno
lhoward Jul 8, 2026
a196c2e
SocketPool+ePoll: retry epoll_wait on EINTR instead of failing
lhoward Jul 14, 2026
5364faa
Merge pull request #224 from PADL/lhoward/read-eof-errno-misclassific…
swhitty Jul 15, 2026
00624f4
HTTPClient ~Copyable
swhitty Jul 16, 2026
8cdf7e4
Merge pull request #225 from swhitty/HTTPClient
swhitty Jul 16, 2026
4e246d3
0.27.1
swhitty Jul 16, 2026
48e68fa
Fix heap allocation leak in Socket.getValue
ianegordon Jul 21, 2026
fe1bf79
Fix socket fd leaks on AsyncSocket connect/accept failure paths
ianegordon Jul 21, 2026
58af82c
Retry kevent on EINTR instead of failing
ianegordon Jul 21, 2026
bb98152
Darwin: fix sockaddr_un overflow in makeAddressUnix for long paths
ianegordon Jul 21, 2026
bb2be7b
Add regression test asserting no fd leak on failed connect
ianegordon Jul 21, 2026
6508863
Remove fd-count regression test
ianegordon Jul 21, 2026
0ddf486
Merge pull request #226 from ianegordon/ian/tvt-1062-socketgetvalue-l…
swhitty Jul 22, 2026
db8cbe7
Merge pull request #227 from ianegordon/ian/tvt-1077-asyncsocket-leak…
swhitty Jul 22, 2026
3ed8e1b
Merge pull request #229 from ianegordon/ian/tvt-305-epollgetnotificat…
swhitty Jul 22, 2026
ac24c58
Merge pull request #230 from ianegordon/ian/tvt-1063-darwin-makeaddre…
swhitty Jul 22, 2026
cedec97
Format HTTP dates as IMF-fixdate via shared HTTPDate formatter
ianegordon Jul 23, 2026
5b3298d
Emit a Date header on all responses
ianegordon Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ on:
workflow_dispatch:

jobs:
xcode_26_2:
xcode_26_6:
runs-on: macos-26
env:
DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer
DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer
steps:
- name: Checkout
uses: actions/checkout@v4
Expand Down Expand Up @@ -39,10 +39,10 @@ jobs:
junit: result-swift-testing.xml
coverage: .build/debug/codecov/FlyingFox.json

xcode_26_1:
runs-on: macos-15
xcode_26_2:
runs-on: macos-26
env:
DEVELOPER_DIR: /Applications/Xcode_26.1.1.app/Contents/Developer
DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer
steps:
- name: Checkout
uses: actions/checkout@v4
Expand Down Expand Up @@ -125,7 +125,7 @@ jobs:

linux_swift_6_3:
runs-on: ubuntu-latest
container: swiftlang/swift:nightly-6.3-noble
container: swift:6.3
timeout-minutes: 5
steps:
- name: Checkout
Expand All @@ -137,9 +137,9 @@ jobs:
- name: Test
run: swift test --skip-build

linux_swift_6_1_musl:
linux_swift_6_3_musl:
runs-on: ubuntu-latest
container: swift:6.1.2
container: swift:6.3.3
steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -148,7 +148,7 @@ jobs:
- name: SDK List Pre
run: swift sdk list
- name: Install SDK
run: swift sdk install https://download.swift.org/swift-6.1.2-release/static-sdk/swift-6.1.2-RELEASE/swift-6.1.2-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz --checksum df0b40b9b582598e7e3d70c82ab503fd6fbfdff71fd17e7f1ab37115a0665b3b
run: swift sdk install https://download.swift.org/swift-6.3.3-release/static-sdk/swift-6.3.3-RELEASE/swift-6.3.3-RELEASE_static-linux-0.1.0.artifactbundle.tar.gz --checksum 87c3eaf908e67c0e13a84367119e12273cec1d2cd3d81f7d74bb36722d6b607b
- name: SDK List Post
run: swift sdk list
- name: Build
Expand Down
6 changes: 3 additions & 3 deletions FlyingFox.podspec.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "FlyingFox",
"version": "0.26.2",
"version": "0.27.1",
"summary": "Lightweight, HTTP server written in Swift using async/await",
"homepage": "https://github.com/swhitty/FlyingFox",
"authors": "Simon Whitty",
Expand All @@ -10,7 +10,7 @@
},
"source": {
"git": "https://github.com/swhitty/FlyingFox.git",
"tag": "0.26.2"
"tag": "0.27.1"
},
"platforms": {
"ios": "13.0",
Expand All @@ -20,7 +20,7 @@
},
"source_files": "FlyingFox/Sources/**/*.swift",
"dependencies": {
"FlyingSocks": "~> 0.26.2"
"FlyingSocks": "~> 0.27.1"
},
"pod_target_xcconfig": {
"OTHER_SWIFT_FLAGS": "-package-name FlyingFox"
Expand Down
8 changes: 8 additions & 0 deletions FlyingFox/Sources/HTTPBodySequence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ public struct HTTPBodySequence: Sendable, AsyncSequence {
)
}

init(chunked bytes: some AsyncBufferedSequence<UInt8>, suggestedBufferSize: Int = 4096) {
self.storage = Storage(
bytes: HTTPChunkedTransferDecoder(bytes: bytes),
count: nil,
bufferSize: suggestedBufferSize
)
}

public init(file url: URL, range: Range<Int>? = nil, suggestedBufferSize: Int = 4096) throws {
self.storage = try Storage(
fileURL: url,
Expand Down
44 changes: 20 additions & 24 deletions FlyingFox/Sources/HTTPCacheControl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@
//

import Foundation
#if canImport(CryptoKit)
import CryptoKit
#endif

public enum HTTPCacheControl {
public enum ResponseDirective: Sendable, CustomStringConvertible {
Expand Down Expand Up @@ -58,18 +55,6 @@ public enum HTTPCacheControl {
}
}

static func getDateHeaderValue() -> String {
return Self.dateFormatter.string(from: Date())
}

private static let dateFormatter: DateFormatter = {
let df = DateFormatter()
df.dateFormat = "EEE, d MMM yyyy HH:mm:ss zzz"
df.timeZone = TimeZone(secondsFromGMT: 0)
df.locale = Locale(identifier: "en_US_POSIX")
return df
}()

static func getExpiresValue(for filePath: URL) -> String? {
do {
let path = {
Expand All @@ -81,21 +66,32 @@ public enum HTTPCacheControl {
}()
let attributes = try FileManager.default.attributesOfItem(atPath: path)
if let modificationDate = attributes[FileAttributeKey.modificationDate] as? Date ?? attributes[FileAttributeKey.creationDate] as? Date {
return Self.dateFormatter.string(from: modificationDate)
return HTTPDate.string(from: modificationDate)
}
} catch {
}
return nil
}

static func getETagValue(for data: Data) -> String? {
#if canImport(CryptoKit)
let sha256digest = SHA256.hash(data: data)
let eTag = "\"\(sha256digest.map { String(format: "%02x", $0) }.joined())\""
return eTag
#else
return nil
#endif
// Strong ETag derived from (mtime, size), matching the format used by
// nginx (`"%xT-%xO"`, see ngx_http_set_etag in src/http/ngx_http_core_module.c)
// and Apache HTTPD's default `FileETag MTime Size`. Cheap to compute and
// does not require reading file contents.
static func getETagValue(for filePath: URL) -> String? {
let path = {
if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) {
return filePath.path()
} else {
return filePath.path
}
}()
guard let attributes = try? FileManager.default.attributesOfItem(atPath: path),
let modificationDate = attributes[.modificationDate] as? Date,
let size = attributes[.size] as? UInt64 else {
return nil
}
let mtime = Int64(modificationDate.timeIntervalSince1970)
return String(format: "\"%llx-%llx\"", mtime, size)
}
}

Expand Down
132 changes: 132 additions & 0 deletions FlyingFox/Sources/HTTPChunkedDecodedSequence.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//
// HTTPChunkedDecodedSequence.swift
// FlyingFox
//
// Created by Simon Whitty on 24/04/2026.
// Copyright © 2026 Simon Whitty. All rights reserved.
//
// Distributed under the permissive MIT license
// Get the latest version from here:
//
// https://github.com/swhitty/FlyingFox
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//

import Foundation
import FlyingSocks

// Decodes an `AsyncBufferedSequence` of bytes that are framed using the
// `chunked` transfer coding (RFC 9112 §7.1) and yields the decoded payload.
struct HTTPChunkedTransferDecoder<Base>: AsyncBufferedSequence, Sendable
where Base: AsyncBufferedSequence,
Base.Element == UInt8,
Base: Sendable {
typealias Element = UInt8

private let bytes: Base

init(bytes: Base) {
self.bytes = bytes
}

func makeAsyncIterator() -> Iterator {
Iterator(bytes: bytes.makeAsyncIterator())
}
}

extension HTTPChunkedTransferDecoder {

struct Iterator: AsyncBufferedIteratorProtocol {

private var bytes: Base.AsyncIterator
private var remainingInChunk: Int = 0
private var isComplete: Bool = false

init(bytes: Base.AsyncIterator) {
self.bytes = bytes
}

mutating func next() async throws -> UInt8? {
fatalError("call nextBuffer(suggested:)")
}

mutating func nextBuffer(suggested count: Int) async throws -> [UInt8]? {
guard !isComplete else { return nil }

if remainingInChunk == 0 {
let size = try await readChunkSize()
if size == 0 {
try await consumeTrailer()
isComplete = true
return nil
}
remainingInChunk = size
}

let take = Swift.min(count, remainingInChunk)
guard let buffer = try await bytes.nextBuffer(count: take) else {
throw HTTPDecoder.Error("Unexpected end of chunked body")
}
remainingInChunk -= buffer.count
if remainingInChunk == 0 {
try await consumeCRLF()
}
return buffer
}

// RFC 9112 §7.1: chunk = chunk-size [ chunk-ext ] CRLF chunk-data CRLF
// chunk-size is hexadecimal; chunk-ext (preceded by `;`) is ignored here.
private mutating func readChunkSize() async throws -> Int {
let line = try await readLine()
let sizePart = line.split(separator: ";", maxSplits: 1).first.map(String.init) ?? line
guard let size = Int(sizePart, radix: 16), size >= 0 else {
throw HTTPDecoder.Error("Invalid chunk-size: \(line)")
}
return size
}

// RFC 9112 §7.1.2: trailer-section is zero or more field lines terminated by CRLF.
private mutating func consumeTrailer() async throws {
while !(try await readLine()).isEmpty { }
}

private mutating func consumeCRLF() async throws {
guard let buffer = try await bytes.nextBuffer(count: 2),
buffer == [0x0D, 0x0A] else {
throw HTTPDecoder.Error("Expected CRLF after chunk-data")
}
}

private mutating func readLine() async throws -> String {
var line = [UInt8]()
while true {
guard let buffer = try await bytes.nextBuffer(count: 1) else {
throw HTTPDecoder.Error("Unexpected end of chunked body")
}
let byte = buffer[0]
if byte == 0x0A {
if line.last == 0x0D { line.removeLast() }
return String(decoding: line, as: UTF8.self)
}
line.append(byte)
}
}
}
}
25 changes: 17 additions & 8 deletions FlyingFox/Sources/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,28 @@

import FlyingSocks

@_spi(Private)
public struct _HTTPClient {
@available(*, deprecated, renamed: "HTTPClient")
public typealias _HTTPClient = HTTPClient

public struct HTTPClient: ~Copyable {

private var _lastSocket: AsyncSocket?

public init() { }

public func sendHTTPRequest(_ request: HTTPRequest, to address: some SocketAddress) async throws -> HTTPResponse {
public mutating func sendHTTPRequest(_ request: HTTPRequest, to address: some SocketAddress) async throws -> HTTPResponse {

try? _lastSocket?.close()
_lastSocket = nil

let socket = try await AsyncSocket.connected(to: address)
_lastSocket = socket
try await socket.writeRequest(request)
let response = try await socket.readResponse()
// if streaming very large responses then you shouldn't close here
// maybe better to close in deinit instead
try? socket.close()
return response
return try await socket.readResponse()
}

deinit {
try? _lastSocket?.close()
}
}

Expand Down
22 changes: 19 additions & 3 deletions FlyingFox/Sources/HTTPConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,25 +36,39 @@ struct HTTPConnection: Sendable {

let hostname: String
private let socket: AsyncSocket
private let bytes: AsyncBufferingSequence<AsyncSocketReadSequence>
private let decoder: HTTPDecoder
private let logger: any Logging
let requests: HTTPRequestSequence<AsyncSocketReadSequence>
let requests: HTTPRequestSequence<AsyncBufferingSequence<AsyncSocketReadSequence>>

init(socket: AsyncSocket, decoder: HTTPDecoder, logger: some Logging) {
self.socket = socket
self.decoder = decoder
self.logger = logger

// Wrap socket.bytes once per connection so header parsing, body
// reading, and any subsequent protocol upgrade all share a single
// 4 KB read buffer. Without this, the HTTP decoder pulls one byte
// per syscall through `iterator.next()` while parsing the status
// line and headers.
let bytes = AsyncBufferingSequence(socket.bytes)
let (peer, identifier) = HTTPConnection.makeIdentifier(from: socket.socket)
self.hostname = identifier
self.requests = HTTPRequestSequence(bytes: socket.bytes, decoder: decoder, remoteAddress: peer)
self.bytes = bytes
self.requests = HTTPRequestSequence(bytes: bytes, decoder: decoder, remoteAddress: peer)
}

func complete() async {
await requests.complete()
}

func sendResponse(_ response: HTTPResponse) async throws {
var response = response
// RFC 9110 §6.6.1: an origin server with a clock MUST send Date on
// 2xx/3xx/4xx responses and MAY on 1xx/5xx; handler-supplied values win.
if response.headers[.date] == nil {
response.headers[.date] = HTTPDate.string(from: Date())
}
let header = HTTPEncoder.encodeResponseHeader(response)

switch response.payload {
Expand All @@ -76,7 +90,9 @@ struct HTTPConnection: Sendable {
}

func switchToWebSocket(with handler: some WSHandler, response: Data) async throws {
let client = AsyncThrowingStream.decodingFrames(from: socket.bytes)
// Reuse the connection-wide buffered stream so any bytes already
// pulled past the upgrade request remain available to the WS framer.
let client = AsyncThrowingStream.decodingFrames(from: bytes)
let server = try await handler.makeFrames(for: client)
try await socket.write(response)
logger.logSwitchProtocol(self, to: "websocket")
Expand Down
Loading