Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
595 changes: 296 additions & 299 deletions ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift

Large diffs are not rendered by default.

42 changes: 42 additions & 0 deletions ios/Sources/GutenbergKitHTTP/HTTPRequestHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#if canImport(Network)

import Foundation

/// Serves requests for an ``HTTPServer``.
///
/// The closure form of
/// ``HTTPServer/start(name:port:listenOnAllInterfaces:requiresAuthentication:maxRequestBodySize:maxConnections:readTimeout:bodyReadTimeout:idleTimeout:startTimeout:cors:delegate:handler:)-(_,_,_,_,_,_,_,_,_,_,_,_,@escaping@Sendable(HTTPServer.Request)async->HTTPResponse)``
/// is the right tool for a handler that needs no state. Conform to this instead when
/// the handler has dependencies: they become stored properties, and the request
/// methods become ordinary instance methods rather than statics threading a context
/// parameter through every call.
///
/// ## Lifetimes
///
/// The server retains its handler for its lifetime, so a handler must not strongly
/// hold the object that owns the server, directly or transitively:
/// `owner → HTTPServer → handler → owner` is a cycle, the owner's `deinit` never runs,
/// and `stop()` is never called — a silently stranded listener, not a crash.
///
/// A value type is **not** protection. A `struct` handler storing the owner closes the
/// same ring: the server captures the struct into a heap node, and its stored properties
/// are strong edges out of it. This protocol is deliberately **not** `AnyObject`-constrained
/// so a handler *can* be a `struct` holding only what it needs — not because a `struct` is
/// safe by construction. Either shape works; both must stay leaves, the same discipline
/// ``HTTPServerDelegate`` documents.
///
/// The usual trap is the object that starts the server also serving it — a view controller
/// starting it in `viewDidLoad` and stopping it in `deinit` is the shape that bites, because
/// the cycle disables the very teardown meant to break it. Conform a separate leaf type, or
/// call `stop()` from a hook that does run.
public protocol HTTPRequestHandler: Sendable {
/// The response for a request the server has parsed and authenticated.
///
/// Called once per request, concurrently across connections — hence `Sendable`.
/// Cancellation is cooperative: the server cancels this task when the client
/// disconnects or the server stops, and discards whatever a cancelled task
/// returns, so check `Task.isCancelled` before any side effect you can't undo.
func handle(_ request: HTTPServer.Request) async -> HTTPResponse
}

#endif // canImport(Network)
40 changes: 40 additions & 0 deletions ios/Sources/GutenbergKitHTTP/HTTPServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,46 @@ public final class HTTPServer: Sendable {
}
}

/// Starts a server that serves requests from an ``HTTPRequestHandler`` object
/// rather than a closure.
///
/// Everything else behaves identically — this forwards to the closure form. Reach
/// for it when the handler has dependencies to hold: a `struct` conformer stores
/// them and serves from instance methods, instead of statics threading a context
/// parameter through every call. See ``HTTPRequestHandler`` for the (short)
/// lifetime rules.
public static func start(
name: String,
port: UInt16? = nil,
listenOnAllInterfaces: Bool = false,
requiresAuthentication: Bool = true,
maxRequestBodySize: Int64 = HTTPRequestParser.defaultMaxBodySize,
maxConnections: Int = HTTPServer.defaultMaxConnections,
readTimeout: Duration = HTTPServer.defaultReadTimeout,
bodyReadTimeout: Duration? = nil,
idleTimeout: Duration = HTTPServer.defaultIdleTimeout,
startTimeout: Duration = HTTPServer.defaultStartTimeout,
cors: CORSPolicy = .none,
delegate: HTTPServerDelegate? = nil,
handler: some HTTPRequestHandler
) async throws -> HTTPServer {
try await start(
name: name,
port: port,
listenOnAllInterfaces: listenOnAllInterfaces,
requiresAuthentication: requiresAuthentication,
maxRequestBodySize: maxRequestBodySize,
maxConnections: maxConnections,
readTimeout: readTimeout,
bodyReadTimeout: bodyReadTimeout,
idleTimeout: idleTimeout,
startTimeout: startTimeout,
cors: cors,
delegate: delegate,
handler: { await handler.handle($0) }
)
}

/// Races `operation` against `timeout`, throwing ``HTTPServerError/startTimeout``
/// if the timeout wins. Used to bound the wait for the listener to become ready
/// so a caller — such as the editor load awaiting the upload server's bind —
Expand Down
18 changes: 18 additions & 0 deletions ios/Sources/GutenbergKitHTTP/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,24 @@ server.stop()

