Skip to content

Commit bedc008

Browse files
authored
Deprecate the HTTP PublicationServer (#276)
1 parent 7ab518d commit bedc008

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+1230
-298
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ All notable changes to this project will be documented in this file. Take a look
1717
* New `VisualNavigatorDelegate` APIs to handle keyboard events (contributed by [@lukeslu](https://github.com/readium/swift-toolkit/pull/267)).
1818
* This can be used to turn pages with the arrow keys, for example.
1919

20+
### Deprecated
21+
22+
#### Streamer
23+
24+
* `PublicationServer` is deprecated. See the [the migration guide](Documentation/Migration%20Guide.md#2.5.0) to migrate the HTTP server.
25+
2026
### Changed
2127

2228
#### Navigator

Documentation/Migration Guide.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,26 @@
22

33
All migration steps necessary in reading apps to upgrade to major versions of the Swift Readium toolkit will be documented in this file.
44

5+
## 2.5.0
6+
7+
### Migrating the HTTP server
8+
9+
The Streamer's `PublicationServer` is now deprecated and you don't need to manage the HTTP server or register publications manually to it anymore.
10+
11+
Instead, the EPUB, PDF and CBZ navigators expect an instance of `HTTPServer` upon creation. They will take care of registering and removing the publication automatically from the provided server.
12+
13+
You can implement your own HTTP server using a third-party library. But the easiest way to migrate is to use the one provided in the new Readium package `ReadiumAdapterGCDWebServer`.
14+
15+
```swift
16+
import R2Navigator
17+
import ReadiumAdapterGCDWebServer
18+
19+
let navigator = try EPUBNavigatorViewController(
20+
publication: publication,
21+
httpServer: GCDHTTPServer.shared
22+
)
23+
```
24+
525
## 2.2.0
626

727
With this new release, we migrated all the [`r2-*-swift`](https://github.com/readium/?q=r2-swift) repositories to [a single `swift-toolkit` repository](https://github.com/readium/r2-testapp-swift/issues/404).

Package.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ let package = Package(
1717
.library(name: "R2Navigator", targets: ["R2Navigator"]),
1818
.library(name: "ReadiumOPDS", targets: ["ReadiumOPDS"]),
1919
.library(name: "ReadiumLCP", targets: ["ReadiumLCP"]),
20+
21+
// Adapters to third-party dependencies.
22+
.library(name: "ReadiumAdapterGCDWebServer", targets: ["ReadiumAdapterGCDWebServer"]),
2023
],
2124
dependencies: [
2225
.package(url: "https://github.com/cezheng/Fuzi.git", from: "3.1.3"),
@@ -142,6 +145,15 @@ let package = Package(
142145
// .copy("Fixtures"),
143146
// ]
144147
// ),
148+
149+
.target(
150+
name: "ReadiumAdapterGCDWebServer",
151+
dependencies: [
152+
"GCDWebServer",
153+
"R2Shared",
154+
],
155+
path: "Sources/Adapters/GCDWebServer"
156+
),
145157
]
146158
)
147159

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
//
2+
// Copyright 2023 Readium Foundation. All rights reserved.
3+
// Use of this source code is governed by the BSD-style license
4+
// available in the top-level LICENSE file of the project.
5+
//
6+
7+
import Foundation
8+
import R2Shared
9+
import GCDWebServer
10+
import UIKit
11+
12+
public enum GCDHTTPServerError: Error {
13+
case failedToStartServer(cause: Error)
14+
case serverNotStarted
15+
case nullServerURL
16+
}
17+
18+
/// Implementation of `HTTPServer` using GCDWebServer under the hood.
19+
public class GCDHTTPServer: HTTPServer, Loggable {
20+
21+
/// Shared instance of the HTTP server.
22+
public static let shared = GCDHTTPServer()
23+
24+
/// The actual underlying HTTP server instance.
25+
private let server = GCDWebServer()
26+
27+
/// Mapping between endpoints and their handlers.
28+
private var handlers: [HTTPServerEndpoint: (HTTPServerRequest) -> Resource] = [:]
29+
30+
private enum State {
31+
case stopped
32+
case started(port: UInt, baseURL: URL)
33+
}
34+
35+
private var state: State = .stopped
36+
37+
/// Dispatch queue to protect accesses to the handlers and state.
38+
private let queue = DispatchQueue(
39+
label: "org.readium.swift-toolkit.ReadiumAdapterGCDWebServer",
40+
attributes: .concurrent
41+
)
42+
43+
/// Creates a new instance of the HTTP server.
44+
///
45+
/// - Parameter logLevel: See `GCDWebServer.setLogLevel`.
46+
init(logLevel: Int = 3) {
47+
GCDWebServer.setLogLevel(Int32(logLevel))
48+
49+
NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
50+
51+
server.addDefaultHandler(
52+
forMethod: "GET",
53+
request: GCDWebServerRequest.self,
54+
asyncProcessBlock: { [weak self] request, completion in
55+
self?.handle(request: request, completion: completion)
56+
}
57+
)
58+
}
59+
60+
deinit {
61+
NotificationCenter.default.removeObserver(self)
62+
}
63+
64+
@objc private func willEnterForeground(_ notification: Notification) {
65+
// Restarts the server if it was stopped while the app was in the
66+
// background.
67+
queue.sync(flags: .barrier) {
68+
guard
69+
case .started(let port, _) = state,
70+
isPortFree(port)
71+
else {
72+
return
73+
}
74+
75+
do {
76+
try startWithPort(server.port)
77+
} catch {
78+
log(.error, error)
79+
}
80+
}
81+
}
82+
83+
private func handle(request: GCDWebServerRequest, completion: @escaping GCDWebServerCompletionBlock) {
84+
queue.async { [self] in
85+
var path = request.path.removingPrefix("/")
86+
path = path.removingPercentEncoding ?? path
87+
// Remove anchors and query params
88+
path = path.components(separatedBy: .init(charactersIn: "#?")).first ?? path
89+
90+
let resource: Resource = {
91+
for (endpoint, handler) in handlers {
92+
if endpoint == path {
93+
return handler(HTTPServerRequest(url: request.url, href: nil))
94+
} else if path.hasPrefix(endpoint.addingSuffix("/")) {
95+
return handler(HTTPServerRequest(
96+
url: request.url,
97+
href: path.removingPrefix(endpoint.removingSuffix("/"))
98+
))
99+
}
100+
}
101+
102+
return FailureResource(link: Link(href: request.url.absoluteString), error: .notFound(nil))
103+
}()
104+
105+
let response: GCDWebServerResponse
106+
switch resource.length {
107+
case .success(let length):
108+
response = ResourceResponse(
109+
resource: resource,
110+
length: length,
111+
range: request.hasByteRange() ? request.byteRange : nil
112+
)
113+
case .failure(let error):
114+
log(.error, error)
115+
response = GCDWebServerErrorResponse(statusCode: error.httpStatusCode)
116+
}
117+
118+
completion(response)
119+
}
120+
}
121+
122+
// MARK: HTTPServer
123+
124+
public func serve(at endpoint: HTTPServerEndpoint, handler: @escaping (HTTPServerRequest) -> Resource) throws -> URL {
125+
try queue.sync(flags: .barrier) {
126+
if case .stopped = state {
127+
try start()
128+
}
129+
guard case let .started(port: _, baseURL: baseURL) = state else {
130+
throw GCDHTTPServerError.serverNotStarted
131+
}
132+
133+
handlers[endpoint] = handler
134+
135+
return baseURL.appendingPathComponent(endpoint)
136+
}
137+
}
138+
139+
public func remove(at endpoint: HTTPServerEndpoint) {
140+
queue.sync(flags: .barrier) {
141+
handlers.removeValue(forKey: endpoint)
142+
143+
if handlers.isEmpty {
144+
stop()
145+
}
146+
}
147+
}
148+
149+
// MARK: Server lifecycle
150+
151+
private func stop() {
152+
dispatchPrecondition(condition: .onQueueAsBarrier(queue))
153+
server.stop()
154+
state = .stopped
155+
}
156+
157+
private func start() throws {
158+
func makeRandomPort() -> UInt {
159+
// https://en.wikipedia.org/wiki/Ephemeral_port#Range
160+
let lowerBound = 49152
161+
let upperBound = 65535
162+
return UInt(lowerBound + Int(arc4random_uniform(UInt32(upperBound - lowerBound))))
163+
}
164+
165+
var attemptsLeft = 50
166+
while attemptsLeft > 0 {
167+
attemptsLeft -= 1
168+
169+
do {
170+
try startWithPort(makeRandomPort())
171+
return
172+
} catch {
173+
log(.error, error)
174+
if attemptsLeft == 0 {
175+
throw error
176+
}
177+
}
178+
}
179+
}
180+
181+
private func startWithPort(_ port: UInt) throws {
182+
dispatchPrecondition(condition: .onQueueAsBarrier(queue))
183+
184+
stop()
185+
186+
do {
187+
try server.start(options: [
188+
GCDWebServerOption_Port: port,
189+
GCDWebServerOption_BindToLocalhost: true,
190+
// We disable automatically suspending the server in the
191+
// background, to be able to play audiobooks even with the
192+
// screen locked.
193+
GCDWebServerOption_AutomaticallySuspendInBackground: false
194+
])
195+
} catch {
196+
throw GCDHTTPServerError.failedToStartServer(cause: error)
197+
}
198+
199+
guard let baseURL = server.serverURL else {
200+
stop()
201+
throw GCDHTTPServerError.nullServerURL
202+
}
203+
204+
state = .started(port: server.port, baseURL: baseURL)
205+
}
206+
207+
/// Checks if the given port is already taken (presumabily by the server).
208+
/// Inspired by https://stackoverflow.com/questions/33086356/swift-2-check-if-port-is-busy
209+
private func isPortFree(_ port: UInt) -> Bool {
210+
let port = in_port_t(port)
211+
212+
func getErrnoMessage() -> String {
213+
return String(cString: UnsafePointer(strerror(errno)))
214+
}
215+
216+
let socketDescriptor = socket(AF_INET, SOCK_STREAM, 0)
217+
if socketDescriptor == -1 {
218+
// Just in case, returns true to attempt restarting the server.
219+
return true
220+
}
221+
defer {
222+
Darwin.shutdown(socketDescriptor, SHUT_RDWR)
223+
close(socketDescriptor)
224+
}
225+
226+
let addrSize = MemoryLayout<sockaddr_in>.size
227+
var addr = sockaddr_in()
228+
addr.sin_len = __uint8_t(addrSize)
229+
addr.sin_family = sa_family_t(AF_INET)
230+
addr.sin_port = Int(OSHostByteOrder()) == OSLittleEndian ? _OSSwapInt16(port) : port
231+
addr.sin_addr = in_addr(s_addr: inet_addr("0.0.0.0"))
232+
addr.sin_zero = (0, 0, 0, 0, 0, 0, 0, 0)
233+
var bindAddr = sockaddr()
234+
memcpy(&bindAddr, &addr, Int(addrSize))
235+
236+
if Darwin.bind(socketDescriptor, &bindAddr, socklen_t(addrSize)) == -1 {
237+
// "Address already in use", the server is already started
238+
if errno == EADDRINUSE {
239+
return false
240+
}
241+
}
242+
243+
// It might not actually be free, but we'll try to restart the server.
244+
return true
245+
}
246+
}

0 commit comments

Comments
 (0)