@annadata/capacitor-mqtt-quic 0.1.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/README.md +399 -0
- package/android/NGTCP2_BUILD_INSTRUCTIONS.md +319 -0
- package/android/build-nghttp3.sh +182 -0
- package/android/build-ngtcp2.sh +289 -0
- package/android/build-openssl.sh +302 -0
- package/android/build.gradle +75 -0
- package/android/gradle.properties +3 -0
- package/android/proguard-rules.pro +2 -0
- package/android/settings.gradle +1 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/assets/mqttquic_ca.pem +5 -0
- package/android/src/main/cpp/CMakeLists.txt +157 -0
- package/android/src/main/cpp/ngtcp2_jni.cpp +928 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/MqttQuicPlugin.kt +232 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/client/MQTTClient.kt +339 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTT5Properties.kt +250 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTT5Protocol.kt +281 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTT5ReasonCodes.kt +109 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTTProtocol.kt +249 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTTTypes.kt +47 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/quic/NGTCP2Client.kt +184 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicClientStub.kt +54 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicTypes.kt +21 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/transport/QUICStreamAdapter.kt +37 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/transport/StreamTransport.kt +91 -0
- package/android/src/test/kotlin/ai/annadata/mqttquic/mqtt/MQTTProtocolTest.kt +92 -0
- package/dist/esm/definitions.d.ts +66 -0
- package/dist/esm/definitions.d.ts.map +1 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +5 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +28 -0
- package/dist/esm/web.d.ts.map +1 -0
- package/dist/esm/web.js +183 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +217 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +215 -0
- package/dist/plugin.js.map +1 -0
- package/ios/MqttQuicPlugin.podspec +34 -0
- package/ios/NGTCP2_BUILD_INSTRUCTIONS.md +302 -0
- package/ios/Sources/MqttQuicPlugin/Client/MQTTClient.swift +343 -0
- package/ios/Sources/MqttQuicPlugin/MQTT/MQTT5Properties.swift +280 -0
- package/ios/Sources/MqttQuicPlugin/MQTT/MQTT5Protocol.swift +333 -0
- package/ios/Sources/MqttQuicPlugin/MQTT/MQTT5ReasonCodes.swift +113 -0
- package/ios/Sources/MqttQuicPlugin/MQTT/MQTTProtocol.swift +322 -0
- package/ios/Sources/MqttQuicPlugin/MQTT/MQTTTypes.swift +54 -0
- package/ios/Sources/MqttQuicPlugin/MqttQuicPlugin.swift +229 -0
- package/ios/Sources/MqttQuicPlugin/QUIC/NGTCP2Bridge.h +29 -0
- package/ios/Sources/MqttQuicPlugin/QUIC/NGTCP2Bridge.mm +865 -0
- package/ios/Sources/MqttQuicPlugin/QUIC/NGTCP2Client.swift +262 -0
- package/ios/Sources/MqttQuicPlugin/QUIC/QuicClientStub.swift +66 -0
- package/ios/Sources/MqttQuicPlugin/QUIC/QuicTypes.swift +23 -0
- package/ios/Sources/MqttQuicPlugin/Resources/mqttquic_ca.pem +5 -0
- package/ios/Sources/MqttQuicPlugin/Transport/QUICStreamAdapter.swift +50 -0
- package/ios/Sources/MqttQuicPlugin/Transport/StreamTransport.swift +105 -0
- package/ios/Tests/MQTTProtocolTests.swift +82 -0
- package/ios/build-nghttp3.sh +173 -0
- package/ios/build-ngtcp2.sh +278 -0
- package/ios/build-openssl.sh +405 -0
- package/package.json +63 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
//
|
|
2
|
+
// NGTCP2Client.swift
|
|
3
|
+
// MqttQuicPlugin
|
|
4
|
+
//
|
|
5
|
+
// ngtcp2-based QUIC client implementation.
|
|
6
|
+
// Replaces QuicClientStub when ngtcp2 is built and linked.
|
|
7
|
+
//
|
|
8
|
+
// Build Requirements:
|
|
9
|
+
// - ngtcp2 static library (libngtcp2.a)
|
|
10
|
+
// - nghttp3 static library (libnghttp3.a)
|
|
11
|
+
// - OpenSSL 3.0+ or BoringSSL for TLS 1.3
|
|
12
|
+
// - iOS 15.0+ (for Network framework)
|
|
13
|
+
//
|
|
14
|
+
|
|
15
|
+
import Foundation
|
|
16
|
+
|
|
17
|
+
/// ngtcp2-based QUIC client implementation
|
|
18
|
+
public final class NGTCP2Client: QuicClientProtocol {
|
|
19
|
+
|
|
20
|
+
// MARK: - Properties
|
|
21
|
+
|
|
22
|
+
/// Native ngtcp2 client handle
|
|
23
|
+
fileprivate var clientHandle: UnsafeMutableRawPointer?
|
|
24
|
+
|
|
25
|
+
/// Connection state
|
|
26
|
+
private var isConnected: Bool = false
|
|
27
|
+
|
|
28
|
+
/// Host and port for connection
|
|
29
|
+
private var host: String?
|
|
30
|
+
private var port: UInt16?
|
|
31
|
+
|
|
32
|
+
/// Active streams
|
|
33
|
+
private var streams: [UInt64: NGTCP2Stream] = [:]
|
|
34
|
+
private let streamLock = NSLock()
|
|
35
|
+
|
|
36
|
+
// MARK: - Initialization
|
|
37
|
+
|
|
38
|
+
public init() {
|
|
39
|
+
clientHandle = ngtcp2_client_create()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
deinit {
|
|
43
|
+
// Cleanup
|
|
44
|
+
Task {
|
|
45
|
+
try? await close()
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// MARK: - QuicClientProtocol Implementation
|
|
50
|
+
|
|
51
|
+
public func connect(host: String, port: UInt16) async throws {
|
|
52
|
+
guard !isConnected else {
|
|
53
|
+
throw NGTCP2Error.alreadyConnected
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
self.host = host
|
|
57
|
+
self.port = port
|
|
58
|
+
|
|
59
|
+
guard let handle = clientHandle else {
|
|
60
|
+
throw NGTCP2Error.quicError("native handle not initialized")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let alpn = "mqtt"
|
|
64
|
+
let rv = host.withCString { hostPtr in
|
|
65
|
+
alpn.withCString { alpnPtr in
|
|
66
|
+
ngtcp2_client_connect(handle, hostPtr, port, alpnPtr)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if rv != 0 {
|
|
70
|
+
throw NGTCP2Error.quicError(lastErrorMessage())
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
isConnected = true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
public func openStream() async throws -> QuicStreamProtocol {
|
|
77
|
+
guard isConnected else {
|
|
78
|
+
throw NGTCP2Error.notConnected
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
streamLock.lock()
|
|
82
|
+
defer { streamLock.unlock() }
|
|
83
|
+
|
|
84
|
+
guard let handle = clientHandle else {
|
|
85
|
+
throw NGTCP2Error.clientDisconnected
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let streamId = ngtcp2_client_open_stream(handle)
|
|
89
|
+
if streamId < 0 {
|
|
90
|
+
throw NGTCP2Error.quicError(lastErrorMessage())
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let stream = NGTCP2Stream(streamId: UInt64(streamId), client: self)
|
|
94
|
+
streams[UInt64(streamId)] = stream
|
|
95
|
+
|
|
96
|
+
return stream
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
public func close() async throws {
|
|
100
|
+
if !isConnected {
|
|
101
|
+
if let handle = clientHandle {
|
|
102
|
+
_ = ngtcp2_client_close(handle)
|
|
103
|
+
ngtcp2_client_destroy(handle)
|
|
104
|
+
clientHandle = nil
|
|
105
|
+
}
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Close all streams
|
|
110
|
+
streamLock.lock()
|
|
111
|
+
let activeStreams = Array(streams.values)
|
|
112
|
+
streams.removeAll()
|
|
113
|
+
streamLock.unlock()
|
|
114
|
+
|
|
115
|
+
for stream in activeStreams {
|
|
116
|
+
try? await stream.close()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if let handle = clientHandle {
|
|
120
|
+
_ = ngtcp2_client_close(handle)
|
|
121
|
+
ngtcp2_client_destroy(handle)
|
|
122
|
+
clientHandle = nil
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
isConnected = false
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// MARK: - Internal Methods (for NGTCP2Stream)
|
|
129
|
+
|
|
130
|
+
/// Send data on a stream (called by NGTCP2Stream)
|
|
131
|
+
internal func sendStreamData(streamId: UInt64, data: Data) async throws {
|
|
132
|
+
guard isConnected else {
|
|
133
|
+
throw NGTCP2Error.notConnected
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
guard let handle = clientHandle else {
|
|
137
|
+
throw NGTCP2Error.clientDisconnected
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let result = data.withUnsafeBytes { buffer in
|
|
141
|
+
ngtcp2_client_write_stream(handle,
|
|
142
|
+
Int64(streamId),
|
|
143
|
+
buffer.bindMemory(to: UInt8.self).baseAddress,
|
|
144
|
+
data.count,
|
|
145
|
+
0)
|
|
146
|
+
}
|
|
147
|
+
if result != 0 {
|
|
148
|
+
throw NGTCP2Error.quicError(lastErrorMessage())
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/// Receive data from a stream (called by packet receive handler)
|
|
153
|
+
internal func receiveStreamData(streamId: UInt64, data: Data) {
|
|
154
|
+
// Native layer delivers stream data directly; no-op here.
|
|
155
|
+
_ = data
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private func lastErrorMessage() -> String {
|
|
159
|
+
guard let handle = clientHandle, let cStr = ngtcp2_client_last_error(handle) else {
|
|
160
|
+
return "unknown QUIC error"
|
|
161
|
+
}
|
|
162
|
+
return String(cString: cStr)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// MARK: - NGTCP2Stream Implementation
|
|
167
|
+
|
|
168
|
+
/// ngtcp2-based QUIC stream
|
|
169
|
+
internal final class NGTCP2Stream: QuicStreamProtocol {
|
|
170
|
+
let streamId: UInt64
|
|
171
|
+
private weak var client: NGTCP2Client?
|
|
172
|
+
private var isClosed: Bool = false
|
|
173
|
+
|
|
174
|
+
init(streamId: UInt64, client: NGTCP2Client) {
|
|
175
|
+
self.streamId = streamId
|
|
176
|
+
self.client = client
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
func read(maxBytes: Int) async throws -> Data {
|
|
180
|
+
guard !isClosed else {
|
|
181
|
+
throw NGTCP2Error.streamClosed
|
|
182
|
+
}
|
|
183
|
+
guard let client = client else {
|
|
184
|
+
throw NGTCP2Error.clientDisconnected
|
|
185
|
+
}
|
|
186
|
+
guard let handle = client.clientHandle else {
|
|
187
|
+
throw NGTCP2Error.clientDisconnected
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
while !isClosed {
|
|
191
|
+
var buffer = [UInt8](repeating: 0, count: maxBytes)
|
|
192
|
+
let nread = buffer.withUnsafeMutableBytes { rawBuffer -> Int in
|
|
193
|
+
let ptr = rawBuffer.bindMemory(to: UInt8.self).baseAddress
|
|
194
|
+
return ngtcp2_client_read_stream(handle, Int64(streamId), ptr, maxBytes)
|
|
195
|
+
}
|
|
196
|
+
if nread < 0 {
|
|
197
|
+
throw NGTCP2Error.quicError(client.lastErrorMessage())
|
|
198
|
+
}
|
|
199
|
+
if nread > 0 {
|
|
200
|
+
return Data(buffer.prefix(Int(nread)))
|
|
201
|
+
}
|
|
202
|
+
try await Task.sleep(nanoseconds: 5_000_000) // 5ms backoff
|
|
203
|
+
}
|
|
204
|
+
return Data()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
func write(_ data: Data) async throws {
|
|
208
|
+
guard !isClosed else {
|
|
209
|
+
throw NGTCP2Error.streamClosed
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
guard let client = client else {
|
|
213
|
+
throw NGTCP2Error.clientDisconnected
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
try await client.sendStreamData(streamId: streamId, data: data)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
func close() async throws {
|
|
220
|
+
isClosed = true
|
|
221
|
+
|
|
222
|
+
if let client = client, let handle = client.clientHandle {
|
|
223
|
+
_ = ngtcp2_client_close_stream(handle, Int64(streamId))
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/// Called by client when data is received for this stream
|
|
228
|
+
func receiveData(_ data: Data) {
|
|
229
|
+
_ = data
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// MARK: - Error Types
|
|
234
|
+
|
|
235
|
+
public enum NGTCP2Error: Error, LocalizedError {
|
|
236
|
+
case notConnected
|
|
237
|
+
case alreadyConnected
|
|
238
|
+
case connectionCancelled
|
|
239
|
+
case streamClosed
|
|
240
|
+
case clientDisconnected
|
|
241
|
+
case tlsError(String)
|
|
242
|
+
case quicError(String)
|
|
243
|
+
|
|
244
|
+
public var errorDescription: String? {
|
|
245
|
+
switch self {
|
|
246
|
+
case .notConnected:
|
|
247
|
+
return "QUIC client is not connected"
|
|
248
|
+
case .alreadyConnected:
|
|
249
|
+
return "QUIC client is already connected"
|
|
250
|
+
case .connectionCancelled:
|
|
251
|
+
return "QUIC connection was cancelled"
|
|
252
|
+
case .streamClosed:
|
|
253
|
+
return "QUIC stream is closed"
|
|
254
|
+
case .clientDisconnected:
|
|
255
|
+
return "QUIC client is disconnected"
|
|
256
|
+
case .tlsError(let message):
|
|
257
|
+
return "TLS error: \(message)"
|
|
258
|
+
case .quicError(let message):
|
|
259
|
+
return "QUIC error: \(message)"
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
//
|
|
2
|
+
// QuicClientStub.swift
|
|
3
|
+
// MqttQuicPlugin
|
|
4
|
+
//
|
|
5
|
+
// Stub QUIC client for Phase 2. Replace with ngtcp2-backed implementation when
|
|
6
|
+
// ngtcp2 is built for iOS (see README). Uses in-memory stream for testing.
|
|
7
|
+
//
|
|
8
|
+
|
|
9
|
+
import Foundation
|
|
10
|
+
|
|
11
|
+
/// Stub stream: buffers read/write in memory. Used when ngtcp2 is not linked.
|
|
12
|
+
public final class QuicStreamStub: QuicStreamProtocol {
|
|
13
|
+
public let streamId: UInt64
|
|
14
|
+
private let buffer: MockStreamBuffer
|
|
15
|
+
|
|
16
|
+
public init(streamId: UInt64, buffer: MockStreamBuffer) {
|
|
17
|
+
self.streamId = streamId
|
|
18
|
+
self.buffer = buffer
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public func read(maxBytes: Int) async throws -> Data {
|
|
22
|
+
let reader = MockStreamReader(buffer: buffer)
|
|
23
|
+
return try await reader.read(maxBytes: maxBytes)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public func write(_ data: Data) async throws {
|
|
27
|
+
let writer = MockStreamWriter(buffer: buffer)
|
|
28
|
+
try await writer.write(data)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public func close() async throws {
|
|
32
|
+
buffer.isClosed = true
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// Stub QUIC client. connect() succeeds without network; openStream() returns a stub stream.
|
|
37
|
+
/// Phase 2 proper: use ngtcp2_conn_client_new, TLS 1.3, NWConnection for UDP.
|
|
38
|
+
/// Pass initialReadData (e.g. CONNACK) to simulate server response for testing.
|
|
39
|
+
public final class QuicClientStub: QuicClientProtocol {
|
|
40
|
+
private var streamId: UInt64 = 0
|
|
41
|
+
private var buffer: MockStreamBuffer?
|
|
42
|
+
private var _stream: QuicStreamStub?
|
|
43
|
+
private let initialReadData: Data
|
|
44
|
+
|
|
45
|
+
public init(initialReadData: Data = Data()) {
|
|
46
|
+
self.initialReadData = initialReadData
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public func connect(host: String, port: UInt16) async throws {
|
|
50
|
+
buffer = MockStreamBuffer(initialReadData: initialReadData)
|
|
51
|
+
streamId = 0
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
public func openStream() async throws -> QuicStreamProtocol {
|
|
55
|
+
guard let buf = buffer else { throw NSError(domain: "QuicClientStub", code: -1, userInfo: [NSLocalizedDescriptionKey: "Not connected"]) }
|
|
56
|
+
let s = QuicStreamStub(streamId: streamId, buffer: buf)
|
|
57
|
+
streamId += 1
|
|
58
|
+
_stream = s
|
|
59
|
+
return s
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public func close() async throws {
|
|
63
|
+
_stream = nil
|
|
64
|
+
buffer = nil
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//
|
|
2
|
+
// QuicTypes.swift
|
|
3
|
+
// MqttQuicPlugin
|
|
4
|
+
//
|
|
5
|
+
// QUIC transport types. Phase 2: ngtcp2 client + single bidirectional stream.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import Foundation
|
|
9
|
+
|
|
10
|
+
/// Represents a single QUIC bidirectional stream (one per MQTT connection).
|
|
11
|
+
public protocol QuicStreamProtocol: AnyObject {
|
|
12
|
+
var streamId: UInt64 { get }
|
|
13
|
+
func read(maxBytes: Int) async throws -> Data
|
|
14
|
+
func write(_ data: Data) async throws
|
|
15
|
+
func close() async throws
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// QUIC client: connect, TLS handshake, open one bidirectional stream.
|
|
19
|
+
public protocol QuicClientProtocol: AnyObject {
|
|
20
|
+
func connect(host: String, port: UInt16) async throws
|
|
21
|
+
func openStream() async throws -> QuicStreamProtocol
|
|
22
|
+
func close() async throws
|
|
23
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//
|
|
2
|
+
// QUICStreamAdapter.swift
|
|
3
|
+
// MqttQuicPlugin
|
|
4
|
+
//
|
|
5
|
+
// StreamReader/StreamWriter over QUIC stream. Mirrors NGTCP2StreamReader/NGTCP2StreamWriter.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import Foundation
|
|
9
|
+
|
|
10
|
+
/// MQTTStreamReader over a QuicStream.
|
|
11
|
+
public final class QUICStreamReader: MQTTStreamReaderProtocol {
|
|
12
|
+
private let stream: QuicStreamProtocol
|
|
13
|
+
|
|
14
|
+
public init(stream: QuicStreamProtocol) {
|
|
15
|
+
self.stream = stream
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public func read(maxBytes: Int) async throws -> Data {
|
|
19
|
+
try await stream.read(maxBytes: maxBytes)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public func readexactly(_ n: Int) async throws -> Data {
|
|
23
|
+
var acc = Data()
|
|
24
|
+
while acc.count < n {
|
|
25
|
+
let chunk = try await stream.read(maxBytes: n - acc.count)
|
|
26
|
+
if chunk.isEmpty { throw MQTTProtocolError.insufficientData("readexactly") }
|
|
27
|
+
acc.append(chunk)
|
|
28
|
+
}
|
|
29
|
+
return acc
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// MQTTStreamWriter over a QuicStream.
|
|
34
|
+
public final class QUICStreamWriter: MQTTStreamWriterProtocol {
|
|
35
|
+
private let stream: QuicStreamProtocol
|
|
36
|
+
|
|
37
|
+
public init(stream: QuicStreamProtocol) {
|
|
38
|
+
self.stream = stream
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public func write(_ data: Data) async throws {
|
|
42
|
+
try await stream.write(data)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public func drain() async throws {}
|
|
46
|
+
|
|
47
|
+
public func close() async throws {
|
|
48
|
+
try await stream.close()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
//
|
|
2
|
+
// StreamTransport.swift
|
|
3
|
+
// MqttQuicPlugin
|
|
4
|
+
//
|
|
5
|
+
// Transport abstraction: StreamReader-like and StreamWriter-like.
|
|
6
|
+
// Phase 2 will implement these over QUIC streams; Phase 1 uses mocks for tests.
|
|
7
|
+
//
|
|
8
|
+
|
|
9
|
+
import Foundation
|
|
10
|
+
|
|
11
|
+
/// StreamReader-like interface: read(n), readexactly(n).
|
|
12
|
+
/// Async where applicable (Phase 2 uses async).
|
|
13
|
+
public protocol MQTTStreamReaderProtocol: AnyObject {
|
|
14
|
+
func read(maxBytes: Int) async throws -> Data
|
|
15
|
+
func readexactly(_ n: Int) async throws -> Data
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// StreamWriter-like interface: write(data), drain(), close().
|
|
19
|
+
public protocol MQTTStreamWriterProtocol: AnyObject {
|
|
20
|
+
func write(_ data: Data) async throws
|
|
21
|
+
func drain() async throws
|
|
22
|
+
func close() async throws
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// MARK: - Mock implementations (Phase 1 unit tests)
|
|
26
|
+
|
|
27
|
+
/// In-memory buffer for mock read/write.
|
|
28
|
+
public final class MockStreamBuffer {
|
|
29
|
+
public private(set) var readBuffer: Data
|
|
30
|
+
public private(set) var writeBuffer: Data
|
|
31
|
+
public var isClosed: Bool = false
|
|
32
|
+
|
|
33
|
+
public init(initialReadData: Data = Data()) {
|
|
34
|
+
self.readBuffer = initialReadData
|
|
35
|
+
self.writeBuffer = Data()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public func appendRead(_ data: Data) {
|
|
39
|
+
readBuffer.append(data)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public func consumeRead(maxBytes: Int) -> Data {
|
|
43
|
+
let n = min(maxBytes, readBuffer.count)
|
|
44
|
+
let out = readBuffer.prefix(n)
|
|
45
|
+
readBuffer = readBuffer.dropFirst(n)
|
|
46
|
+
return Data(out)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public func consumeWrite() -> Data {
|
|
50
|
+
let d = writeBuffer
|
|
51
|
+
writeBuffer = Data()
|
|
52
|
+
return d
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// Mock reader: reads from MockStreamBuffer.
|
|
57
|
+
public final class MockStreamReader: MQTTStreamReaderProtocol {
|
|
58
|
+
private let buffer: MockStreamBuffer
|
|
59
|
+
|
|
60
|
+
public init(buffer: MockStreamBuffer) {
|
|
61
|
+
self.buffer = buffer
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public func read(maxBytes: Int) async throws -> Data {
|
|
65
|
+
if buffer.isClosed && buffer.readBuffer.isEmpty {
|
|
66
|
+
return Data()
|
|
67
|
+
}
|
|
68
|
+
let n = min(maxBytes, buffer.readBuffer.count)
|
|
69
|
+
guard n > 0 else {
|
|
70
|
+
try await Task.sleep(nanoseconds: 1_000_000) // 1ms
|
|
71
|
+
return try await read(maxBytes: maxBytes)
|
|
72
|
+
}
|
|
73
|
+
return buffer.consumeRead(maxBytes: n)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
public func readexactly(_ n: Int) async throws -> Data {
|
|
77
|
+
var acc = Data()
|
|
78
|
+
while acc.count < n {
|
|
79
|
+
if buffer.isClosed { throw MQTTProtocolError.insufficientData("stream closed") }
|
|
80
|
+
let chunk = try await read(maxBytes: n - acc.count)
|
|
81
|
+
if chunk.isEmpty { throw MQTTProtocolError.insufficientData("readexactly") }
|
|
82
|
+
acc.append(chunk)
|
|
83
|
+
}
|
|
84
|
+
return acc
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// Mock writer: appends to MockStreamBuffer.
|
|
89
|
+
public final class MockStreamWriter: MQTTStreamWriterProtocol {
|
|
90
|
+
private let buffer: MockStreamBuffer
|
|
91
|
+
|
|
92
|
+
public init(buffer: MockStreamBuffer) {
|
|
93
|
+
self.buffer = buffer
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
public func write(_ data: Data) async throws {
|
|
97
|
+
buffer.writeBuffer.append(data)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
public func drain() async throws {}
|
|
101
|
+
|
|
102
|
+
public func close() async throws {
|
|
103
|
+
buffer.isClosed = true
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
//
|
|
2
|
+
// MQTTProtocolTests.swift
|
|
3
|
+
// Unit tests for MQTT encode/decode. Run via Xcode test target or Swift Package.
|
|
4
|
+
//
|
|
5
|
+
|
|
6
|
+
import XCTest
|
|
7
|
+
@testable import MqttQuicPlugin
|
|
8
|
+
|
|
9
|
+
final class MQTTProtocolTests: XCTestCase {
|
|
10
|
+
|
|
11
|
+
func testEncodeDecodeRemainingLength() throws {
|
|
12
|
+
for (len, bytes) in [(0, 1), (127, 1), (128, 2), (16383, 2), (16384, 3), (2_097_151, 3), (2_097_152, 4)] {
|
|
13
|
+
let enc = try MQTTProtocol.encodeRemainingLength(len)
|
|
14
|
+
XCTAssertEqual(enc.count, bytes, "length \(len)")
|
|
15
|
+
let (dec, _) = try MQTTProtocol.decodeRemainingLength(Data(enc), offset: 0)
|
|
16
|
+
XCTAssertEqual(dec, len)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func testEncodeDecodeString() throws {
|
|
21
|
+
let s = "hello/mqtt"
|
|
22
|
+
let enc = try MQTTProtocol.encodeString(s)
|
|
23
|
+
XCTAssertEqual(enc.count, 2 + s.utf8.count)
|
|
24
|
+
let (dec, _) = try MQTTProtocol.decodeString(enc, offset: 0)
|
|
25
|
+
XCTAssertEqual(dec, s)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func testBuildConnect() throws {
|
|
29
|
+
let data = try MQTTProtocol.buildConnect(clientId: "test-client", username: "u", password: "p", keepalive: 90, cleanSession: true)
|
|
30
|
+
XCTAssertGreaterThanOrEqual(data.count, 10)
|
|
31
|
+
XCTAssertEqual(data[0], MQTTMessageType.CONNECT.rawValue)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func testBuildConnack() {
|
|
35
|
+
let data = MQTTProtocol.buildConnack(returnCode: MQTTConnAckCode.accepted.rawValue)
|
|
36
|
+
XCTAssertEqual(data.count, 4)
|
|
37
|
+
XCTAssertEqual(data[0], MQTTMessageType.CONNACK.rawValue)
|
|
38
|
+
XCTAssertEqual(data[3], MQTTConnAckCode.accepted.rawValue)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func testBuildPublish() throws {
|
|
42
|
+
let payload = Data("hello".utf8)
|
|
43
|
+
let data = try MQTTProtocol.buildPublish(topic: "a/b", payload: payload, qos: 0, retain: false)
|
|
44
|
+
XCTAssertEqual(data[0] & 0xF0, MQTTMessageType.PUBLISH.rawValue)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func testBuildSubscribeAndSuback() throws {
|
|
48
|
+
let sub = try MQTTProtocol.buildSubscribe(packetId: 1, topic: "t/1", qos: 0)
|
|
49
|
+
XCTAssertEqual(sub[0], MQTTMessageType.SUBSCRIBE.rawValue | 0x02)
|
|
50
|
+
|
|
51
|
+
let suback = MQTTProtocol.buildSuback(packetId: 1, returnCode: 0)
|
|
52
|
+
XCTAssertEqual(suback[0], MQTTMessageType.SUBACK.rawValue)
|
|
53
|
+
let (pid, rc, _) = try MQTTProtocol.parseSuback(suback, offset: 2) // skip fixed header (2 bytes)
|
|
54
|
+
XCTAssertEqual(pid, 1)
|
|
55
|
+
XCTAssertEqual(rc, 0)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func testBuildPingreqPingrespDisconnect() {
|
|
59
|
+
let pr = MQTTProtocol.buildPingreq()
|
|
60
|
+
XCTAssertEqual(pr[0], MQTTMessageType.PINGREQ.rawValue)
|
|
61
|
+
let ps = MQTTProtocol.buildPingresp()
|
|
62
|
+
XCTAssertEqual(ps[0], MQTTMessageType.PINGRESP.rawValue)
|
|
63
|
+
let dc = MQTTProtocol.buildDisconnect()
|
|
64
|
+
XCTAssertEqual(dc[0], MQTTMessageType.DISCONNECT.rawValue)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func testMockStreamReaderWriter() async throws {
|
|
68
|
+
let buf = MockStreamBuffer(initialReadData: Data([1, 2, 3, 4, 5]))
|
|
69
|
+
let reader = MockStreamReader(buffer: buf)
|
|
70
|
+
let writer = MockStreamWriter(buffer: buf)
|
|
71
|
+
|
|
72
|
+
let r1 = try await reader.read(maxBytes: 2)
|
|
73
|
+
XCTAssertEqual(r1, Data([1, 2]))
|
|
74
|
+
let r2 = try await reader.readexactly(3)
|
|
75
|
+
XCTAssertEqual(r2, Data([3, 4, 5]))
|
|
76
|
+
|
|
77
|
+
try await writer.write(Data([6, 7, 8]))
|
|
78
|
+
try await writer.drain()
|
|
79
|
+
let written = buf.consumeWrite()
|
|
80
|
+
XCTAssertEqual(written, Data([6, 7, 8]))
|
|
81
|
+
}
|
|
82
|
+
}
|