Pass `nil` (or omit `port`) to let the system assign an available port — useful for tests or when running multiple servers.

#### Handlers with state

A closure is right for a handler that needs no state. When the handler has dependencies, conform a type to `HTTPRequestHandler` and pass it as `handler:` instead — the dependencies become stored properties and the request logic becomes instance methods, rather than statics threading a context parameter through every call.

```swift
struct MediaHandler: HTTPRequestHandler {
let uploader: Uploader

func handle(_ request: HTTPServer.Request) async -> HTTPResponse {
await uploader.upload(request.parsed.body)
}
}

let server = try await HTTPServer.start(name: "media", handler: MediaHandler(uploader: uploader))
```

The server retains its handler, so a handler must not strongly hold the object that owns the server, directly or transitively — `owner → HTTPServer → handler → owner` is a cycle, and the owner's `deinit` would never run. A value type is **not** protection here: a `struct` handler storing the owner closes the same ring, because the server captures the struct into a heap node and its stored properties are strong edges out of it. `HTTPRequestHandler` is deliberately not `AnyObject`-constrained so a handler *can* be a `struct` holding only what it needs — not because a `struct` is safe by construction. Either shape works, as long as it stays a leaf.

When `requiresAuthentication` is enabled (the default), each request must include a `Proxy-Authorization: Bearer <token>` header carrying the server's randomly-generated token. The server uses `Proxy-Authorization` per RFC 9110 §11.7.1 rather than `Authorization`, so the client's `Authorization` header remains available for upstream credentials (e.g. HTTP Basic auth to the remote server). Unauthenticated requests receive a `407 Proxy Authentication Required` response with a `Proxy-Authenticate: Bearer` challenge header.

### Proxying via URLSession
Expand Down
30 changes: 30 additions & 0 deletions ios/Tests/GutenbergKitHTTPTests/HTTPServerStartTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,36 @@ struct HTTPServerStartTests {
// rather than suspending its caller indefinitely.
#expect(elapsed < .seconds(5))
}

@Test("serves requests from an HTTPRequestHandler object, carrying its state")
func servesFromRequestHandlerObject() async throws {
// The point of the object overload: the handler holds its dependencies as
// stored properties and serves from an instance method, so a consumer with
// state doesn't need statics threading a context through every call.
let server = try await HTTPServer.start(
name: "handler-object-test",
requiresAuthentication: false,
handler: EchoHandler(greeting: "hello from a struct")
)
defer { server.stop() }

let url = URL(string: "http://127.0.0.1:\(server.port)/anything")!
let (data, response) = try await URLSession.shared.data(from: url)

#expect((response as? HTTPURLResponse)?.statusCode == 200)
#expect(String(decoding: data, as: UTF8.self) == "hello from a struct")
}
}

/// A value-type handler, holding only a `String` — so it has no strong edge back to
/// whatever owns the server. ``HTTPRequestHandler`` isn't `AnyObject`-constrained so a
/// handler *can* take this shape; a `struct` storing the owner would still cycle.
private struct EchoHandler: HTTPRequestHandler {
let greeting: String

func handle(_ request: HTTPServer.Request) async -> HTTPResponse {
HTTPResponse(status: 200, body: Data(greeting.utf8))
}
}

#endif
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ struct MediaUploadServerTests {
/// conform, and the server actually calls it.
///
/// Every other conformer in the tree is a class, so without this nothing exercises the
/// boxed-existential path — copied into `UploadContext`, captured by the `@Sendable`
/// boxed-existential path — copied into `Handler`, captured by the `@Sendable`
/// handler closure, read again at `processFile`. Re-imposing a class requirement, or
/// breaking that path, would otherwise compile and pass green and surface only in a
/// host's build.
Expand Down Expand Up @@ -597,7 +597,7 @@ struct MediaUploadServerTests {
// The server-side half of the ownership story, and the one nothing else covers.
// `EditorViewController.stopMediaHandling()` clears its own properties *and* stops
// the server, because releasing only one leaves the loop routed through the other:
// `listener -> newConnectionHandler -> handler -> UploadContext -> processor -> server`.
// `listener -> newConnectionHandler -> Handler -> processor -> server`.
//
// Polled rather than asserted outright, unlike `retainsProcessorForServerLifetime`:
// `releaseConnectionHandler()` opens the loop on the caller's thread, but it is not
Expand Down
Loading