@dvai-bridge/ios-shared-core 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +51 -0
- package/Package.swift +62 -0
- package/README.md +199 -0
- package/ios/Sources/DVAISharedCore/HandlerContext.swift +121 -0
- package/ios/Sources/DVAISharedCore/HandlerDispatch.swift +132 -0
- package/ios/Sources/DVAISharedCore/HttpServer.swift +469 -0
- package/ios/Tests/DVAISharedCoreTests/HandlerDispatchTest.swift +160 -0
- package/ios/Tests/DVAISharedCoreTests/HttpServerIntegrationTest.swift +163 -0
- package/ios/Tests/DVAISharedCoreTests/HttpServerTest.swift +94 -0
- package/package.json +18 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import DVAISharedCore
|
|
3
|
+
|
|
4
|
+
/// v3.2.x — drive a live HTTP request through the Hummingbird-backed
|
|
5
|
+
/// `HttpServer` end-to-end. Counterpart to the (Telegraph-era)
|
|
6
|
+
/// `HttpServerTest` lifecycle suite, which only exercises bind / stop
|
|
7
|
+
/// + port-fallback. This suite proves the request → dispatchRoute →
|
|
8
|
+
/// response → byte-on-the-wire path actually works for both buffered
|
|
9
|
+
/// (JSON) and streaming (SSE) handlers.
|
|
10
|
+
@available(iOS 17.0, macOS 14.0, *)
|
|
11
|
+
final class HttpServerIntegrationTest: XCTestCase {
|
|
12
|
+
|
|
13
|
+
// MARK: - Test handlers
|
|
14
|
+
|
|
15
|
+
/// Replies to chat/completions with an SSE stream of three chunks
|
|
16
|
+
/// + [DONE]. Used to verify that streaming responses flush
|
|
17
|
+
/// incrementally — we observe the bytes arriving via
|
|
18
|
+
/// `URLSession.bytes(for:)` and assert each chunk lands separately.
|
|
19
|
+
final class StreamingHandlers: DVAIHandlers, @unchecked Sendable {
|
|
20
|
+
let chunkInterval: TimeInterval
|
|
21
|
+
|
|
22
|
+
init(chunkInterval: TimeInterval = 0.05) {
|
|
23
|
+
self.chunkInterval = chunkInterval
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func handleChatCompletion(body: [String: Any], ctx: HandlerContext) async throws -> HandlerResponse {
|
|
27
|
+
let interval = chunkInterval
|
|
28
|
+
let (stream, cont) = AsyncStream<String>.makeStream()
|
|
29
|
+
Task {
|
|
30
|
+
for chunk in ["data: chunk1\n\n", "data: chunk2\n\n", "data: chunk3\n\n", "data: [DONE]\n\n"] {
|
|
31
|
+
cont.yield(chunk)
|
|
32
|
+
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
|
|
33
|
+
}
|
|
34
|
+
cont.finish()
|
|
35
|
+
}
|
|
36
|
+
return .sse(stream)
|
|
37
|
+
}
|
|
38
|
+
func handleCompletion(body: [String: Any], ctx: HandlerContext) async throws -> HandlerResponse { .json(200, [:]) }
|
|
39
|
+
func handleEmbeddings(body: [String: Any], ctx: HandlerContext) async throws -> HandlerResponse { .json(200, [:]) }
|
|
40
|
+
func handleModels(ctx: HandlerContext) async throws -> HandlerResponse {
|
|
41
|
+
.json(200, ["object": "list", "data": [["id": ctx.modelId]]])
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// MARK: - Helpers
|
|
46
|
+
|
|
47
|
+
/// Bring up an HttpServer on a free port, hand back the bound URL.
|
|
48
|
+
/// Caller must `stop()` when done.
|
|
49
|
+
private func startServer(
|
|
50
|
+
handlers: DVAIHandlers = StreamingHandlers(),
|
|
51
|
+
cors: CORSConfig = .wildcard
|
|
52
|
+
) async throws -> (server: HttpServer, baseUrl: URL) {
|
|
53
|
+
let server = HttpServer()
|
|
54
|
+
let ctx = HandlerContext(modelId: "integration-test-model", backendName: "test")
|
|
55
|
+
await server.installRoutes(handlers: handlers, ctx: ctx, corsConfig: cors)
|
|
56
|
+
// Use a port range well above other tests' bands to avoid
|
|
57
|
+
// collisions when the suite runs concurrently.
|
|
58
|
+
let port = try await server.tryBind(basePort: 39200, maxAttempts: 32, host: "127.0.0.1")
|
|
59
|
+
let url = URL(string: "http://127.0.0.1:\(port)")!
|
|
60
|
+
return (server, url)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// MARK: - Buffered (JSON) path
|
|
64
|
+
|
|
65
|
+
func testGetModelsReturnsJsonOverTheWire() async throws {
|
|
66
|
+
let (server, baseUrl) = try await startServer()
|
|
67
|
+
defer { Task { await server.stop() } }
|
|
68
|
+
|
|
69
|
+
let url = baseUrl.appendingPathComponent("v1/models")
|
|
70
|
+
let (data, response) = try await URLSession.shared.data(from: url)
|
|
71
|
+
let http = response as! HTTPURLResponse
|
|
72
|
+
|
|
73
|
+
XCTAssertEqual(http.statusCode, 200)
|
|
74
|
+
XCTAssertEqual(http.value(forHTTPHeaderField: "Content-Type"), "application/json")
|
|
75
|
+
XCTAssertEqual(http.value(forHTTPHeaderField: "Access-Control-Allow-Origin"), "*")
|
|
76
|
+
|
|
77
|
+
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
78
|
+
XCTAssertEqual(json?["object"] as? String, "list")
|
|
79
|
+
let array = json?["data"] as? [[String: Any]]
|
|
80
|
+
XCTAssertEqual(array?.first?["id"] as? String, "integration-test-model")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func testCorsPreflightReturns204() async throws {
|
|
84
|
+
let (server, baseUrl) = try await startServer()
|
|
85
|
+
defer { Task { await server.stop() } }
|
|
86
|
+
|
|
87
|
+
var req = URLRequest(url: baseUrl.appendingPathComponent("v1/chat/completions"))
|
|
88
|
+
req.httpMethod = "OPTIONS"
|
|
89
|
+
req.setValue("https://app.example.com", forHTTPHeaderField: "Origin")
|
|
90
|
+
|
|
91
|
+
let (_, response) = try await URLSession.shared.data(for: req)
|
|
92
|
+
let http = response as! HTTPURLResponse
|
|
93
|
+
XCTAssertEqual(http.statusCode, 204)
|
|
94
|
+
XCTAssertEqual(http.value(forHTTPHeaderField: "Access-Control-Allow-Private-Network"), "true")
|
|
95
|
+
XCTAssertNotNil(http.value(forHTTPHeaderField: "Access-Control-Allow-Methods"))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func testUnknownPathReturns404WithCors() async throws {
|
|
99
|
+
let (server, baseUrl) = try await startServer()
|
|
100
|
+
defer { Task { await server.stop() } }
|
|
101
|
+
|
|
102
|
+
let (_, response) = try await URLSession.shared.data(from: baseUrl.appendingPathComponent("nope"))
|
|
103
|
+
let http = response as! HTTPURLResponse
|
|
104
|
+
XCTAssertEqual(http.statusCode, 404)
|
|
105
|
+
XCTAssertEqual(http.value(forHTTPHeaderField: "Access-Control-Allow-Origin"), "*")
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// MARK: - Streaming (SSE) path
|
|
109
|
+
|
|
110
|
+
func testSseStreamFlushesChunksIncrementally() async throws {
|
|
111
|
+
// 50ms between chunks — small enough to keep the test fast,
|
|
112
|
+
// large enough that "all chunks at once" would be detectable.
|
|
113
|
+
let (server, baseUrl) = try await startServer(handlers: StreamingHandlers(chunkInterval: 0.05))
|
|
114
|
+
defer { Task { await server.stop() } }
|
|
115
|
+
|
|
116
|
+
var req = URLRequest(url: baseUrl.appendingPathComponent("v1/chat/completions"))
|
|
117
|
+
req.httpMethod = "POST"
|
|
118
|
+
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
119
|
+
req.httpBody = "{}".data(using: .utf8)
|
|
120
|
+
|
|
121
|
+
// bytes(for:) gives us an AsyncSequence<UInt8> we can consume
|
|
122
|
+
// chunk-by-chunk to observe streaming behavior. A non-streaming
|
|
123
|
+
// proxy would deliver everything in one TCP read; Hummingbird's
|
|
124
|
+
// ResponseBody writer should flush each `data: …\n\n` chunk
|
|
125
|
+
// separately.
|
|
126
|
+
let (stream, response) = try await URLSession.shared.bytes(for: req)
|
|
127
|
+
let http = response as! HTTPURLResponse
|
|
128
|
+
XCTAssertEqual(http.statusCode, 200)
|
|
129
|
+
XCTAssertEqual(http.value(forHTTPHeaderField: "Content-Type"), "text/event-stream")
|
|
130
|
+
XCTAssertEqual(http.value(forHTTPHeaderField: "Cache-Control"), "no-cache")
|
|
131
|
+
|
|
132
|
+
// Collect all body bytes into a buffer + record per-chunk
|
|
133
|
+
// arrival timestamps via newline boundaries. We count distinct
|
|
134
|
+
// ChunkArrivalEvents to verify streaming.
|
|
135
|
+
var collected = Data()
|
|
136
|
+
var firstByteAt: Date? = nil
|
|
137
|
+
var doneAt: Date? = nil
|
|
138
|
+
for try await byte in stream {
|
|
139
|
+
collected.append(byte)
|
|
140
|
+
if firstByteAt == nil { firstByteAt = Date() }
|
|
141
|
+
if collected.range(of: "[DONE]".data(using: .utf8)!) != nil {
|
|
142
|
+
doneAt = Date()
|
|
143
|
+
// Read a couple more bytes (trailing \n\n) then exit.
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
let asString = String(data: collected, encoding: .utf8) ?? ""
|
|
148
|
+
XCTAssertTrue(asString.contains("chunk1"))
|
|
149
|
+
XCTAssertTrue(asString.contains("chunk2"))
|
|
150
|
+
XCTAssertTrue(asString.contains("chunk3"))
|
|
151
|
+
XCTAssertTrue(asString.contains("[DONE]"))
|
|
152
|
+
|
|
153
|
+
// Smoke-check: total elapsed should be ≥ 3 × chunkInterval
|
|
154
|
+
// (since 4 chunks emit at 50ms apart). If the server were
|
|
155
|
+
// buffering the whole body server-side, time-to-first-byte
|
|
156
|
+
// would equal time-to-last-byte rather than streaming through.
|
|
157
|
+
if let first = firstByteAt, let done = doneAt {
|
|
158
|
+
let elapsed = done.timeIntervalSince(first)
|
|
159
|
+
XCTAssertGreaterThan(elapsed, 0.05,
|
|
160
|
+
"Streaming should take ≥ 1 chunkInterval; got \(elapsed)s")
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import DVAISharedCore
|
|
3
|
+
|
|
4
|
+
/// Stub handlers — the bind-lifecycle tests only need the HttpServer
|
|
5
|
+
/// to accept SOMETHING for `installRoutes` so it knows which routes
|
|
6
|
+
/// to register. They never actually send requests through.
|
|
7
|
+
private final class StubHandlers: DVAIHandlers, @unchecked Sendable {
|
|
8
|
+
func handleChatCompletion(body: [String: Any], ctx: HandlerContext) async throws -> HandlerResponse {
|
|
9
|
+
.json(200, [:])
|
|
10
|
+
}
|
|
11
|
+
func handleCompletion(body: [String: Any], ctx: HandlerContext) async throws -> HandlerResponse {
|
|
12
|
+
.json(200, [:])
|
|
13
|
+
}
|
|
14
|
+
func handleEmbeddings(body: [String: Any], ctx: HandlerContext) async throws -> HandlerResponse {
|
|
15
|
+
.json(200, [:])
|
|
16
|
+
}
|
|
17
|
+
func handleModels(ctx: HandlerContext) async throws -> HandlerResponse {
|
|
18
|
+
.json(200, [:])
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// Helper to install the stub triple before `tryBind` in tests.
|
|
23
|
+
private func makeReadyServer() async -> HttpServer {
|
|
24
|
+
let server = HttpServer()
|
|
25
|
+
let ctx = HandlerContext(modelId: "test", backendName: "test")
|
|
26
|
+
await server.installRoutes(handlers: StubHandlers(), ctx: ctx, corsConfig: .wildcard)
|
|
27
|
+
return server
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
final class HttpServerTest: XCTestCase {
|
|
31
|
+
func testTryBindBindsBasePort() async throws {
|
|
32
|
+
let server = await makeReadyServer()
|
|
33
|
+
let port = try await server.tryBind(basePort: 39001, maxAttempts: 4, host: "127.0.0.1")
|
|
34
|
+
XCTAssertEqual(port, 39001)
|
|
35
|
+
await server.stop()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func testTryBindFallsBackOnPortInUse() async throws {
|
|
39
|
+
// Block port 39010 with another server.
|
|
40
|
+
let blocker = await makeReadyServer()
|
|
41
|
+
_ = try await blocker.tryBind(basePort: 39010, maxAttempts: 1, host: "127.0.0.1")
|
|
42
|
+
|
|
43
|
+
let server = await makeReadyServer()
|
|
44
|
+
let port = try await server.tryBind(basePort: 39010, maxAttempts: 4, host: "127.0.0.1")
|
|
45
|
+
XCTAssertEqual(port, 39011)
|
|
46
|
+
await server.stop()
|
|
47
|
+
await blocker.stop()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func testStopIsIdempotent() async throws {
|
|
51
|
+
let server = await makeReadyServer()
|
|
52
|
+
await server.stop() // before start — should not throw
|
|
53
|
+
_ = try await server.tryBind(basePort: 39020, maxAttempts: 1, host: "127.0.0.1")
|
|
54
|
+
await server.stop()
|
|
55
|
+
await server.stop() // double-stop should not throw
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func testThrowsActionableErrorWhenAllPortsBlocked() async throws {
|
|
59
|
+
// Block 39030..39032.
|
|
60
|
+
var blockers: [HttpServer] = []
|
|
61
|
+
for i in 0..<3 {
|
|
62
|
+
let s = await makeReadyServer()
|
|
63
|
+
_ = try await s.tryBind(basePort: 39030 + i, maxAttempts: 1, host: "127.0.0.1")
|
|
64
|
+
blockers.append(s)
|
|
65
|
+
}
|
|
66
|
+
defer {
|
|
67
|
+
Task { for b in blockers { await b.stop() } }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let server = await makeReadyServer()
|
|
71
|
+
do {
|
|
72
|
+
_ = try await server.tryBind(basePort: 39030, maxAttempts: 3, host: "127.0.0.1")
|
|
73
|
+
XCTFail("should have thrown")
|
|
74
|
+
} catch let error as NSError {
|
|
75
|
+
XCTAssertTrue(error.localizedDescription.contains("39030..39032"),
|
|
76
|
+
"Error should name the tried range; got: \(error.localizedDescription)")
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func testTryBindWithoutInstallRoutesThrows() async throws {
|
|
81
|
+
// Calling tryBind without installRoutes should fail-fast with
|
|
82
|
+
// an actionable error.
|
|
83
|
+
let server = HttpServer()
|
|
84
|
+
do {
|
|
85
|
+
_ = try await server.tryBind(basePort: 39040, maxAttempts: 1, host: "127.0.0.1")
|
|
86
|
+
XCTFail("should have thrown")
|
|
87
|
+
} catch let error as NSError {
|
|
88
|
+
XCTAssertTrue(
|
|
89
|
+
error.localizedDescription.lowercased().contains("installroutes"),
|
|
90
|
+
"Expected error to mention installRoutes; got: \(error.localizedDescription)"
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dvai-bridge/ios-shared-core",
|
|
3
|
+
"version": "4.0.0",
|
|
4
|
+
"description": "DVAI-Bridge iOS shared HTTP-server + handler-dispatch types used by all backend cores. Extracted from ios-llama-core so non-llama backends don't transitively inherit llama.xcframework.",
|
|
5
|
+
"author": "Deep Chakraborty <https://github.com/dk013>",
|
|
6
|
+
"license": "Custom (See LICENSE)",
|
|
7
|
+
"main": "Package.swift",
|
|
8
|
+
"files": [
|
|
9
|
+
"Package.swift",
|
|
10
|
+
"ios",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"registry": "https://registry.npmjs.org/",
|
|
16
|
+
"access": "public"
|
|
17
|
+
}
|
|
18
|
+
}
|