init
Some checks failed
ci / macos (push) Has been cancelled
ci / ios (push) Has been cancelled
ci / check-linter (push) Has been cancelled

This commit is contained in:
wenzuhuai
2026-01-12 18:29:52 +08:00
commit d7bdb4f378
87 changed files with 12664 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
import Nats
let nats = NatsClientOptions()
.url(URL(string: "nats://localhost:4222")!)
.build()
print("Connecting...")
try! await nats.connect()
print("Connected!")
let data = "foo".data(using: .utf8)!
// Warmup
print("Warming up...")
for _ in 0..<10_000 {
try! await nats.publish(data, subject: "foo")
}
print("Starting benchmark...")
let now = DispatchTime.now()
let numMsgs = 1_000_000
for _ in 0..<numMsgs {
try! await nats.publish(data, subject: "foo")
}
try! await nats.flush()
let elapsed = DispatchTime.now().uptimeNanoseconds - now.uptimeNanoseconds
let msgsPerSec: Double = Double(numMsgs) / (Double(elapsed) / 1_000_000_000)
print("Elapsed: \(elapsed / 1_000_000)ms")
print("\(msgsPerSec) msgs/s")

View File

@@ -0,0 +1,82 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
import Nats
let nats = NatsClientOptions().urls([URL(string: "nats://localhost:4222")!]).build()
print("Connecting...")
try! await nats.connect()
print("Connected!")
let data = "foo".data(using: .utf8)!
// Warmup
print("Warming up...")
for _ in 0..<10_000 {
try! await nats.publish(data, subject: "foo")
}
print("Starting benchmark...")
let now = DispatchTime.now()
let numMsgs = 1_000_000
let sub = try await nats.subscribe(subject: "foo")
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
var hm = NatsHeaderMap()
hm.append(try! NatsHeaderName("foo"), NatsHeaderValue("bar"))
hm.append(try! NatsHeaderName("foo"), NatsHeaderValue("baz"))
hm.insert(try! NatsHeaderName("another"), NatsHeaderValue("one"))
var i = 0
for try await msg in sub {
let payload = msg.payload!
if String(data: payload, encoding: .utf8) != "\(i)" {
let emptyString = ""
print(
"invalid payload; expected: \(i); got: \(String(data: payload, encoding: .utf8) ?? emptyString)"
)
}
guard let headers = msg.headers else {
print("empty headers!")
continue
}
if headers != hm {
print("invalid headers; expected: \(hm); got: \(headers)")
}
if i % 1000 == 0 {
print("received \(i) msgs")
}
i += 1
}
}
group.addTask {
var hm = NatsHeaderMap()
hm.append(try! NatsHeaderName("foo"), NatsHeaderValue("bar"))
hm.append(try! NatsHeaderName("foo"), NatsHeaderValue("baz"))
hm.insert(try! NatsHeaderName("another"), NatsHeaderValue("one"))
for i in 0..<numMsgs {
try await nats.publish("\(i)".data(using: .utf8)!, subject: "foo", headers: hm)
if i % 1000 == 0 {
print("published \(i) msgs")
}
}
}
// Wait for all tasks in the group to complete
try await group.waitForAll()
}
try! await nats.flush()
let elapsed = DispatchTime.now().uptimeNanoseconds - now.uptimeNanoseconds
let msgsPerSec: Double = Double(numMsgs) / (Double(elapsed) / 1_000_000_000)
print("Elapsed: \(elapsed / 1_000_000)ms")
print("\(msgsPerSec) msgs/s")

View File

@@ -0,0 +1,60 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
import Nats
let nats = NatsClientOptions().url(URL(string: "nats://localhost:4222")!).build()
print("Connecting...")
try! await nats.connect()
print("Connected!")
let data = "foo".data(using: .utf8)!
// Warmup
print("Starting benchmark...")
print("Waiting for first message...")
var now = DispatchTime.now()
let numMsgs = 1_000_000
let sub = try await nats.subscribe(subject: "foo").makeAsyncIterator()
var hm = NatsHeaderMap()
hm.append(try! NatsHeaderName("foo"), NatsHeaderValue("bar"))
hm.append(try! NatsHeaderName("foo"), NatsHeaderValue("baz"))
for i in 1...numMsgs {
let msg = try await sub.next()
if i == 0 {
print("Received first message! Starting the timer")
now = DispatchTime.now()
}
guard let payload = msg?.payload else {
print("empty payload!")
continue
}
if String(data: payload, encoding: .utf8) != "\(i)" {
let emptyString = ""
print(
"invalid payload; expected: \(i); got: \(String(data: payload, encoding: .utf8) ?? emptyString)"
)
}
guard msg?.headers != nil else {
print("empty headers!")
continue
}
if i % 1000 == 0 {
print("received \(i) msgs")
}
}
let elapsed = DispatchTime.now().uptimeNanoseconds - now.uptimeNanoseconds
let msgsPerSec: Double = Double(numMsgs) / (Double(elapsed) / 1_000_000_000)
print("Elapsed: \(elapsed / 1_000_000)ms")
print("\(msgsPerSec) msgs/s")

76
Sources/Example/main.swift Executable file
View File

@@ -0,0 +1,76 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
import Nats
print("\n### Setup NATS Connection")
let nats = NatsClientOptions()
.url(URL(string: "nats://localhost:4222")!)
.build()
nats.on(.connected) { event in
print("event: connected")
}
print("connecting...")
try await nats.connect()
print("\n### Publish / Subscribe")
print("subscribing...")
let sub = try await nats.subscribe(subject: "foo.>")
let loop = Task {
print("starting message loop...")
for try await msg in sub {
if msg.subject == "foo.done" {
break
}
if let payload = msg.payload {
print("received \(msg.subject): \(String(data: payload, encoding: .utf8) ?? "")")
}
if let headers = msg.headers {
if let headerValue = headers.get(try! NatsHeaderName("X-Example")) {
print(" header: X-Example: \(headerValue.description)")
}
}
}
print("message loop done...")
}
print("publishing data...")
for i in 1...3 {
var headers = NatsHeaderMap()
headers.append(try! NatsHeaderName("X-Example"), NatsHeaderValue("example value"))
if let data = "data\(i)".data(using: .utf8) {
try await nats.publish(data, subject: "foo.\(i)", headers: headers)
}
}
print("signalling done...")
try await nats.publish(Data(), subject: "foo.done")
try await loop.value
print("closing...")
try await nats.close()
print("bye")

View File

@@ -0,0 +1,220 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
import Nats
import Nuid
/// Extension to ``Consumer`` adding pull consumer capabilities.
extension Consumer {
/// Retrieves up to a provided number of messages from a stream.
/// This method will send a single request and deliver requested messages unless time out is met earlier.
///
/// - Parameters:
/// - batch: maximum number of messages to be retrieved
/// - expires: timeout of a pull request
/// - idleHeartbeat: interval in which server should send heartbeat messages (if no user messages are available).
///
/// - Returns: ``FetchResult`` which implements ``AsyncSequence`` allowing iteration over messages.
///
/// - Throws:
/// - ``JetStreamError/FetchError`` if there was an error while fetching messages
public func fetch(
batch: Int, expires: TimeInterval = 30, idleHeartbeat: TimeInterval? = nil
) async throws -> FetchResult {
var request: PullRequest
if let idleHeartbeat {
request = PullRequest(
batch: batch, expires: NanoTimeInterval(expires),
heartbeat: NanoTimeInterval(idleHeartbeat))
} else {
request = PullRequest(batch: batch, expires: NanoTimeInterval(expires))
}
let subject = ctx.apiSubject("CONSUMER.MSG.NEXT.\(info.stream).\(info.name)")
let inbox = ctx.client.newInbox()
let sub = try await ctx.client.subscribe(subject: inbox)
try await self.ctx.client.publish(
JSONEncoder().encode(request), subject: subject, reply: inbox)
return FetchResult(ctx: ctx, sub: sub, idleHeartbeat: idleHeartbeat, batch: batch)
}
}
/// Used to iterate over results of ``Consumer/fetch(batch:expires:idleHeartbeat:)``
public class FetchResult: AsyncSequence {
public typealias Element = JetStreamMessage
public typealias AsyncIterator = FetchIterator
private let ctx: JetStreamContext
private let sub: NatsSubscription
private let idleHeartbeat: TimeInterval?
private let batch: Int
init(ctx: JetStreamContext, sub: NatsSubscription, idleHeartbeat: TimeInterval?, batch: Int) {
self.ctx = ctx
self.sub = sub
self.idleHeartbeat = idleHeartbeat
self.batch = batch
}
public func makeAsyncIterator() -> FetchIterator {
return FetchIterator(
ctx: ctx,
sub: self.sub, idleHeartbeat: self.idleHeartbeat, remainingMessages: self.batch)
}
public struct FetchIterator: AsyncIteratorProtocol {
private let ctx: JetStreamContext
private let sub: NatsSubscription
private let idleHeartbeat: TimeInterval?
private var remainingMessages: Int
private var subIterator: NatsSubscription.AsyncIterator
init(
ctx: JetStreamContext, sub: NatsSubscription, idleHeartbeat: TimeInterval?,
remainingMessages: Int
) {
self.ctx = ctx
self.sub = sub
self.idleHeartbeat = idleHeartbeat
self.remainingMessages = remainingMessages
self.subIterator = sub.makeAsyncIterator()
}
public mutating func next() async throws -> JetStreamMessage? {
if remainingMessages <= 0 {
try await sub.unsubscribe()
return nil
}
while true {
let message: NatsMessage?
if let idleHeartbeat = idleHeartbeat {
let timeout = idleHeartbeat * 2
message = try await nextWithTimeout(timeout, subIterator)
} else {
message = try await subIterator.next()
}
guard let message else {
// the subscription has ended
try await sub.unsubscribe()
return nil
}
let status = message.status ?? .ok
switch status {
case .timeout:
try await sub.unsubscribe()
return nil
case .idleHeartbeat:
// in case of idle heartbeat error, we want to
// wait for next message on subscription
continue
case .notFound:
try await sub.unsubscribe()
return nil
case .ok:
remainingMessages -= 1
return JetStreamMessage(message: message, client: ctx.client)
case .badRequest:
try await sub.unsubscribe()
throw JetStreamError.FetchError.badRequest
case .noResponders:
try await sub.unsubscribe()
throw JetStreamError.FetchError.noResponders
case .requestTerminated:
try await sub.unsubscribe()
guard let description = message.description else {
throw JetStreamError.FetchError.invalidResponse
}
let descLower = description.lowercased()
if descLower.contains("message size exceeds maxbytes") {
return nil
} else if descLower.contains("leadership changed") {
throw JetStreamError.FetchError.leadershipChanged
} else if descLower.contains("consumer deleted") {
throw JetStreamError.FetchError.consumerDeleted
} else if descLower.contains("consumer is push based") {
throw JetStreamError.FetchError.consumerIsPush
}
default:
throw JetStreamError.FetchError.unknownStatus(status, message.description)
}
if remainingMessages == 0 {
try await sub.unsubscribe()
}
}
}
func nextWithTimeout(
_ timeout: TimeInterval, _ subIterator: NatsSubscription.AsyncIterator
) async throws -> NatsMessage? {
try await withThrowingTaskGroup(of: NatsMessage?.self) { group in
group.addTask {
return try await subIterator.next()
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
try await sub.unsubscribe()
return nil
}
defer {
group.cancelAll()
}
for try await result in group {
if let msg = result {
return msg
} else {
throw JetStreamError.FetchError.noHeartbeatReceived
}
}
// this should not be reachable
throw JetStreamError.FetchError.noHeartbeatReceived
}
}
}
}
internal struct PullRequest: Codable {
let batch: Int
let expires: NanoTimeInterval
let maxBytes: Int?
let noWait: Bool?
let heartbeat: NanoTimeInterval?
internal init(
batch: Int, expires: NanoTimeInterval, maxBytes: Int? = nil, noWait: Bool? = nil,
heartbeat: NanoTimeInterval? = nil
) {
self.batch = batch
self.expires = expires
self.maxBytes = maxBytes
self.noWait = noWait
self.heartbeat = heartbeat
}
enum CodingKeys: String, CodingKey {
case batch
case expires
case maxBytes = "max_bytes"
case noWait = "no_wait"
case heartbeat = "idle_heartbeat"
}
}

View File

@@ -0,0 +1,379 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import CryptoKit
import Foundation
import Nuid
public class Consumer {
private static var rdigits: [UInt8] = Array(
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".utf8)
/// Contains information about the consumer.
/// Note that this may be out of date and reading it does not query the server.
/// For up-to-date stream info use ``Consumer/info()``
public internal(set) var info: ConsumerInfo
internal let ctx: JetStreamContext
init(ctx: JetStreamContext, info: ConsumerInfo) {
self.ctx = ctx
self.info = info
}
/// Retrieves information about the consumer
/// This also refreshes ``Consumer/info``.
///
/// - Returns ``ConsumerInfo`` from the server.
///
/// > **Throws:**
/// > - ``JetStreamRequestError`` if the request was unsuccessful.
/// > - ``JetStreamError`` if the server responded with an API error.
public func info() async throws -> ConsumerInfo {
let subj = "CONSUMER.INFO.\(info.stream).\(info.name)"
let info: Response<ConsumerInfo> = try await ctx.request(subj)
switch info {
case .success(let info):
self.info = info
return info
case .error(let apiResponse):
throw apiResponse.error
}
}
internal static func validate(name: String) throws {
guard !name.isEmpty else {
throw JetStreamError.StreamError.nameRequired
}
let invalidChars = CharacterSet(charactersIn: ">*. /\\")
if name.rangeOfCharacter(from: invalidChars) != nil {
throw JetStreamError.StreamError.invalidStreamName(name)
}
}
internal static func generateConsumerName() -> String {
let name = nextNuid()
let hash = SHA256.hash(data: Data(name.utf8))
let hashData = Data(hash)
// Convert the first 8 bytes of the hash to the required format.
let base: UInt8 = 36
var result = [UInt8]()
for i in 0..<8 {
let index = Int(hashData[i] % base)
result.append(Consumer.rdigits[index])
}
// Convert the result array to a string and return it.
return String(bytes: result, encoding: .utf8)!
}
}
/// `ConsumerInfo` is the detailed information about a JetStream consumer.
public struct ConsumerInfo: Codable {
/// The name of the stream that the consumer is bound to.
public let stream: String
/// The unique identifier for the consumer.
public let name: String
/// The timestamp when the consumer was created.
public let created: String
/// The configuration settings of the consumer, set when creating or updating the consumer.
public let config: ConsumerConfig
/// Information about the most recently delivered message, including its sequence numbers and timestamp.
public let delivered: SequenceInfo
/// Indicates the message before the first unacknowledged message.
public let ackFloor: SequenceInfo
/// The number of messages that have been delivered but not yet acknowledged.
public let numAckPending: Int
/// The number of messages that have been redelivered and not yet acknowledged.
public let numRedelivered: Int
/// The count of active pull requests (relevant for pull-based consumers).
public let numWaiting: Int
/// The number of messages that match the consumer's filter but have not been delivered yet.
public let numPending: UInt64
/// Information about the cluster to which this consumer belongs (if applicable).
public let cluster: ClusterInfo?
/// Indicates whether at least one subscription exists for the delivery subject of this consumer (only for push-based consumers).
public let pushBound: Bool?
/// The timestamp indicating when this information was gathered by the server.
public let timeStamp: String
enum CodingKeys: String, CodingKey {
case stream = "stream_name"
case name
case created
case config
case delivered
case ackFloor = "ack_floor"
case numAckPending = "num_ack_pending"
case numRedelivered = "num_redelivered"
case numWaiting = "num_waiting"
case numPending = "num_pending"
case cluster
case pushBound = "push_bound"
case timeStamp = "ts"
}
}
/// `ConsumerConfig` is the configuration of a JetStream consumer.
public struct ConsumerConfig: Codable, Equatable {
/// Optional name for the consumer.
public var name: String?
/// Optional durable name for the consumer.
public var durable: String?
/// Optional description of the consumer.
public var description: String?
/// Defines from which point to start delivering messages from the stream.
public var deliverPolicy: DeliverPolicy
/// Optional sequence number from which to start message delivery.
public var optStartSeq: UInt64?
/// Optional time from which to start message delivery.
public var optStartTime: String?
/// Defines the acknowledgment policy for the consumer.
public var ackPolicy: AckPolicy
/// Defines how long the server will wait for an acknowledgment before resending a message.
public var ackWait: NanoTimeInterval?
/// Defines the maximum number of delivery attempts for a message.
public var maxDeliver: Int?
/// Specifies the optional back-off intervals for retrying message delivery after a failed acknowledgment.
public var backOff: [NanoTimeInterval]?
/// Can be used to filter messages delivered from the stream by given subject.
/// It is exclusive with ``ConsumerConfig/filterSubjects``
public var filterSubject: String?
/// Can be used to filter messages delivered from the stream by given subjects.
/// It is exclusive with ``ConsumerConfig/filterSubject``
public var filterSubjects: [String]?
/// Defines the rate at which messages are sent to the consumer.
public var replayPolicy: ReplayPolicy
/// Specifies an optional maximum rate of message delivery in bits per second.
public var rateLimit: UInt64?
/// Optional frequency for sampling acknowledgments for observability.
public var sampleFrequency: String?
/// Maximum number of pull requests waiting to be fulfilled.
public var maxWaiting: Int?
/// Maximum number of outstanding unacknowledged messages.
public var maxAckPending: Int?
/// Indicates whether only headers of messages should be sent (and no payload).
public var headersOnly: Bool?
/// Optional maximum batch size a single pull request can make.
public var maxRequestBatch: Int?
/// Maximum duration a single pull request will wait for messages to be available to pull.
public var maxRequestExpires: NanoTimeInterval?
/// Optional maximum total bytes that can be requested in a given batch.
public var maxRequestMaxBytes: Int?
/// Duration which instructs the server to clean up the consumer if it has been inactive for the specified duration.
public var inactiveThreshold: NanoTimeInterval?
/// Number of replicas for the consumer's state.
public var replicas: Int
/// Flag to force the consumer to use memory storage rather than inherit the storage type from the stream.
public var memoryStorage: Bool?
/// A set of application-defined key-value pairs for associating metadata on the consumer.
public var metadata: [String: String]?
public init(
name: String? = nil,
durable: String? = nil,
description: String? = nil,
deliverPolicy: DeliverPolicy = .all,
optStartSeq: UInt64? = nil,
optStartTime: String? = nil,
ackPolicy: AckPolicy = .explicit,
ackWait: NanoTimeInterval? = nil,
maxDeliver: Int? = nil,
backOff: [NanoTimeInterval]? = nil,
filterSubject: String? = nil,
filterSubjects: [String]? = nil,
replayPolicy: ReplayPolicy = .instant,
rateLimit: UInt64? = nil,
sampleFrequency: String? = nil,
maxWaiting: Int? = nil,
maxAckPending: Int? = nil,
headersOnly: Bool? = nil,
maxRequestBatch: Int? = nil,
maxRequestExpires: NanoTimeInterval? = nil,
maxRequestMaxBytes: Int? = nil,
inactiveThreshold: NanoTimeInterval? = nil,
replicas: Int = 1,
memoryStorage: Bool? = nil,
metadata: [String: String]? = nil
) {
self.name = name
self.durable = durable
self.description = description
self.deliverPolicy = deliverPolicy
self.optStartSeq = optStartSeq
self.optStartTime = optStartTime
self.ackPolicy = ackPolicy
self.ackWait = ackWait
self.maxDeliver = maxDeliver
self.backOff = backOff
self.filterSubject = filterSubject
self.replayPolicy = replayPolicy
self.rateLimit = rateLimit
self.sampleFrequency = sampleFrequency
self.maxWaiting = maxWaiting
self.maxAckPending = maxAckPending
self.headersOnly = headersOnly
self.maxRequestBatch = maxRequestBatch
self.maxRequestExpires = maxRequestExpires
self.maxRequestMaxBytes = maxRequestMaxBytes
self.inactiveThreshold = inactiveThreshold
self.replicas = replicas
self.memoryStorage = memoryStorage
self.filterSubjects = filterSubjects
self.metadata = metadata
}
enum CodingKeys: String, CodingKey {
case name
case durable = "durable_name"
case description
case deliverPolicy = "deliver_policy"
case optStartSeq = "opt_start_seq"
case optStartTime = "opt_start_time"
case ackPolicy = "ack_policy"
case ackWait = "ack_wait"
case maxDeliver = "max_deliver"
case backOff = "backoff"
case filterSubject = "filter_subject"
case replayPolicy = "replay_policy"
case rateLimit = "rate_limit_bps"
case sampleFrequency = "sample_freq"
case maxWaiting = "max_waiting"
case maxAckPending = "max_ack_pending"
case headersOnly = "headers_only"
case maxRequestBatch = "max_batch"
case maxRequestExpires = "max_expires"
case maxRequestMaxBytes = "max_bytes"
case inactiveThreshold = "inactive_threshold"
case replicas = "num_replicas"
case memoryStorage = "mem_storage"
case filterSubjects = "filter_subjects"
case metadata
}
}
/// `SequenceInfo` has both the consumer and the stream sequence and last activity.
public struct SequenceInfo: Codable, Equatable {
/// Consumer sequence number.
public let consumer: UInt64
/// Stream sequence number.
public let stream: UInt64
/// Last activity timestamp.
public let last: String?
enum CodingKeys: String, CodingKey {
case consumer = "consumer_seq"
case stream = "stream_seq"
case last = "last_active"
}
}
/// `DeliverPolicy` determines from which point to start delivering messages.
public enum DeliverPolicy: String, Codable {
/// DeliverAllPolicy starts delivering messages from the very beginning of stream. This is the default.
case all
/// DeliverLastPolicy will start the consumer with the last received.
case last
/// DeliverNewPolicy will only deliver new messages that are sent after consumer is created.
case new
/// DeliverByStartSequencePolicy will deliver messages starting from a sequence configured with OptStartSeq in ConsumerConfig.
case byStartSequence = "by_start_sequence"
/// DeliverByStartTimePolicy will deliver messages starting from a given configured with OptStartTime in ConsumerConfig.
case byStartTime = "by_start_time"
/// DeliverLastPerSubjectPolicy will start the consumer with the last for all subjects received.
case lastPerSubject = "last_per_subject"
}
/// `AckPolicy` determines how the consumer should acknowledge delivered messages.
public enum AckPolicy: String, Codable {
/// AckNonePolicy requires no acks for delivered messages./
case none
/// AckAllPolicy when acking a sequence number, this implicitly acks sequences below this one as well.
case all
/// AckExplicitPolicy requires ack or nack for all messages.
case explicit
}
/// `ReplayPolicy` determines how the consumer should replay messages it already has queued in the stream.
public enum ReplayPolicy: String, Codable {
/// ReplayInstantPolicy will replay messages as fast as possible./
case instant
/// ReplayOriginalPolicy will maintain the same timing as the messages received.
case original
}
internal struct CreateConsumerRequest: Codable {
internal let stream: String
internal let config: ConsumerConfig
internal let action: String?
enum CodingKeys: String, CodingKey {
case stream = "stream_name"
case config
case action
}
}
struct ConsumerDeleteResponse: Codable {
let success: Bool
}

View File

@@ -0,0 +1,322 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
extension JetStreamContext {
/// Creates a consumer with the specified configuration.
///
/// - Parameters:
/// - stream: name of the stream the consumer will be created on
/// - cfg: consumer config
///
/// - Returns: ``Consumer`` object containing ``ConsumerConfig`` and exposing operations on the consumer
///
/// > **Throws:**
/// > - ``JetStreamError/ConsumerError``: if there was am error creating the consumer. There are several errors which may occur, most common being:
/// > - ``JetStreamError/ConsumerError/invalidConfig(_:)``: if the provided configuration is not valid.
/// > - ``JetStreamError/ConsumerError/consumerNameExist(_:)``: if attempting to overwrite an existing consumer (with different configuration)
/// > - ``JetStreamError/ConsumerError/maximumConsumersLimit(_:)``: if a max number of consumers (specified on stream/account level) has been reached.
/// > - ``JetStreamError/RequestError``: if the request fails if e.g. JetStream is not enabled.
/// > - ``JetStreamError/APIError``: if there was a different API error returned from JetStream.
public func createConsumer(stream: String, cfg: ConsumerConfig) async throws -> Consumer {
try Stream.validate(name: stream)
return try await upsertConsumer(stream: stream, cfg: cfg, action: "create")
}
/// Updates an existing consumer using specified config.
///
/// - Parameters:
/// - stream: name of the stream the consumer will be updated on
/// - cfg: consumer config
///
/// - Returns: ``Consumer`` object containing ``ConsumerConfig`` and exposing operations on the consumer
///
/// > **Throws:**
/// > - ``JetStreamError/ConsumerError``: if there was am error updating the consumer. There are several errors which may occur, most common being:
/// > - ``JetStreamError/ConsumerError/invalidConfig(_:)``: if the provided configuration is not valid or atteppting to update an illegal property
/// > - ``JetStreamError/ConsumerError/consumerDoesNotExist(_:)``: if attempting to update a non-existing consumer
/// > - ``JetStreamError/RequestError``: if the request fails if e.g. JetStream is not enabled.
/// > - ``JetStreamError/APIError``: if there was a different API error returned from JetStream.
public func updateConsumer(stream: String, cfg: ConsumerConfig) async throws -> Consumer {
try Stream.validate(name: stream)
return try await upsertConsumer(stream: stream, cfg: cfg, action: "update")
}
/// Creates a consumer with the specified configuration or updates an existing consumer.
///
/// - Parameters:
/// - stream: name of the stream the consumer will be created on
/// - cfg: consumer config
///
/// - Returns: ``Consumer`` object containing ``ConsumerConfig`` and exposing operations on the consumer
///
/// > **Throws:**
/// > - ``JetStreamError/ConsumerError``: if there was am error creating or updatig the consumer. There are several errors which may occur, most common being:
/// > - ``JetStreamError/ConsumerError/invalidConfig(_:)``: if the provided configuration is not valid or atteppting to update an illegal property
/// > - ``JetStreamError/ConsumerError/maximumConsumersLimit(_:)``: if a max number of consumers (specified on stream/account level) has been reached.
/// > - ``JetStreamError/RequestError``: if the request fails if e.g. JetStream is not enabled.
/// > - ``JetStreamError/APIError``: if there was a different API error returned from JetStream.
public func createOrUpdateConsumer(stream: String, cfg: ConsumerConfig) async throws -> Consumer
{
try Stream.validate(name: stream)
return try await upsertConsumer(stream: stream, cfg: cfg)
}
/// Retrieves a consumer with given name from a stream.
///
/// - Parameters:
/// - stream: name of the stream the consumer is retrieved from
/// - name: name of the stream
///
/// - Returns a ``Stream`` object containing ``StreamInfo`` and exposing operations on the stream or nil if stream with given name does not exist.
///
/// > **Throws:**
/// > - ``JetStreamError/ConsumerError/consumerNotFound(_:)``: if the consumer with given name does not exist on a given stream.
/// > - ``JetStreamError/RequestError``: if the request fails if e.g. JetStream is not enabled.
/// > - ``JetStreamError/APIError``: if there was a different API error returned from JetStream.
public func getConsumer(stream: String, name: String) async throws -> Consumer? {
try Stream.validate(name: stream)
try Consumer.validate(name: name)
let subj = "CONSUMER.INFO.\(stream).\(name)"
let info: Response<ConsumerInfo> = try await request(subj)
switch info {
case .success(let info):
return Consumer(ctx: self, info: info)
case .error(let apiResponse):
if apiResponse.error.errorCode == .consumerNotFound {
return nil
}
if let consumerError = JetStreamError.ConsumerError(from: apiResponse.error) {
throw consumerError
}
throw apiResponse.error
}
}
/// Deletes a consumer from a stream.
///
/// - Parameters:
/// - stream: name of the stream the consumer will be created on
/// - name: consumer name
///
/// > **Throws:**
/// > - ``JetStreamError/ConsumerError/consumerNotFound(_:)``: if the consumer with given name does not exist on a given stream.
/// > - ``JetStreamError/RequestError``: if the request fails if e.g. JetStream is not enabled.
/// > - ``JetStreamError/APIError``: if there was a different API error returned from JetStream.
public func deleteConsumer(stream: String, name: String) async throws {
try Stream.validate(name: stream)
try Consumer.validate(name: name)
let subject = "CONSUMER.DELETE.\(stream).\(name)"
let resp: Response<ConsumerDeleteResponse> = try await request(subject)
switch resp {
case .success(_):
return
case .error(let apiResponse):
if let streamErr = JetStreamError.ConsumerError(from: apiResponse.error) {
throw streamErr
}
throw apiResponse.error
}
}
internal func upsertConsumer(
stream: String, cfg: ConsumerConfig, action: String? = nil
) async throws -> Consumer {
let consumerName = cfg.name ?? cfg.durable ?? Consumer.generateConsumerName()
try Consumer.validate(name: consumerName)
let createReq = CreateConsumerRequest(stream: stream, config: cfg, action: action)
let req = try! JSONEncoder().encode(createReq)
var subject: String
if let filterSubject = cfg.filterSubject {
subject = "CONSUMER.CREATE.\(stream).\(consumerName).\(filterSubject)"
} else {
subject = "CONSUMER.CREATE.\(stream).\(consumerName)"
}
let info: Response<ConsumerInfo> = try await request(subject, message: req)
switch info {
case .success(let info):
return Consumer(ctx: self, info: info)
case .error(let apiResponse):
if let consumerError = JetStreamError.ConsumerError(from: apiResponse.error) {
throw consumerError
}
throw apiResponse.error
}
}
/// Used to list consumer names.
///
/// - Parameters:
/// - stream: the name of the strem to list the consumers from.
///
/// - Returns ``Consumers`` which implements AsyncSequence allowing iteration over stream infos.
public func consumers(stream: String) async -> Consumers {
return Consumers(ctx: self, stream: stream)
}
/// Used to list consumer names.
///
/// - Parameters:
/// - stream: the name of the strem to list the consumers from.
///
/// - Returns ``ConsumerNames`` which implements AsyncSequence allowing iteration over consumer names.
public func consumerNames(stream: String) async -> ConsumerNames {
return ConsumerNames(ctx: self, stream: stream)
}
}
internal struct ConsumersPagedRequest: Codable {
let offset: Int
}
/// Used to iterate over consumer names when using ``JetStreamContext/consumerNames(stream:)``
public struct ConsumerNames: AsyncSequence {
public typealias Element = String
public typealias AsyncIterator = ConsumerNamesIterator
private let ctx: JetStreamContext
private let stream: String
private var buffer: [String]
private var offset: Int
private var total: Int?
private struct ConsumerNamesPage: Codable {
let total: Int
let consumers: [String]?
}
init(ctx: JetStreamContext, stream: String) {
self.stream = stream
self.ctx = ctx
self.buffer = []
self.offset = 0
}
public func makeAsyncIterator() -> ConsumerNamesIterator {
return ConsumerNamesIterator(seq: self)
}
public mutating func next() async throws -> Element? {
if let consumer = buffer.first {
buffer.removeFirst()
return consumer
}
if let total = self.total, self.offset >= total {
return nil
}
// poll consumers
let request = ConsumersPagedRequest(offset: offset)
let res: Response<ConsumerNamesPage> = try await ctx.request(
"CONSUMER.NAMES.\(self.stream)", message: JSONEncoder().encode(request))
switch res {
case .success(let names):
guard let consumers = names.consumers else {
return nil
}
self.offset += consumers.count
self.total = names.total
buffer.append(contentsOf: consumers)
return try await self.next()
case .error(let err):
throw err.error
}
}
public struct ConsumerNamesIterator: AsyncIteratorProtocol {
var seq: ConsumerNames
public mutating func next() async throws -> Element? {
try await seq.next()
}
}
}
/// Used to iterate over consumers when listing consumer infos using ``JetStreamContext/consumers(stream:)``
public struct Consumers: AsyncSequence {
public typealias Element = ConsumerInfo
public typealias AsyncIterator = ConsumersIterator
private let ctx: JetStreamContext
private let stream: String
private var buffer: [ConsumerInfo]
private var offset: Int
private var total: Int?
private struct ConsumersPage: Codable {
let total: Int
let consumers: [ConsumerInfo]?
}
init(ctx: JetStreamContext, stream: String) {
self.stream = stream
self.ctx = ctx
self.buffer = []
self.offset = 0
}
public func makeAsyncIterator() -> ConsumersIterator {
return ConsumersIterator(seq: self)
}
public mutating func next() async throws -> Element? {
if let consumer = buffer.first {
buffer.removeFirst()
return consumer
}
if let total = self.total, self.offset >= total {
return nil
}
// poll consumers
let request = ConsumersPagedRequest(offset: offset)
let res: Response<ConsumersPage> = try await ctx.request(
"CONSUMER.LIST.\(self.stream)", message: JSONEncoder().encode(request))
switch res {
case .success(let infos):
guard let consumers = infos.consumers else {
return nil
}
self.offset += consumers.count
self.total = infos.total
buffer.append(contentsOf: consumers)
return try await self.next()
case .error(let err):
throw err.error
}
}
public struct ConsumersIterator: AsyncIteratorProtocol {
var seq: Consumers
public mutating func next() async throws -> Element? {
try await seq.next()
}
}
}

View File

@@ -0,0 +1,291 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
/// Extension to `JetStreamContext` adding stream management functionalities.
extension JetStreamContext {
/// Creates a stream with the specified configuration.
///
/// - Parameter cfg: stream config
///
/// - Returns: ``Stream`` object containing ``StreamInfo`` and exposing operations on the stream
///
/// > **Throws:**
/// > - ``JetStreamError/StreamError``: if there was am error creating the stream. There are several errors which may occur, most common being:
/// > - ``JetStreamError/StreamError/nameRequired``: if the provided stream name is empty.
/// > - ``JetStreamError/StreamError/invalidStreamName(_:)``: if the provided stream name is not valid.
/// > - ``JetStreamError/StreamError/streamNameExist(_:)``: if a stream with provided name exists and has different configuration.
/// > - ``JetStreamError/StreamError/invalidConfig(_:)``: if the stream config is not valid.
/// > - ``JetStreamError/StreamError/maximumStreamsLimit(_:)``: if the maximum number of streams has been reached.
/// > - ``JetStreamError/RequestError``: if the request fails if e.g. JetStream is not enabled.
/// > - ``JetStreamError/APIError``: if there was a different API error returned from JetStream.
public func createStream(cfg: StreamConfig) async throws -> Stream {
try Stream.validate(name: cfg.name)
let req = try! JSONEncoder().encode(cfg)
let subj = "STREAM.CREATE.\(cfg.name)"
let info: Response<StreamInfo> = try await request(subj, message: req)
switch info {
case .success(let info):
return Stream(ctx: self, info: info)
case .error(let apiResponse):
if let streamErr = JetStreamError.StreamError(from: apiResponse.error) {
throw streamErr
}
throw apiResponse.error
}
}
/// Retrieves a stream by its name.
///
/// - Parameter name: name of the stream
///
/// - Returns a ``Stream`` object containing ``StreamInfo`` and exposing operations on the stream or nil if stream with given name does not exist.
///
/// > **Throws:**
/// > - ``JetStreamError/StreamError/nameRequired`` if the provided stream name is empty.
/// > - ``JetStreamError/StreamError/invalidStreamName(_:)`` if the provided stream name is not valid.
/// > - ``JetStreamError/RequestError`` if the request fails if e.g. JetStream is not enabled.
/// > - ``JetStreamError/APIError`` if there was a different JetStreamError returned from JetStream.
public func getStream(name: String) async throws -> Stream? {
try Stream.validate(name: name)
let subj = "STREAM.INFO.\(name)"
let info: Response<StreamInfo> = try await request(subj)
switch info {
case .success(let info):
return Stream(ctx: self, info: info)
case .error(let apiResponse):
if apiResponse.error.errorCode == .streamNotFound {
return nil
}
if let streamErr = JetStreamError.StreamError(from: apiResponse.error) {
throw streamErr
}
throw apiResponse.error
}
}
/// Updates an existing stream with new configuration.
///
/// - Parameter: cfg: stream config
///
/// - Returns: ``Stream`` object containing ``StreamInfo`` and exposing operations on the stream
///
/// > **Throws:**
/// > - ``JetStreamError/StreamError`` if there was am error updating the stream.
/// > There are several errors which may occur, most common being:
/// > - ``JetStreamError/StreamError/nameRequired`` if the provided stream name is empty.
/// > - ``JetStreamError/StreamError/invalidStreamName(_:)`` if the provided stream name is not valid.
/// > - ``JetStreamError/StreamError/streamNotFound(_:)`` if a stream with provided name exists and has different configuration.
/// > - ``JetStreamError/StreamError/invalidConfig(_:)`` if the stream config is not valid or user attempts to update non-updatable properties.
/// > - ``JetStreamError/RequestError`` if the request fails if e.g. JetStream is not enabled.
/// > - ``JetStreamError/APIError`` if there was a different API error returned from JetStream.
public func updateStream(cfg: StreamConfig) async throws -> Stream {
try Stream.validate(name: cfg.name)
let req = try! JSONEncoder().encode(cfg)
let subj = "STREAM.UPDATE.\(cfg.name)"
let info: Response<StreamInfo> = try await request(subj, message: req)
switch info {
case .success(let info):
return Stream(ctx: self, info: info)
case .error(let apiResponse):
if let streamErr = JetStreamError.StreamError(from: apiResponse.error) {
throw streamErr
}
throw apiResponse.error
}
}
/// Deletes a stream by its name.
///
/// - Parameter name: name of the stream to be deleted.
///
/// > **Throws:**
/// > - ``JetStreamError/StreamError/nameRequired`` if the provided stream name is empty.
/// > - ``JetStreamError/StreamError/invalidStreamName(_:)`` if the provided stream name is not valid.
/// > - ``JetStreamError/RequestError`` if the request fails if e.g. JetStream is not enabled.
/// > - ``JetStreamError/APIError`` if there was a different JetStreamError returned from JetStream.
public func deleteStream(name: String) async throws {
try Stream.validate(name: name)
let subj = "STREAM.DELETE.\(name)"
let info: Response<StreamDeleteResponse> = try await request(subj)
switch info {
case .success(_):
return
case .error(let apiResponse):
if let streamErr = JetStreamError.StreamError(from: apiResponse.error) {
throw streamErr
}
throw apiResponse.error
}
}
struct StreamDeleteResponse: Codable {
let success: Bool
}
/// Used to list stream infos.
///
/// - Returns ``Streams`` which implements AsyncSequence allowing iteration over streams.
///
/// - Parameter subject: if provided will be used to filter out returned streams
public func streams(subject: String? = nil) async -> Streams {
return Streams(ctx: self, subject: subject)
}
/// Used to list stream names.
///
/// - Returns ``StreamNames`` which implements AsyncSequence allowing iteration over stream names.
///
/// - Parameter subject: if provided will be used to filter out returned stream names
public func streamNames(subject: String? = nil) async -> StreamNames {
return StreamNames(ctx: self, subject: subject)
}
}
internal struct StreamsPagedRequest: Codable {
let offset: Int
let subject: String?
}
/// Used to iterate over streams when listing stream infos using ``JetStreamContext/streams(subject:)``
public struct Streams: AsyncSequence {
public typealias Element = StreamInfo
public typealias AsyncIterator = StreamsIterator
private let ctx: JetStreamContext
private let subject: String?
private var buffer: [StreamInfo]
private var offset: Int
private var total: Int?
private struct StreamsInfoPage: Codable {
let total: Int
let streams: [StreamInfo]?
}
init(ctx: JetStreamContext, subject: String?) {
self.ctx = ctx
self.subject = subject
self.buffer = []
self.offset = 0
}
public func makeAsyncIterator() -> StreamsIterator {
return StreamsIterator(seq: self)
}
public mutating func next() async throws -> Element? {
if let stream = buffer.first {
buffer.removeFirst()
return stream
}
if let total = self.total, self.offset >= total {
return nil
}
// poll streams
let request = StreamsPagedRequest(offset: offset, subject: subject)
let res: Response<StreamsInfoPage> = try await ctx.request(
"STREAM.LIST", message: JSONEncoder().encode(request))
switch res {
case .success(let infos):
guard let streams = infos.streams else {
return nil
}
self.offset += streams.count
self.total = infos.total
buffer.append(contentsOf: streams)
return try await self.next()
case .error(let err):
throw err.error
}
}
public struct StreamsIterator: AsyncIteratorProtocol {
var seq: Streams
public mutating func next() async throws -> Element? {
try await seq.next()
}
}
}
public struct StreamNames: AsyncSequence {
public typealias Element = String
public typealias AsyncIterator = StreamNamesIterator
private let ctx: JetStreamContext
private let subject: String?
private var buffer: [String]
private var offset: Int
private var total: Int?
private struct StreamNamesPage: Codable {
let total: Int
let streams: [String]?
}
init(ctx: JetStreamContext, subject: String?) {
self.ctx = ctx
self.subject = subject
self.buffer = []
self.offset = 0
}
public func makeAsyncIterator() -> StreamNamesIterator {
return StreamNamesIterator(seq: self)
}
public mutating func next() async throws -> Element? {
if let stream = buffer.first {
buffer.removeFirst()
return stream
}
if let total = self.total, self.offset >= total {
return nil
}
// poll streams
let request = StreamsPagedRequest(offset: offset, subject: subject)
let res: Response<StreamNamesPage> = try await ctx.request(
"STREAM.NAMES", message: JSONEncoder().encode(request))
switch res {
case .success(let names):
guard let streams = names.streams else {
return nil
}
self.offset += streams.count
self.total = names.total
buffer.append(contentsOf: streams)
return try await self.next()
case .error(let err):
throw err.error
}
}
public struct StreamNamesIterator: AsyncIteratorProtocol {
var seq: StreamNames
public mutating func next() async throws -> Element? {
try await seq.next()
}
}
}

View File

@@ -0,0 +1,234 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Combine
import Foundation
import Nats
import Nuid
/// A context which can perform jetstream scoped requests.
public class JetStreamContext {
internal var client: NatsClient
private var prefix: String = "$JS.API"
private var timeout: TimeInterval = 5.0
/// Creates a JetStreamContext from ``NatsClient`` with optional custom prefix and timeout.
///
/// - Parameters:
/// - client: NATS client connection.
/// - prefix: Used to comfigure a prefix for JetStream API requests.
/// - timeout: Used to configure a timeout for JetStream API operations.
public init(client: NatsClient, prefix: String = "$JS.API", timeout: TimeInterval = 5.0) {
self.client = client
self.prefix = prefix
self.timeout = timeout
}
/// Creates a JetStreamContext from ``NatsClient`` with custom domain and timeout.
///
/// - Parameters:
/// - client: NATS client connection.
/// - domain: Used to comfigure a domain for JetStream API requests.
/// - timeout: Used to configure a timeout for JetStream API operations.
public init(client: NatsClient, domain: String, timeout: TimeInterval = 5.0) {
self.client = client
self.prefix = "$JS.\(domain).API"
self.timeout = timeout
}
/// Creates a JetStreamContext from ``NatsClient``
///
/// - Parameters:
/// - client: NATS client connection.
public init(client: NatsClient) {
self.client = client
}
/// Sets a custom timeout for JetStream API requests.
public func setTimeout(_ timeout: TimeInterval) {
self.timeout = timeout
}
}
extension JetStreamContext {
/// Publishes a message on a stream subjec without waiting for acknowledgment from the server that the message has been successfully delivered.
///
/// - Parameters:
/// - subject: Subject on which the message will be published.
/// - message: NATS message payload.
/// - headers:Optional set of message headers.
///
/// - Returns: ``AckFuture`` allowing to await for the ack from the server.
public func publish(
_ subject: String, message: Data, headers: NatsHeaderMap? = nil
) async throws -> AckFuture {
// TODO(pp): add stream header options (expected seq etc)
let inbox = client.newInbox()
let sub = try await self.client.subscribe(subject: inbox)
try await self.client.publish(message, subject: subject, reply: inbox, headers: headers)
return AckFuture(sub: sub, timeout: self.timeout)
}
internal func request<T: Codable>(
_ subject: String, message: Data? = nil
) async throws -> Response<T> {
let data = message ?? Data()
do {
let response = try await self.client.request(
data, subject: apiSubject(subject), timeout: self.timeout)
let decoder = JSONDecoder()
guard let payload = response.payload else {
throw JetStreamError.RequestError.emptyResponsePayload
}
return try decoder.decode(Response<T>.self, from: payload)
} catch let err as NatsError.RequestError {
switch err {
case .noResponders:
throw JetStreamError.RequestError.noResponders
case .timeout:
throw JetStreamError.RequestError.timeout
case .permissionDenied:
throw JetStreamError.RequestError.permissionDenied(subject)
}
}
}
internal func request(_ subject: String, message: Data? = nil) async throws -> NatsMessage {
let data = message ?? Data()
do {
return try await self.client.request(
data, subject: apiSubject(subject), timeout: self.timeout)
} catch let err as NatsError.RequestError {
switch err {
case .noResponders:
throw JetStreamError.RequestError.noResponders
case .timeout:
throw JetStreamError.RequestError.timeout
case .permissionDenied:
throw JetStreamError.RequestError.permissionDenied(subject)
}
}
}
internal func apiSubject(_ subject: String) -> String {
return "\(self.prefix).\(subject)"
}
}
public struct JetStreamAPIResponse: Codable {
public let type: String
public let error: JetStreamError.APIError
}
/// Used to await for response from ``JetStreamContext/publish(_:message:headers:)``
public struct AckFuture {
let sub: NatsSubscription
let timeout: TimeInterval
/// Waits for an ACK from JetStream server.
///
/// - Returns: Acknowledgement object returned by the server.
///
/// > **Throws:**
/// > - ``JetStreamError/RequestError`` if the request timed out (client did not receive the ack in time) or
public func wait() async throws -> Ack {
let response = try await withThrowingTaskGroup(
of: NatsMessage?.self,
body: { group in
group.addTask {
return try await sub.makeAsyncIterator().next()
}
// task for the timeout
group.addTask {
try await Task.sleep(nanoseconds: UInt64(self.timeout * 1_000_000_000))
return nil
}
for try await result in group {
// if the result is not empty, return it (or throw status error)
if let msg = result {
group.cancelAll()
return msg
} else {
group.cancelAll()
try await sub.unsubscribe()
// if result is empty, time out
throw JetStreamError.RequestError.timeout
}
}
// this should not be reachable
throw NatsError.ClientError.internalError("error waiting for response")
})
if response.status == StatusCode.noResponders {
throw JetStreamError.PublishError.streamNotFound
}
let decoder = JSONDecoder()
guard let payload = response.payload else {
throw JetStreamError.RequestError.emptyResponsePayload
}
let ack = try decoder.decode(Response<Ack>.self, from: payload)
switch ack {
case .success(let ack):
return ack
case .error(let err):
if let publishErr = JetStreamError.PublishError(from: err.error) {
throw publishErr
} else {
throw err.error
}
}
}
}
public struct Ack: Codable {
public let stream: String
public let seq: UInt64
public let domain: String?
public let duplicate: Bool
// Custom CodingKeys to map JSON keys to Swift property names
enum CodingKeys: String, CodingKey {
case stream
case seq
case domain
case duplicate
}
// Custom initializer from Decoder
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Decode `stream` and `seq` as they are required
stream = try container.decode(String.self, forKey: .stream)
seq = try container.decode(UInt64.self, forKey: .seq)
// Decode `domain` as optional since it may not be present
domain = try container.decodeIfPresent(String.self, forKey: .domain)
// Decode `duplicate` and provide a default value of `false` if not present
duplicate = try container.decodeIfPresent(Bool.self, forKey: .duplicate) ?? false
}
}
/// contains info about the `JetStream` usage from the current account.
public struct AccountInfo: Codable {
public let memory: Int64
public let storage: Int64
public let streams: Int64
public let consumers: Int64
}

View File

@@ -0,0 +1,863 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
import Nats
public protocol JetStreamErrorProtocol: Error, CustomStringConvertible {}
public enum JetStreamError {
public struct APIError: Codable, Error {
public var code: UInt
public var errorCode: ErrorCode
public var description: String
enum CodingKeys: String, CodingKey {
case code = "code"
case errorCode = "err_code"
case description = "description"
}
}
public enum RequestError: JetStreamErrorProtocol {
case noResponders
case timeout
case emptyResponsePayload
case permissionDenied(String)
public var description: String {
switch self {
case .noResponders:
return "nats: no responders available for request"
case .timeout:
return "nats: request timed out"
case .emptyResponsePayload:
return "nats: empty response payload"
case .permissionDenied(let subject):
return "nats: permission denied on subject \(subject)"
}
}
}
public enum MessageMetadataError: JetStreamErrorProtocol {
case noReplyInMessage
case invalidPrefix
case invalidTokenNum
case invalidTokenValue
public var description: String {
switch self {
case .noReplyInMessage:
return "nats: did not fund reply subject in message"
case .invalidPrefix:
return "nats: invalid reply subject prefix"
case .invalidTokenNum:
return "nats: invalid token count"
case .invalidTokenValue:
return "nats: invalid token value"
}
}
}
public enum FetchError: JetStreamErrorProtocol {
case noHeartbeatReceived
case consumerDeleted
case badRequest
case noResponders
case consumerIsPush
case invalidResponse
case leadershipChanged
case unknownStatus(StatusCode, String?)
public var description: String {
switch self {
case .noHeartbeatReceived:
return "nats: no heartbeat received"
case .consumerDeleted:
return "nats: consumer deleted"
case .badRequest:
return "nats: bad request"
case .noResponders:
return "nats: no responders"
case .consumerIsPush:
return "nats: consumer is push based"
case .invalidResponse:
return "nats: no description in status response"
case .leadershipChanged:
return "nats: leadership changed"
case .unknownStatus(let status, let description):
if let description {
return "nats: unknown response status: \(status): \(description)"
} else {
return "nats: unknown response status: \(status)"
}
}
}
}
public enum AckError: JetStreamErrorProtocol {
case noReplyInMessage
public var description: String {
switch self {
case .noReplyInMessage:
return "nats: did not fund reply subject in message"
}
}
}
public enum StreamError: JetStreamErrorProtocol {
case nameRequired
case invalidStreamName(String)
case streamNotFound(APIError)
case streamNameExist(APIError)
case streamMessageExceedsMaximum(APIError)
case streamDelete(APIError)
case streamUpdate(APIError)
case streamInvalidExternalDeliverySubject(APIError)
case streamMirrorNotUpdatable(APIError)
case streamLimitsExceeded(APIError)
case invalidConfig(APIError)
case maximumStreamsLimit(APIError)
case streamSealed(APIError)
public var description: String {
switch self {
case .nameRequired:
return "nats: stream name is required"
case .invalidStreamName(let name):
return "nats: invalid stream name: \(name)"
case .streamNotFound(let err),
.streamNameExist(let err),
.streamMessageExceedsMaximum(let err),
.streamDelete(let err),
.streamUpdate(let err),
.streamInvalidExternalDeliverySubject(let err),
.streamMirrorNotUpdatable(let err),
.streamLimitsExceeded(let err),
.invalidConfig(let err),
.maximumStreamsLimit(let err),
.streamSealed(let err):
return "nats: \(err.description)"
}
}
internal init?(from err: APIError) {
switch err.errorCode {
case ErrorCode.streamNotFound:
self = .streamNotFound(err)
case ErrorCode.streamNameExist:
self = .streamNameExist(err)
case ErrorCode.streamMessageExceedsMaximum:
self = .streamMessageExceedsMaximum(err)
case ErrorCode.streamDelete:
self = .streamDelete(err)
case ErrorCode.streamUpdate:
self = .streamUpdate(err)
case ErrorCode.streamInvalidExternalDeliverySubject:
self = .streamInvalidExternalDeliverySubject(err)
case ErrorCode.streamMirrorNotUpdatable:
self = .streamMirrorNotUpdatable(err)
case ErrorCode.streamLimits:
self = .streamLimitsExceeded(err)
case ErrorCode.mirrorWithSources,
ErrorCode.streamSubjectOverlap,
ErrorCode.streamExternalDeletePrefixOverlaps,
ErrorCode.mirrorMaxMessageSizeTooBig,
ErrorCode.sourceMaxMessageSizeTooBig,
ErrorCode.streamInvalidConfig,
ErrorCode.mirrorWithSubjects,
ErrorCode.streamExternalApiOverlap,
ErrorCode.mirrorWithStartSequenceAndTime,
ErrorCode.mirrorWithSubjectFilters,
ErrorCode.streamReplicasNotSupported,
ErrorCode.streamReplicasNotUpdatable,
ErrorCode.streamMaxBytesRequired,
ErrorCode.streamMaxStreamBytesExceeded,
ErrorCode.streamNameContainsPathSeparators,
ErrorCode.replicasCountCannotBeNegative,
ErrorCode.sourceDuplicateDetected,
ErrorCode.sourceInvalidStreamName,
ErrorCode.mirrorInvalidStreamName,
ErrorCode.sourceMultipleFiltersNotAllowed,
ErrorCode.sourceInvalidSubjectFilter,
ErrorCode.sourceInvalidTransformDestination,
ErrorCode.sourceOverlappingSubjectFilters,
ErrorCode.streamExternalDeletePrefixOverlaps:
self = .invalidConfig(err)
case ErrorCode.maximumStreamsLimit:
self = .maximumStreamsLimit(err)
case ErrorCode.streamSealed:
self = .streamSealed(err)
default:
return nil
}
}
}
public enum PublishError: JetStreamErrorProtocol {
case streamWrongLastSequence(APIError)
case streamWrongLastMessageId(APIError)
case streamNotMatch(APIError)
case streamNotFound
public var description: String {
switch self {
case .streamWrongLastSequence(let err),
.streamWrongLastMessageId(let err),
.streamNotMatch(let err):
return "nats: \(err.description)"
case .streamNotFound:
return "nats: stream not found"
}
}
internal init?(from err: APIError) {
switch err.errorCode {
case ErrorCode.streamWrongLastSequence:
self = .streamWrongLastSequence(err)
case ErrorCode.streamWrongLastMessageId:
self = .streamWrongLastMessageId(err)
case ErrorCode.streamNotMatch:
self = .streamNotMatch(err)
default:
return nil
}
}
}
public enum ConsumerError: JetStreamErrorProtocol {
case consumerNotFound(APIError)
case maximumConsumersLimit(APIError)
case consumerNameExist(APIError)
case consumerDoesNotExist(APIError)
case invalidConfig(APIError)
public var description: String {
switch self {
case .consumerNotFound(let err),
.maximumConsumersLimit(let err),
.consumerNameExist(let err),
.consumerDoesNotExist(let err),
.invalidConfig(let err):
return "nats: \(err.description)"
}
}
internal init?(from err: APIError) {
switch err.errorCode {
case ErrorCode.consumerNotFound:
self = .consumerNotFound(err)
case ErrorCode.maximumConsumersLimit:
self = .maximumConsumersLimit(err)
case ErrorCode.consumerNameExist,
ErrorCode.consumerExistingActive,
ErrorCode.consumerAlreadyExists:
self = .consumerNameExist(err)
case ErrorCode.consumerDoesNotExist:
self = .consumerDoesNotExist(err)
case ErrorCode.consumerDeliverToWildcards,
ErrorCode.consumerPushMaxWaiting,
ErrorCode.consumerDeliverCycle,
ErrorCode.consumerMaxPendingAckPolicyRequired,
ErrorCode.consumerSmallHeartbeat,
ErrorCode.consumerPullRequiresAck,
ErrorCode.consumerPullNotDurable,
ErrorCode.consumerPullWithRateLimit,
ErrorCode.consumerPullNotDurable,
ErrorCode.consumerMaxWaitingNegative,
ErrorCode.consumerHeartbeatRequiresPush,
ErrorCode.consumerFlowControlRequiresPush,
ErrorCode.consumerDirectRequiresPush,
ErrorCode.consumerDirectRequiresEphemeral,
ErrorCode.consumerOnMapped,
ErrorCode.consumerFilterNotSubset,
ErrorCode.consumerInvalidPolicy,
ErrorCode.consumerInvalidSampling,
ErrorCode.consumerWithFlowControlNeedsHeartbeats,
ErrorCode.consumerWqRequiresExplicitAck,
ErrorCode.consumerWqMultipleUnfiltered,
ErrorCode.consumerWqConsumerNotUnique,
ErrorCode.consumerWqConsumerNotDeliverAll,
ErrorCode.consumerNameTooLong,
ErrorCode.consumerBadDurableName,
ErrorCode.consumerDescriptionTooLong,
ErrorCode.consumerInvalidDeliverSubject,
ErrorCode.consumerMaxRequestBatchNegative,
ErrorCode.consumerMaxRequestExpiresToSmall,
ErrorCode.consumerMaxDeliverBackoff,
ErrorCode.consumerMaxPendingAckExcess,
ErrorCode.consumerMaxRequestBatchExceeded,
ErrorCode.consumerReplicasExceedsStream,
ErrorCode.consumerNameContainsPathSeparators,
ErrorCode.consumerCreateFilterSubjectMismatch,
ErrorCode.consumerCreateDurableAndNameMismatch,
ErrorCode.replicasCountCannotBeNegative,
ErrorCode.consumerReplicasShouldMatchStream,
ErrorCode.consumerMetadataLength,
ErrorCode.consumerDuplicateFilterSubjects,
ErrorCode.consumerMultipleFiltersNotAllowed,
ErrorCode.consumerOverlappingSubjectFilters,
ErrorCode.consumerEmptyFilter,
ErrorCode.mirrorMultipleFiltersNotAllowed,
ErrorCode.mirrorInvalidSubjectFilter,
ErrorCode.mirrorOverlappingSubjectFilters,
ErrorCode.consumerInactiveThresholdExcess:
self = .invalidConfig(err)
default:
return nil
}
}
}
public enum StreamMessageError: JetStreamErrorProtocol {
case deleteSequenceNotFound(APIError)
public var description: String {
switch self {
case .deleteSequenceNotFound(let err):
return "nats: \(err.description)"
}
}
internal init?(from err: APIError) {
switch err.errorCode {
case ErrorCode.sequenceNotFound:
self = .deleteSequenceNotFound(err)
default:
return nil
}
}
}
public enum DirectGetError: JetStreamErrorProtocol {
case invalidResponse(String)
case errorResponse(StatusCode, String?)
public var description: String {
switch self {
case .invalidResponse(let cause):
return "invalid response: \(cause)"
case .errorResponse(let code, let description):
if let description {
return "unable to get message: \(code) \(description)"
} else {
return "unable to get message: \(code)"
}
}
}
}
}
public struct ErrorCode: Codable, Equatable {
public let rawValue: UInt64
/// Peer not a member
public static let clusterPeerNotMember = ErrorCode(rawValue: 10040)
/// Consumer expected to be ephemeral but detected a durable name set in subject
public static let consumerEphemeralWithDurable = ErrorCode(rawValue: 10019)
/// Stream external delivery prefix overlaps with stream subject
public static let streamExternalDeletePrefixOverlaps = ErrorCode(rawValue: 10022)
/// Resource limits exceeded for account
public static let accountResourcesExceeded = ErrorCode(rawValue: 10002)
/// Jetstream system temporarily unavailable
public static let clusterNotAvailable = ErrorCode(rawValue: 10008)
/// Subjects overlap with an existing stream
public static let streamSubjectOverlap = ErrorCode(rawValue: 10065)
/// Wrong last sequence
public static let streamWrongLastSequence = ErrorCode(rawValue: 10071)
/// Template name in subject does not match request
public static let nameNotMatchSubject = ErrorCode(rawValue: 10073)
/// No suitable peers for placement
public static let clusterNoPeers = ErrorCode(rawValue: 10005)
/// Consumer expected to be ephemeral but a durable name was set in request
public static let consumerEphemeralWithDurableName = ErrorCode(rawValue: 10020)
/// Insufficient resources
public static let insufficientResources = ErrorCode(rawValue: 10023)
/// Stream mirror must have max message size >= source
public static let mirrorMaxMessageSizeTooBig = ErrorCode(rawValue: 10030)
/// Generic error from stream deletion operation
public static let streamTemplateDeleteFailed = ErrorCode(rawValue: 10067)
/// Bad request
public static let badRequest = ErrorCode(rawValue: 10003)
/// Not currently supported in clustered mode
public static let notSupportedInClusterMode = ErrorCode(rawValue: 10036)
/// Consumer not found
public static let consumerNotFound = ErrorCode(rawValue: 10014)
/// Stream source must have max message size >= target
public static let sourceMaxMessageSizeTooBig = ErrorCode(rawValue: 10046)
/// Generic error when stream operation fails.
public static let streamAssignment = ErrorCode(rawValue: 10048)
/// Message size exceeds maximum allowed
public static let streamMessageExceedsMaximum = ErrorCode(rawValue: 10054)
/// Generic error for stream creation error with a string
public static let streamCreateTemplate = ErrorCode(rawValue: 10066)
/// Invalid JSON
public static let invalidJson = ErrorCode(rawValue: 10025)
/// Stream external delivery prefix must not contain wildcards
public static let streamInvalidExternalDeliverySubject = ErrorCode(rawValue: 10024)
/// Restore failed
public static let streamRestore = ErrorCode(rawValue: 10062)
/// Incomplete results
public static let clusterIncomplete = ErrorCode(rawValue: 10004)
/// Account not found
public static let noAccount = ErrorCode(rawValue: 10035)
/// General RAFT error
public static let raftGeneral = ErrorCode(rawValue: 10041)
/// Jetstream unable to subscribe to restore snapshot
public static let restoreSubscribeFailed = ErrorCode(rawValue: 10042)
/// Stream deletion failed
public static let streamDelete = ErrorCode(rawValue: 10050)
/// Stream external api prefix must not overlap
public static let streamExternalApiOverlap = ErrorCode(rawValue: 10021)
/// Stream mirrors can not contain subjects
public static let mirrorWithSubjects = ErrorCode(rawValue: 10034)
/// Jetstream not enabled
public static let jetstreamNotEnabled = ErrorCode(rawValue: 10076)
/// Jetstream not enabled for account
public static let jetstreamNotEnabledForAccount = ErrorCode(rawValue: 10039)
/// Sequence not found
public static let sequenceNotFound = ErrorCode(rawValue: 10043)
/// Stream mirror configuration can not be updated
public static let streamMirrorNotUpdatable = ErrorCode(rawValue: 10055)
/// Expected stream sequence does not match
public static let streamSequenceNotMatch = ErrorCode(rawValue: 10063)
/// Wrong last msg id
public static let streamWrongLastMessageId = ErrorCode(rawValue: 10070)
/// Jetstream unable to open temp storage for restore
public static let tempStorageFailed = ErrorCode(rawValue: 10072)
/// Insufficient storage resources available
public static let storageResourcesExceeded = ErrorCode(rawValue: 10047)
/// Stream name in subject does not match request
public static let streamMismatch = ErrorCode(rawValue: 10056)
/// Expected stream does not match
public static let streamNotMatch = ErrorCode(rawValue: 10060)
/// Setting up consumer mirror failed
public static let mirrorConsumerSetupFailed = ErrorCode(rawValue: 10029)
/// Expected an empty request payload
public static let notEmptyRequest = ErrorCode(rawValue: 10038)
/// Stream name already in use with a different configuration
public static let streamNameExist = ErrorCode(rawValue: 10058)
/// Tags placement not supported for operation
public static let clusterTags = ErrorCode(rawValue: 10011)
/// Maximum consumers limit reached
public static let maximumConsumersLimit = ErrorCode(rawValue: 10026)
/// General source consumer setup failure
public static let sourceConsumerSetupFailed = ErrorCode(rawValue: 10045)
/// Consumer creation failed
public static let consumerCreate = ErrorCode(rawValue: 10012)
/// Consumer expected to be durable but no durable name set in subject
public static let consumerDurableNameNotInSubject = ErrorCode(rawValue: 10016)
/// Stream limits error
public static let streamLimits = ErrorCode(rawValue: 10053)
/// Replicas configuration can not be updated
public static let streamReplicasNotUpdatable = ErrorCode(rawValue: 10061)
/// Template not found
public static let streamTemplateNotFound = ErrorCode(rawValue: 10068)
/// Jetstream cluster not assigned to this server
public static let clusterNotAssigned = ErrorCode(rawValue: 10007)
/// Jetstream cluster can't handle request
public static let clusterNotLeader = ErrorCode(rawValue: 10009)
/// Consumer name already in use
public static let consumerNameExist = ErrorCode(rawValue: 10013)
/// Stream mirrors can't also contain other sources
public static let mirrorWithSources = ErrorCode(rawValue: 10031)
/// Stream not found
public static let streamNotFound = ErrorCode(rawValue: 10059)
/// Jetstream clustering support required
public static let clusterRequired = ErrorCode(rawValue: 10010)
/// Consumer expected to be durable but a durable name was not set
public static let consumerDurableNameNotSet = ErrorCode(rawValue: 10018)
/// Maximum number of streams reached
public static let maximumStreamsLimit = ErrorCode(rawValue: 10027)
/// Stream mirrors can not have both start seq and start time configured
public static let mirrorWithStartSequenceAndTime = ErrorCode(rawValue: 10032)
/// Stream snapshot failed
public static let streamSnapshot = ErrorCode(rawValue: 10064)
/// Stream update failed
public static let streamUpdate = ErrorCode(rawValue: 10069)
/// Jetstream not in clustered mode
public static let clusterNotActive = ErrorCode(rawValue: 10006)
/// Consumer name in subject does not match durable name in request
public static let consumerDurableNameNotMatchSubject = ErrorCode(rawValue: 10017)
/// Insufficient memory resources available
public static let memoryResourcesExceeded = ErrorCode(rawValue: 10028)
/// Stream mirrors can not contain filtered subjects
public static let mirrorWithSubjectFilters = ErrorCode(rawValue: 10033)
/// Stream create failed with a string
public static let streamCreate = ErrorCode(rawValue: 10049)
/// Server is not a member of the cluster
public static let clusterServerNotMember = ErrorCode(rawValue: 10044)
/// No message found
public static let noMessageFound = ErrorCode(rawValue: 10037)
/// Deliver subject not valid
public static let snapshotDeliverSubjectInvalid = ErrorCode(rawValue: 10015)
/// General stream failure
public static let streamGeneralError = ErrorCode(rawValue: 10051)
/// Invalid stream config
public static let streamInvalidConfig = ErrorCode(rawValue: 10052)
/// Replicas > 1 not supported in non-clustered mode
public static let streamReplicasNotSupported = ErrorCode(rawValue: 10074)
/// Stream message delete failed
public static let streamMessageDeleteFailed = ErrorCode(rawValue: 10057)
/// Peer remap failed
public static let peerRemap = ErrorCode(rawValue: 10075)
/// Stream store failed
public static let streamStoreFailed = ErrorCode(rawValue: 10077)
/// Consumer config required
public static let consumerConfigRequired = ErrorCode(rawValue: 10078)
/// Consumer deliver subject has wildcards
public static let consumerDeliverToWildcards = ErrorCode(rawValue: 10079)
/// Consumer in push mode can not set max waiting
public static let consumerPushMaxWaiting = ErrorCode(rawValue: 10080)
/// Consumer deliver subject forms a cycle
public static let consumerDeliverCycle = ErrorCode(rawValue: 10081)
/// Consumer requires ack policy for max ack pending
public static let consumerMaxPendingAckPolicyRequired = ErrorCode(rawValue: 10082)
/// Consumer idle heartbeat needs to be >= 100ms
public static let consumerSmallHeartbeat = ErrorCode(rawValue: 10083)
/// Consumer in pull mode requires ack policy
public static let consumerPullRequiresAck = ErrorCode(rawValue: 10084)
/// Consumer in pull mode requires a durable name
public static let consumerPullNotDurable = ErrorCode(rawValue: 10085)
/// Consumer in pull mode can not have rate limit set
public static let consumerPullWithRateLimit = ErrorCode(rawValue: 10086)
/// Consumer max waiting needs to be positive
public static let consumerMaxWaitingNegative = ErrorCode(rawValue: 10087)
/// Consumer idle heartbeat requires a push based consumer
public static let consumerHeartbeatRequiresPush = ErrorCode(rawValue: 10088)
/// Consumer flow control requires a push based consumer
public static let consumerFlowControlRequiresPush = ErrorCode(rawValue: 10089)
/// Consumer direct requires a push based consumer
public static let consumerDirectRequiresPush = ErrorCode(rawValue: 10090)
/// Consumer direct requires an ephemeral consumer
public static let consumerDirectRequiresEphemeral = ErrorCode(rawValue: 10091)
/// Consumer direct on a mapped consumer
public static let consumerOnMapped = ErrorCode(rawValue: 10092)
/// Consumer filter subject is not a valid subset of the interest subjects
public static let consumerFilterNotSubset = ErrorCode(rawValue: 10093)
/// Invalid consumer policy
public static let consumerInvalidPolicy = ErrorCode(rawValue: 10094)
/// Failed to parse consumer sampling configuration
public static let consumerInvalidSampling = ErrorCode(rawValue: 10095)
/// Stream not valid
public static let streamInvalid = ErrorCode(rawValue: 10096)
/// Workqueue stream requires explicit ack
public static let consumerWqRequiresExplicitAck = ErrorCode(rawValue: 10098)
/// Multiple non-filtered consumers not allowed on workqueue stream
public static let consumerWqMultipleUnfiltered = ErrorCode(rawValue: 10099)
/// Filtered consumer not unique on workqueue stream
public static let consumerWqConsumerNotUnique = ErrorCode(rawValue: 10100)
/// Consumer must be deliver all on workqueue stream
public static let consumerWqConsumerNotDeliverAll = ErrorCode(rawValue: 10101)
/// Consumer name is too long
public static let consumerNameTooLong = ErrorCode(rawValue: 10102)
/// Durable name can not contain token separators and wildcards
public static let consumerBadDurableName = ErrorCode(rawValue: 10103)
/// Error creating store for consumer
public static let consumerStoreFailed = ErrorCode(rawValue: 10104)
/// Consumer already exists and is still active
public static let consumerExistingActive = ErrorCode(rawValue: 10105)
/// Consumer replacement durable config not the same
public static let consumerReplacementWithDifferentName = ErrorCode(rawValue: 10106)
/// Consumer description is too long
public static let consumerDescriptionTooLong = ErrorCode(rawValue: 10107)
/// Header size exceeds maximum allowed of 64k
public static let streamHeaderExceedsMaximum = ErrorCode(rawValue: 10097)
/// Consumer with flow control also needs heartbeats
public static let consumerWithFlowControlNeedsHeartbeats = ErrorCode(rawValue: 10108)
/// Invalid operation on sealed stream
public static let streamSealed = ErrorCode(rawValue: 10109)
/// Stream purge failed
public static let streamPurgeFailed = ErrorCode(rawValue: 10110)
/// Stream rollup failed
public static let streamRollupFailed = ErrorCode(rawValue: 10111)
/// Invalid push consumer deliver subject
public static let consumerInvalidDeliverSubject = ErrorCode(rawValue: 10112)
/// Account requires a stream config to have max bytes set
public static let streamMaxBytesRequired = ErrorCode(rawValue: 10113)
/// Consumer max request batch needs to be > 0
public static let consumerMaxRequestBatchNegative = ErrorCode(rawValue: 10114)
/// Consumer max request expires needs to be >= 1ms
public static let consumerMaxRequestExpiresToSmall = ErrorCode(rawValue: 10115)
/// Max deliver is required to be > length of backoff values
public static let consumerMaxDeliverBackoff = ErrorCode(rawValue: 10116)
/// Subject details would exceed maximum allowed
public static let streamInfoMaxSubjects = ErrorCode(rawValue: 10117)
/// Stream is offline
public static let streamOffline = ErrorCode(rawValue: 10118)
/// Consumer is offline
public static let consumerOffline = ErrorCode(rawValue: 10119)
/// No jetstream default or applicable tiered limit present
public static let noLimits = ErrorCode(rawValue: 10120)
/// Consumer max ack pending exceeds system limit
public static let consumerMaxPendingAckExcess = ErrorCode(rawValue: 10121)
/// Stream max bytes exceeds account limit max stream bytes
public static let streamMaxStreamBytesExceeded = ErrorCode(rawValue: 10122)
/// Can not move and scale a stream in a single update
public static let streamMoveAndScale = ErrorCode(rawValue: 10123)
/// Stream move already in progress
public static let streamMoveInProgress = ErrorCode(rawValue: 10124)
/// Consumer max request batch exceeds server limit
public static let consumerMaxRequestBatchExceeded = ErrorCode(rawValue: 10125)
/// Consumer config replica count exceeds parent stream
public static let consumerReplicasExceedsStream = ErrorCode(rawValue: 10126)
/// Consumer name can not contain path separators
public static let consumerNameContainsPathSeparators = ErrorCode(rawValue: 10127)
/// Stream name can not contain path separators
public static let streamNameContainsPathSeparators = ErrorCode(rawValue: 10128)
/// Stream move not in progress
public static let streamMoveNotInProgress = ErrorCode(rawValue: 10129)
/// Stream name already in use, cannot restore
public static let streamNameExistRestoreFailed = ErrorCode(rawValue: 10130)
/// Consumer create request did not match filtered subject from create subject
public static let consumerCreateFilterSubjectMismatch = ErrorCode(rawValue: 10131)
/// Consumer durable and name have to be equal if both are provided
public static let consumerCreateDurableAndNameMismatch = ErrorCode(rawValue: 10132)
/// Replicas count cannot be negative
public static let replicasCountCannotBeNegative = ErrorCode(rawValue: 10133)
/// Consumer config replicas must match interest retention stream's replicas
public static let consumerReplicasShouldMatchStream = ErrorCode(rawValue: 10134)
/// Consumer metadata exceeds maximum size
public static let consumerMetadataLength = ErrorCode(rawValue: 10135)
/// Consumer cannot have both filter_subject and filter_subjects specified
public static let consumerDuplicateFilterSubjects = ErrorCode(rawValue: 10136)
/// Consumer with multiple subject filters cannot use subject based api
public static let consumerMultipleFiltersNotAllowed = ErrorCode(rawValue: 10137)
/// Consumer subject filters cannot overlap
public static let consumerOverlappingSubjectFilters = ErrorCode(rawValue: 10138)
/// Consumer filter in filter_subjects cannot be empty
public static let consumerEmptyFilter = ErrorCode(rawValue: 10139)
/// Duplicate source configuration detected
public static let sourceDuplicateDetected = ErrorCode(rawValue: 10140)
/// Sourced stream name is invalid
public static let sourceInvalidStreamName = ErrorCode(rawValue: 10141)
/// Mirrored stream name is invalid
public static let mirrorInvalidStreamName = ErrorCode(rawValue: 10142)
/// Source with multiple subject transforms cannot also have a single subject filter
public static let sourceMultipleFiltersNotAllowed = ErrorCode(rawValue: 10144)
/// Source subject filter is invalid
public static let sourceInvalidSubjectFilter = ErrorCode(rawValue: 10145)
/// Source transform destination is invalid
public static let sourceInvalidTransformDestination = ErrorCode(rawValue: 10146)
/// Source filters cannot overlap
public static let sourceOverlappingSubjectFilters = ErrorCode(rawValue: 10147)
/// Consumer already exists
public static let consumerAlreadyExists = ErrorCode(rawValue: 10148)
/// Consumer does not exist
public static let consumerDoesNotExist = ErrorCode(rawValue: 10149)
/// Mirror with multiple subject transforms cannot also have a single subject filter
public static let mirrorMultipleFiltersNotAllowed = ErrorCode(rawValue: 10150)
/// Mirror subject filter is invalid
public static let mirrorInvalidSubjectFilter = ErrorCode(rawValue: 10151)
/// Mirror subject filters cannot overlap
public static let mirrorOverlappingSubjectFilters = ErrorCode(rawValue: 10152)
/// Consumer inactive threshold exceeds system limit
public static let consumerInactiveThresholdExcess = ErrorCode(rawValue: 10153)
}
extension ErrorCode {
// Encoding
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(rawValue)
}
// Decoding
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let decodedValue = try container.decode(UInt64.self)
self = ErrorCode(rawValue: decodedValue)
}
}
public enum Response<T: Codable>: Codable {
case success(T)
case error(JetStreamAPIResponse)
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
// Try to decode the expected success type T first
if let successResponse = try? container.decode(T.self) {
self = .success(successResponse)
return
}
// If that fails, try to decode ErrorResponse
let errorResponse = try container.decode(JetStreamAPIResponse.self)
self = .error(errorResponse)
return
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .success(let successData):
try container.encode(successData)
case .error(let errorData):
try container.encode(errorData)
}
}
}

View File

@@ -0,0 +1,193 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
import Nats
/// Representation of NATS message in the context of JetStream.
/// It exposes message properties (payload, headers etc.) and various methods for acknowledging delivery.
/// It also allows for checking message metadata.
public struct JetStreamMessage {
private let message: NatsMessage
/// Message payload.
public var payload: Data? { message.payload }
/// Message headers.
public var headers: NatsHeaderMap? { message.headers }
/// The subject the message was published on.
public var subject: String { message.subject }
/// Reply subject used for acking a message.
public var reply: String? { message.replySubject }
internal let client: NatsClient
private let emptyPayload = "".data(using: .utf8)!
internal init(message: NatsMessage, client: NatsClient) {
self.message = message
self.client = client
}
/// Sends an acknowledgement of given kind to the server.
///
/// - Parameter ackType: the type of acknowledgement being sent (defaults to ``AckKind/ack``. For details, see ``AckKind``.
/// - Throws:
/// - ``JetStreamError/AckError`` if there was an error sending the acknowledgement.
public func ack(ackType: AckKind = .ack) async throws {
guard let subject = message.replySubject else {
throw JetStreamError.AckError.noReplyInMessage
}
try await client.publish(ackType.payload(), subject: subject)
}
/// Parses the reply subject of the message, exposing JetStream message metadata.
///
/// - Returns ``MessageMetadata``
///
/// - Throws:
/// - ``JetStreamError/MessageMetadataError`` when there is an error parsing metadata.
public func metadata() throws -> MessageMetadata {
let prefix = "$JS.ACK."
guard let subject = message.replySubject else {
throw JetStreamError.MessageMetadataError.noReplyInMessage
}
if !subject.starts(with: prefix) {
throw JetStreamError.MessageMetadataError.invalidPrefix
}
let startIndex = subject.index(subject.startIndex, offsetBy: prefix.count)
let parts = subject[startIndex...].split(separator: ".")
return try MessageMetadata(tokens: parts)
}
}
/// Represents various types of JetStream message acknowledgement.
public enum AckKind {
/// Normal acknowledgemnt
case ack
/// Negative ack, message will be redelivered (immediately or after given delay)
case nak(delay: TimeInterval? = nil)
/// Marks the message as being processed, resets ack wait timer delaying evential redelivery.
case inProgress
/// Marks the message as terminated, it will never be redelivered.
case term(reason: String? = nil)
func payload() -> Data {
switch self {
case .ack:
return "+ACK".data(using: .utf8)!
case .nak(let delay):
if let delay {
let delayStr = String(Int64(delay * 1_000_000_000))
return "-NAK {\"delay\":\(delayStr)}".data(using: .utf8)!
} else {
return "-NAK".data(using: .utf8)!
}
case .inProgress:
return "+WPI".data(using: .utf8)!
case .term(let reason):
if let reason {
return "+TERM \(reason)".data(using: .utf8)!
} else {
return "+TERM".data(using: .utf8)!
}
}
}
}
/// Metadata of a JetStream message.
public struct MessageMetadata {
/// The domain this message was received on.
public let domain: String?
/// Optional account hash, present in servers post-ADR-15.
public let accountHash: String?
/// Name of the stream the message is delivered from.
public let stream: String
/// Name of the consumer the mesasge is delivered from.
public let consumer: String
/// Number of delivery attempts of this message.
public let delivered: UInt64
/// Stream sequence associated with this message.
public let streamSequence: UInt64
/// Consumer sequence associated with this message.
public let consumerSequence: UInt64
/// The time this message was received by the server from the publisher.
public let timestamp: String
/// The number of messages known by the server to be pending to this consumer.
public let pending: UInt64
private let v1TokenCount = 7
private let v2TokenCount = 9
init(tokens: [Substring]) throws {
if tokens.count >= v2TokenCount {
self.domain = String(tokens[0])
self.accountHash = String(tokens[1])
self.stream = String(tokens[2])
self.consumer = String(tokens[3])
guard let delivered = UInt64(tokens[4]) else {
throw JetStreamError.MessageMetadataError.invalidTokenValue
}
self.delivered = delivered
guard let sseq = UInt64(tokens[5]) else {
throw JetStreamError.MessageMetadataError.invalidTokenValue
}
self.streamSequence = sseq
guard let cseq = UInt64(tokens[6]) else {
throw JetStreamError.MessageMetadataError.invalidTokenValue
}
self.consumerSequence = cseq
self.timestamp = String(tokens[7])
guard let pending = UInt64(tokens[8]) else {
throw JetStreamError.MessageMetadataError.invalidTokenValue
}
self.pending = pending
} else if tokens.count == v1TokenCount {
self.domain = nil
self.accountHash = nil
self.stream = String(tokens[0])
self.consumer = String(tokens[1])
guard let delivered = UInt64(tokens[2]) else {
throw JetStreamError.MessageMetadataError.invalidTokenValue
}
self.delivered = delivered
guard let sseq = UInt64(tokens[3]) else {
throw JetStreamError.MessageMetadataError.invalidTokenValue
}
self.streamSequence = sseq
guard let cseq = UInt64(tokens[4]) else {
throw JetStreamError.MessageMetadataError.invalidTokenValue
}
self.consumerSequence = cseq
self.timestamp = String(tokens[5])
guard let pending = UInt64(tokens[6]) else {
throw JetStreamError.MessageMetadataError.invalidTokenValue
}
self.pending = pending
} else {
throw JetStreamError.MessageMetadataError.invalidTokenNum
}
}
}

View File

@@ -0,0 +1,39 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
/// `NanoTimeInterval` represents a time interval in nanoseconds, facilitating high precision time measurements.
public struct NanoTimeInterval: Codable, Equatable {
/// The value of the time interval in seconds.
var value: TimeInterval
public init(_ timeInterval: TimeInterval) {
self.value = timeInterval
}
/// Initializes a `NanoTimeInterval` from a decoder, assuming the encoded value is in nanoseconds.
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let nanoseconds = try container.decode(Double.self)
self.value = nanoseconds / 1_000_000_000.0
}
/// Encodes this `NanoTimeInterval` into a given encoder, converting the time interval from seconds to nanoseconds.
/// This method allows `NanoTimeInterval` to be serialized directly into a format that stores time in nanoseconds.
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
let nanoseconds = self.value * 1_000_000_000.0
try container.encode(nanoseconds)
}
}

View File

@@ -0,0 +1,118 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
extension Stream {
/// Creates a consumer with the specified configuration.
///
/// - Parameters:
/// - cfg: consumer config
///
/// - Returns: ``Consumer`` object containing ``ConsumerConfig`` and exposing operations on the consumer
///
/// > **Throws:**
/// > - ``JetStreamError/ConsumerError``: if there was am error creating the stream. There are several errors which may occur, most common being:
/// > - ``JetStreamError/ConsumerError/invalidConfig(_:)``: if the provided configuration is not valid.
/// > - ``JetStreamError/ConsumerError/consumerNameExist(_:)``: if attempting to overwrite an existing consumer (with different configuration)
/// > - ``JetStreamError/ConsumerError/maximumConsumersLimit(_:)``: if a max number of consumers (specified on stream/account level) has been reached.
/// > - ``JetStreamError/RequestError``: if the request fails if e.g. JetStream is not enabled.
/// > - ``JetStreamError/APIError``: if there was a different API error returned from JetStream.
public func createConsumer(cfg: ConsumerConfig) async throws -> Consumer {
return try await ctx.upsertConsumer(stream: info.config.name, cfg: cfg, action: "create")
}
/// Updates an existing consumer using specified config.
///
/// - Parameters:
/// - cfg: consumer config
///
/// - Returns: ``Consumer`` object containing ``ConsumerConfig`` and exposing operations on the consumer
///
/// > **Throws:**
/// > - ``JetStreamError/ConsumerError``: if there was am error creating the stream. There are several errors which may occur, most common being:
/// > - ``JetStreamError/ConsumerError/invalidConfig(_:)``: if the provided configuration is not valid or atteppting to update an illegal property
/// > - ``JetStreamError/ConsumerError/consumerDoesNotExist(_:)``: if attempting to update a non-existing consumer
/// > - ``JetStreamError/RequestError``: if the request fails if e.g. JetStream is not enabled.
/// > - ``JetStreamError/APIError``: if there was a different API error returned from JetStream.
public func updateConsumer(cfg: ConsumerConfig) async throws -> Consumer {
return try await ctx.upsertConsumer(stream: info.config.name, cfg: cfg, action: "update")
}
/// Creates a consumer with the specified configuration or updates an existing consumer.
///
/// - Parameters:
/// - stream: name of the stream the consumer will be created on
/// - cfg: consumer config
///
/// - Returns: ``Consumer`` object containing ``ConsumerConfig`` and exposing operations on the consumer
///
/// > **Throws:**
/// > - ``JetStreamError/ConsumerError``: if there was am error creating the stream. There are several errors which may occur, most common being:
/// > - ``JetStreamError/ConsumerError/invalidConfig(_:)``: if the provided configuration is not valid or atteppting to update an illegal property
/// > - ``JetStreamError/ConsumerError/maximumConsumersLimit(_:)``: if a max number of consumers (specified on stream/account level) has been reached.
/// > - ``JetStreamError/RequestError``: if the request fails if e.g. JetStream is not enabled.
/// > - ``JetStreamError/APIError``: if there was a different API error returned from JetStream.
public func createOrUpdateConsumer(cfg: ConsumerConfig) async throws -> Consumer {
return try await ctx.upsertConsumer(stream: info.config.name, cfg: cfg)
}
/// Retrieves a consumer with given name from a stream.
///
/// - Parameters:
/// - name: name of the stream
///
/// - Returns a ``Stream`` object containing ``StreamInfo`` and exposing operations on the stream or nil if stream with given name does not exist.
///
/// > **Throws:**
/// > - ``JetStreamError/ConsumerError/consumerNotFound(_:)``: if the consumer with given name does not exist on a given stream.
/// > - ``JetStreamError/RequestError``: if the request fails if e.g. JetStream is not enabled.
/// > - ``JetStreamError/APIError``: if there was a different API error returned from JetStream.
public func getConsumer(name: String) async throws -> Consumer? {
return try await ctx.getConsumer(stream: info.config.name, name: name)
}
/// Deletes a consumer from a stream.
///
/// - Parameters:
/// - name: consumer name
///
/// > **Throws:**
/// > - ``JetStreamError/ConsumerError/consumerNotFound(_:)``: if the consumer with given name does not exist on a given stream.
/// > - ``JetStreamError/RequestError``: if the request fails if e.g. JetStream is not enabled.
/// > - ``JetStreamError/APIError``: if there was a different API error returned from JetStream.
public func deleteConsumer(name: String) async throws {
try await ctx.deleteConsumer(stream: info.config.name, name: name)
}
/// Used to list consumer names.
///
/// - Parameters:
/// - stream: the name of the strem to list the consumers from.
///
/// - Returns ``Consumers`` which implements AsyncSequence allowing iteration over stream infos.
public func consumers() async -> Consumers {
return Consumers(ctx: ctx, stream: info.config.name)
}
/// Used to list consumer names.
///
/// - Parameters:
/// - stream: the name of the strem to list the consumers from.
///
/// - Returns ``ConsumerNames`` which implements AsyncSequence allowing iteration over consumer names.
public func consumerNames() async -> ConsumerNames {
return ConsumerNames(ctx: ctx, stream: info.config.name)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,128 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
import NIO
import NIOConcurrencyHelpers
extension BatchBuffer {
struct State {
private var buffer: ByteBuffer
private var allocator: ByteBufferAllocator
var waitingPromises: [(ClientOp, UnsafeContinuation<Void, Error>)] = []
var isWriteInProgress: Bool = false
internal init(allocator: ByteBufferAllocator, batchSize: Int = 16 * 1024) {
self.allocator = allocator
self.buffer = allocator.buffer(capacity: batchSize)
}
var readableBytes: Int {
return self.buffer.readableBytes
}
mutating func clear() {
buffer.clear()
}
mutating func getWriteBuffer() -> ByteBuffer {
var writeBuffer = allocator.buffer(capacity: buffer.readableBytes)
writeBuffer.writeBytes(buffer.readableBytesView)
buffer.clear()
return writeBuffer
}
mutating func writeMessage(_ message: ClientOp) {
self.buffer.writeClientOp(message)
}
}
}
internal class BatchBuffer {
private let batchSize: Int
private let channel: Channel
private let state: NIOLockedValueBox<State>
init(channel: Channel, batchSize: Int = 16 * 1024) {
self.batchSize = batchSize
self.channel = channel
self.state = .init(
State(allocator: channel.allocator)
)
}
func writeMessage(_ message: ClientOp) async throws {
#if SWIFT_NATS_BATCH_BUFFER_DISABLED
let b = channel.allocator.buffer(bytes: data)
try await channel.writeAndFlush(b)
#else
// Batch writes and if we have more than the batch size
// already in the buffer await until buffer is flushed
// to handle any back pressure
try await withUnsafeThrowingContinuation { continuation in
self.state.withLockedValue { state in
guard state.readableBytes < self.batchSize else {
state.waitingPromises.append((message, continuation))
return
}
state.writeMessage(message)
self.flushWhenIdle(state: &state)
continuation.resume()
}
}
#endif
}
private func flushWhenIdle(state: inout State) {
// The idea is to keep writing to the buffer while a writeAndFlush() is
// in progress, so we can batch as many messages as possible.
guard !state.isWriteInProgress else {
return
}
// We need a separate write buffer so we can free the message buffer for more
// messages to be collected.
let writeBuffer = state.getWriteBuffer()
state.isWriteInProgress = true
let writePromise = self.channel.eventLoop.makePromise(of: Void.self)
writePromise.futureResult.whenComplete { result in
self.state.withLockedValue { state in
state.isWriteInProgress = false
switch result {
case .success:
for (message, continuation) in state.waitingPromises {
state.writeMessage(message)
continuation.resume()
}
state.waitingPromises.removeAll()
case .failure(let error):
for (_, continuation) in state.waitingPromises {
continuation.resume(throwing: error)
}
state.waitingPromises.removeAll()
state.clear()
}
// Check if there are any pending flushes
if state.readableBytes > 0 {
self.flushWhenIdle(state: &state)
}
}
}
self.channel.writeAndFlush(writeBuffer, promise: writePromise)
}
}

View File

@@ -0,0 +1,32 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import NIOConcurrencyHelpers
internal class ConcurrentQueue<T> {
private var elements: [T] = []
private let lock = NIOLock()
func enqueue(_ element: T) {
lock.lock()
defer { lock.unlock() }
elements.append(element)
}
func dequeue() -> T? {
lock.lock()
defer { lock.unlock() }
guard !elements.isEmpty else { return nil }
return elements.removeFirst()
}
}

View File

@@ -0,0 +1,87 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
import NIO
extension ByteBuffer {
mutating func writeClientOp(_ op: ClientOp) {
switch op {
case .publish((let subject, let reply, let payload, let headers)):
if let payload = payload {
self.reserveCapacity(
minimumWritableBytes: payload.count + subject.utf8.count
+ NatsOperation.publish.rawValue.count + 12)
if headers != nil {
self.writeBytes(NatsOperation.hpublish.rawBytes)
} else {
self.writeBytes(NatsOperation.publish.rawBytes)
}
self.writeString(" ")
self.writeString(subject)
self.writeString(" ")
if let reply = reply {
self.writeString("\(reply) ")
}
if let headers = headers {
let headers = headers.toBytes()
let totalLen = headers.count + payload.count
let headersLen = headers.count
self.writeString("\(headersLen) \(totalLen)\r\n")
self.writeData(headers)
} else {
self.writeString("\(payload.count)\r\n")
}
self.writeData(payload)
self.writeString("\r\n")
} else {
self.reserveCapacity(
minimumWritableBytes: subject.utf8.count + NatsOperation.publish.rawValue.count
+ 12)
self.writeBytes(NatsOperation.publish.rawBytes)
self.writeString(" ")
self.writeString(subject)
if let reply = reply {
self.writeString("\(reply) ")
}
self.writeString("\r\n")
}
case .subscribe((let sid, let subject, let queue)):
if let queue {
self.writeString(
"\(NatsOperation.subscribe.rawValue) \(subject) \(queue) \(sid)\r\n")
} else {
self.writeString("\(NatsOperation.subscribe.rawValue) \(subject) \(sid)\r\n")
}
case .unsubscribe((let sid, let max)):
if let max {
self.writeString("\(NatsOperation.unsubscribe.rawValue) \(sid) \(max)\r\n")
} else {
self.writeString("\(NatsOperation.unsubscribe.rawValue) \(sid)\r\n")
}
case .connect(let info):
// This encode can't actually fail
let json = try! JSONEncoder().encode(info)
self.reserveCapacity(minimumWritableBytes: json.count + 5)
self.writeString("\(NatsOperation.connect.rawValue) ")
self.writeData(json)
self.writeString("\r\n")
case .ping:
self.writeString("\(NatsOperation.ping.rawValue)\r\n")
case .pong:
self.writeString("\(NatsOperation.pong.rawValue)\r\n")
}
}
}

View File

@@ -0,0 +1,24 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
extension Data {
/// Swift does not provide a way to encode data to base64 without padding in URL safe way.
func base64EncodedURLSafeNotPadded() -> String {
return self.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.trimmingCharacters(in: CharacterSet(charactersIn: "="))
}
}

View File

@@ -0,0 +1,184 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
extension Data {
private static let cr = UInt8(ascii: "\r")
private static let lf = UInt8(ascii: "\n")
private static let crlf = Data([cr, lf])
private static var currentNum = 0
private static var errored = false
internal static let versionLinePrefix = "NATS/1.0"
func removePrefix(_ prefix: Data) -> Data {
guard self.starts(with: prefix) else { return self }
return self.dropFirst(prefix.count)
}
func split(
separator: Data, maxSplits: Int = .max, omittingEmptySubsequences: Bool = true
)
-> [Data]
{
var chunks: [Data] = []
var start = startIndex
var end = startIndex
var splitsCount = 0
while end < count {
if splitsCount >= maxSplits {
break
}
if self[start..<end].elementsEqual(separator) {
if !omittingEmptySubsequences || start != end {
chunks.append(self[start..<end])
}
start = index(end, offsetBy: separator.count)
end = start
splitsCount += 1
continue
}
end = index(after: end)
}
if start <= endIndex {
if !omittingEmptySubsequences || start != endIndex {
chunks.append(self[start..<endIndex])
}
}
return chunks
}
func getMessageType() -> NatsOperation? {
guard self.count > 2 else { return nil }
for operation in NatsOperation.allOperations() {
if self.starts(with: operation.rawBytes) {
return operation
}
}
return nil
}
func starts(with bytes: [UInt8]) -> Bool {
guard self.count >= bytes.count else { return false }
return self.prefix(bytes.count).elementsEqual(bytes)
}
internal mutating func prepend(_ other: Data) {
self = other + self
}
internal func parseOutMessages() throws -> (ops: [ServerOp], remainder: Data?) {
var serverOps = [ServerOp]()
var startIndex = self.startIndex
var remainder: Data?
while startIndex < self.endIndex {
var nextLineStartIndex: Int
var lineData: Data
if let range = self[startIndex...].range(of: Data.crlf) {
let lineEndIndex = range.lowerBound
nextLineStartIndex =
self.index(range.upperBound, offsetBy: 0, limitedBy: self.endIndex)
?? self.endIndex
lineData = self[startIndex..<lineEndIndex]
} else {
remainder = self[startIndex..<self.endIndex]
break
}
if lineData.count == 0 {
startIndex = nextLineStartIndex
continue
}
let serverOp = try ServerOp.parse(from: lineData)
// if it's a message, get the full payload and add to returned data
if case .message(var msg) = serverOp {
if msg.length == 0 {
serverOps.append(serverOp)
} else {
var payload = Data()
let payloadEndIndex = nextLineStartIndex + msg.length
let payloadStartIndex = nextLineStartIndex
// include crlf in the expected payload leangth
if payloadEndIndex + Data.crlf.count > endIndex {
remainder = self[startIndex..<self.endIndex]
break
}
payload.append(self[payloadStartIndex..<payloadEndIndex])
msg.payload = payload
startIndex =
self.index(
payloadEndIndex, offsetBy: Data.crlf.count, limitedBy: self.endIndex)
?? self.endIndex
serverOps.append(.message(msg))
continue
}
//TODO(jrm): Add HMSG handling here too.
} else if case .hMessage(var msg) = serverOp {
if msg.length == 0 {
serverOps.append(serverOp)
} else {
let headersStartIndex = nextLineStartIndex
let headersEndIndex = nextLineStartIndex + msg.headersLength
let payloadStartIndex = headersEndIndex
let payloadEndIndex = nextLineStartIndex + msg.length
var payload: Data?
if msg.length > msg.headersLength {
payload = Data()
}
var headers = NatsHeaderMap()
// if the whole msg length (including training crlf) is longer
// than the remaining chunk, break and return the remainder
if payloadEndIndex + Data.crlf.count > endIndex {
remainder = self[startIndex..<self.endIndex]
break
}
let headersData = self[headersStartIndex..<headersEndIndex]
if let headersString = String(data: headersData, encoding: .utf8) {
headers = try NatsHeaderMap(from: headersString)
}
msg.status = headers.status
msg.description = headers.description
msg.headers = headers
if var payload = payload {
payload.append(self[payloadStartIndex..<payloadEndIndex])
msg.payload = payload
}
startIndex =
self.index(
payloadEndIndex, offsetBy: Data.crlf.count, limitedBy: self.endIndex)
?? self.endIndex
serverOps.append(.hMessage(msg))
continue
}
} else {
// otherwise, just add this server op to the result
serverOps.append(serverOp)
}
startIndex = nextLineStartIndex
}
return (serverOps, remainder)
}
}

View File

@@ -0,0 +1,24 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
import NIOPosix
extension Data {
func toString() -> String? {
if let str = String(data: self, encoding: .utf8) {
return str
}
return nil
}
}

View File

@@ -0,0 +1,38 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
extension String {
private static let charactersToTrim: CharacterSet = .whitespacesAndNewlines.union(
CharacterSet(charactersIn: "'"))
static func hash() -> String {
let uuid = String.uuid()
return uuid[0...7]
}
func trimWhitespacesAndApostrophes() -> String {
return self.trimmingCharacters(in: String.charactersToTrim)
}
static func uuid() -> String {
return UUID().uuidString.trimmingCharacters(in: .punctuationCharacters)
}
subscript(bounds: CountableClosedRange<Int>) -> String {
let start = index(startIndex, offsetBy: bounds.lowerBound)
let end = index(startIndex, offsetBy: bounds.upperBound)
return String(self[start...end])
}
}

View File

@@ -0,0 +1,182 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import NIO
import NIOHTTP1
import NIOWebSocket
// Adapted from https://github.com/vapor/websocket-kit/blob/main/Sources/WebSocketKit/HTTPUpgradeRequestHandler.swift
internal final class HTTPUpgradeRequestHandler: ChannelInboundHandler, RemovableChannelHandler {
typealias InboundIn = HTTPClientResponsePart
typealias OutboundOut = HTTPClientRequestPart
let host: String
let path: String
let query: String?
let headers: HTTPHeaders
let upgradePromise: EventLoopPromise<Void>
private var requestSent = false
init(
host: String, path: String, query: String?, headers: HTTPHeaders,
upgradePromise: EventLoopPromise<Void>
) {
self.host = host
self.path = path
self.query = query
self.headers = headers
self.upgradePromise = upgradePromise
}
func channelActive(context: ChannelHandlerContext) {
self.sendRequest(context: context)
context.fireChannelActive()
}
func handlerAdded(context: ChannelHandlerContext) {
if context.channel.isActive {
self.sendRequest(context: context)
}
}
private func sendRequest(context: ChannelHandlerContext) {
if self.requestSent {
// we might run into this handler twice, once in handlerAdded and once in channelActive.
return
}
self.requestSent = true
var headers = self.headers
headers.add(name: "Host", value: self.host)
var uri: String
if self.path.hasPrefix("/") || self.path.hasPrefix("ws://") || self.path.hasPrefix("wss://")
{
uri = self.path
} else {
uri = "/" + self.path
}
if let query = self.query {
uri += "?\(query)"
}
let requestHead = HTTPRequestHead(
version: HTTPVersion(major: 1, minor: 1),
method: .GET,
uri: uri,
headers: headers
)
context.write(self.wrapOutboundOut(.head(requestHead)), promise: nil)
let emptyBuffer = context.channel.allocator.buffer(capacity: 0)
let body = HTTPClientRequestPart.body(.byteBuffer(emptyBuffer))
context.write(self.wrapOutboundOut(body), promise: nil)
context.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil)
}
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
// `NIOHTTPClientUpgradeHandler` should consume the first response in the success case,
// any response we see here indicates a failure. Report the failure and tidy up at the end of the response.
let clientResponse = self.unwrapInboundIn(data)
switch clientResponse {
case .head(let responseHead):
self.upgradePromise.fail(
NatsError.ClientError.invalidConnection("ws error \(responseHead)"))
case .body: break
case .end:
context.close(promise: nil)
}
}
func errorCaught(context: ChannelHandlerContext, error: Error) {
self.upgradePromise.fail(error)
context.close(promise: nil)
}
}
internal final class WebSocketByteBufferCodec: ChannelDuplexHandler {
typealias InboundIn = WebSocketFrame
typealias InboundOut = ByteBuffer
typealias OutboundIn = ByteBuffer
typealias OutboundOut = WebSocketFrame
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
let frame = unwrapInboundIn(data)
switch frame.opcode {
case .binary:
context.fireChannelRead(wrapInboundOut(frame.data))
case .text:
preconditionFailure("We will never receive a text frame")
case .continuation:
preconditionFailure("We will never receive a continuation frame")
case .pong:
break
case .ping:
if frame.fin {
var frameData = frame.data
let maskingKey = frame.maskKey
if let maskingKey = maskingKey {
frameData.webSocketUnmask(maskingKey)
}
let bb = context.channel.allocator.buffer(bytes: frameData.readableBytesView)
self.send(
bb,
context: context,
opcode: .pong
)
} else {
context.close(promise: nil)
}
default:
// We ignore all other frames.
break
}
}
func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise<Void>?) {
let buffer = unwrapOutboundIn(data)
let frame = WebSocketFrame(
fin: true,
opcode: .binary,
maskKey: self.makeMaskKey(),
data: buffer
)
context.write(wrapOutboundOut(frame), promise: promise)
}
public func send(
_ data: ByteBuffer,
context: ChannelHandlerContext,
opcode: WebSocketOpcode = .binary,
fin: Bool = true,
promise: EventLoopPromise<Void>? = nil
) {
let frame = WebSocketFrame(
fin: fin,
opcode: opcode,
maskKey: self.makeMaskKey(),
data: data
)
context.writeAndFlush(wrapOutboundOut(frame), promise: promise)
}
func makeMaskKey() -> WebSocketMaskingKey? {
/// See https://github.com/apple/swift/issues/66099
var generator = SystemRandomNumberGenerator()
return WebSocketMaskingKey.random(using: &generator)
}
}

View File

@@ -0,0 +1,57 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
extension NatsClient {
/// Registers a callback for given event types.
///
/// - Parameters:
/// - events: an array of ``NatsEventKind`` for which the handler will be invoked.
/// - handler: a callback invoked upon triggering a specific event.
///
/// - Returns an ID of the registered listener which can be used to disable it.
@discardableResult
public func on(_ events: [NatsEventKind], _ handler: @escaping (NatsEvent) -> Void) -> String {
guard let connectionHandler = self.connectionHandler else {
return ""
}
return connectionHandler.addListeners(for: events, using: handler)
}
/// Registers a callback for given event type.
///
/// - Parameters:
/// - events: a ``NatsEventKind`` for which the handler will be invoked.
/// - handler: a callback invoked upon triggering a specific event.
///
/// - Returns an ID of the registered listener which can be used to disable it.
@discardableResult
public func on(_ event: NatsEventKind, _ handler: @escaping (NatsEvent) -> Void) -> String {
guard let connectionHandler = self.connectionHandler else {
return ""
}
return connectionHandler.addListeners(for: [event], using: handler)
}
/// Disables the event listener.
///
/// - Parameter id: an ID of a listener to be disabled (returned when creating it).
public func off(_ id: String) {
guard let connectionHandler = self.connectionHandler else {
return
}
connectionHandler.removeListener(id)
}
}

View File

@@ -0,0 +1,352 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Dispatch
import Foundation
import Logging
import NIO
import NIOFoundationCompat
import Nuid
public var logger = Logger(label: "Nats")
/// NatsClient connection states
public enum NatsState {
case pending
case connecting
case connected
case disconnected
case closed
case suspended
}
public struct Auth {
var user: String?
var password: String?
var token: String?
var credentialsPath: URL?
var nkeyPath: URL?
var nkey: String?
init() {
}
init(user: String, password: String) {
self.user = user
self.password = password
}
init(token: String) {
self.token = token
}
static func fromCredentials(_ credentials: URL) -> Auth {
var auth = Auth()
auth.credentialsPath = credentials
return auth
}
static func fromNkey(_ nkey: URL) -> Auth {
var auth = Auth()
auth.nkeyPath = nkey
return auth
}
static func fromNkey(_ nkey: String) -> Auth {
var auth = Auth()
auth.nkey = nkey
return auth
}
}
public class NatsClient {
public var connectedUrl: URL? {
connectionHandler?.connectedUrl
}
internal let allocator = ByteBufferAllocator()
internal var buffer: ByteBuffer
internal var connectionHandler: ConnectionHandler?
internal var inboxPrefix: String = "_INBOX."
internal init() {
self.buffer = allocator.buffer(capacity: 1024)
}
/// Returns a new inbox subject using the configured prefix and a generated NUID.
public func newInbox() -> String {
return inboxPrefix + nextNuid()
}
}
extension NatsClient {
/// Connects to a NATS server using configuration provided via ``NatsClientOptions``.
/// If ``NatsClientOptions/retryOnfailedConnect()`` is used, `connect()`
/// will not wait until the connection is established but rather return immediatelly.
///
/// > **Throws:**
/// > - ``NatsError/ConnectError/invalidConfig(_:)`` if the provided configuration is invalid
/// > - ``NatsError/ConnectError/tlsFailure(_:)`` if upgrading to TLS connection fails
/// > - ``NatsError/ConnectError/timeout`` if there was a timeout waiting to establish TCP connection
/// > - ``NatsError/ConnectError/dns(_:)`` if there was an error during dns lookup
/// > - ``NatsError/ConnectError/io`` if there was other error establishing connection
/// > - ``NatsError/ServerError/autorization(_:)`` if connection could not be established due to invalid/missing/expired auth
/// > - ``NatsError/ServerError/other(_:)`` if the server responds to client connection with a different error (e.g. max connections exceeded)
public func connect() async throws {
logger.debug("connect")
guard let connectionHandler = self.connectionHandler else {
throw NatsError.ClientError.internalError("empty connection handler")
}
// Check if already connected or in invalid state for connect()
let currentState = connectionHandler.currentState
switch currentState {
case .connected, .connecting:
throw NatsError.ClientError.alreadyConnected
case .closed:
throw NatsError.ClientError.connectionClosed
case .suspended:
throw NatsError.ClientError.invalidConnection(
"connection is suspended, use resume() instead")
case .pending, .disconnected:
// These states allow connection/reconnection
break
}
// Set state to connecting immediately to prevent concurrent connect() calls
connectionHandler.setState(.connecting)
do {
if !connectionHandler.retryOnFailedConnect {
try await connectionHandler.connect()
connectionHandler.setState(.connected)
connectionHandler.fire(.connected)
} else {
connectionHandler.handleReconnect()
}
} catch {
// Reset state on connection failure
connectionHandler.setState(.disconnected)
throw error
}
}
/// Closes a connection to NATS server.
///
/// - Throws ``NatsError/ClientError/connectionClosed`` if the conneciton is already closed.
public func close() async throws {
logger.debug("close")
guard let connectionHandler = self.connectionHandler else {
throw NatsError.ClientError.internalError("empty connection handler")
}
if case .closed = connectionHandler.currentState {
throw NatsError.ClientError.connectionClosed
}
try await connectionHandler.close()
}
/// Suspends a connection to NATS server.
/// A suspended connection does not receive messages on subscriptions.
/// It can be resumed using ``resume()`` which restores subscriptions on successful reconnect.
///
/// - Throws ``NatsError/ClientError/connectionClosed`` if the conneciton is closed.
public func suspend() async throws {
logger.debug("suspend")
guard let connectionHandler = self.connectionHandler else {
throw NatsError.ClientError.internalError("empty connection handler")
}
if case .closed = connectionHandler.currentState {
throw NatsError.ClientError.connectionClosed
}
try await connectionHandler.suspend()
}
/// Resumes a suspended connection.
/// ``resume()`` will not wait for successful reconnection but rather trigger a reconnect process and return.
/// Register ``NatsEvent`` using ``NatsClient/on()`` to wait for successful reconnection.
///
/// - Throws ``NatsError/ClientError`` if the conneciton is not in suspended state.
public func resume() async throws {
logger.debug("resume")
guard let connectionHandler = self.connectionHandler else {
throw NatsError.ClientError.internalError("empty connection handler")
}
if case .closed = connectionHandler.currentState {
throw NatsError.ClientError.connectionClosed
}
try await connectionHandler.resume()
}
/// Forces a reconnect attempt to the server.
/// This is a non-blocking operation and will start the process without waiting for it to complete.
///
/// - Throws ``NatsError/ClientError/connectionClosed`` if the conneciton is closed.
public func reconnect() async throws {
logger.debug("resume")
guard let connectionHandler = self.connectionHandler else {
throw NatsError.ClientError.internalError("empty connection handler")
}
if case .closed = connectionHandler.currentState {
throw NatsError.ClientError.connectionClosed
}
try await connectionHandler.reconnect()
}
/// Publishes a message on a given subject.
///
/// - Parameters:
/// - payload: data to be published.
/// - subject: a NATS subject on which the message will be published.
/// - reply: optional reply subject when publishing a request.
/// - headers: optional message headers.
///
/// > **Throws:**
/// > - ``NatsError/ClientError/connectionClosed`` if the conneciton is closed.
/// > - ``NatsError/ClientError/io(_:)`` if there is an error writing message to a TCP socket (e.g. bloken pipe).
public func publish(
_ payload: Data, subject: String, reply: String? = nil, headers: NatsHeaderMap? = nil
) async throws {
logger.debug("publish")
guard let connectionHandler = self.connectionHandler else {
throw NatsError.ClientError.internalError("empty connection handler")
}
if case .closed = connectionHandler.currentState {
throw NatsError.ClientError.connectionClosed
}
try await connectionHandler.write(
operation: ClientOp.publish((subject, reply, payload, headers)))
}
/// Sends a blocking request on a given subject.
///
/// - Parameters:
/// - payload: data to be published in the request.
/// - subject: a NATS subject on which the request will be published.
/// - headers: optional request headers.
/// - timeout: request timeout - defaults to 5 seconds.
///
/// - Returns a ``NatsMessage`` containing the response.
///
/// > **Throws:**
/// > - ``NatsError/ClientError/connectionClosed`` if the conneciton is closed.
/// > - ``NatsError/ClientError/io(_:)`` if there is an error writing message to a TCP socket (e.g. bloken pipe).
/// > - ``NatsError/RequestError/noResponders`` if there are no responders available for the request.
/// > - ``NatsError/RequestError/timeout`` if there was a timeout waiting for the response.
public func request(
_ payload: Data, subject: String, headers: NatsHeaderMap? = nil, timeout: TimeInterval = 5
) async throws -> NatsMessage {
logger.debug("request")
guard let connectionHandler = self.connectionHandler else {
throw NatsError.ClientError.internalError("empty connection handler")
}
if case .closed = connectionHandler.currentState {
throw NatsError.ClientError.connectionClosed
}
let inbox = newInbox()
let sub = try await connectionHandler.subscribe(inbox)
try await sub.unsubscribe(after: 1)
try await connectionHandler.write(
operation: ClientOp.publish((subject, inbox, payload, headers)))
return try await withThrowingTaskGroup(
of: NatsMessage?.self
) { group in
group.addTask {
do {
return try await sub.makeAsyncIterator().next()
} catch NatsError.SubscriptionError.permissionDenied {
throw NatsError.RequestError.permissionDenied
}
}
// task for the timeout
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
return nil
}
for try await result in group {
// if the result is not empty, return it (or throw status error)
if let msg = result {
group.cancelAll()
if let status = msg.status, status == StatusCode.noResponders {
throw NatsError.RequestError.noResponders
}
return msg
} else {
try await sub.unsubscribe()
group.cancelAll()
throw NatsError.RequestError.timeout
}
}
// this should not be reachable
throw NatsError.ClientError.internalError("error waiting for response")
}
}
/// Flushes the internal buffer ensuring that all messages are sent.
///
/// - Throws ``NatsError/ClientError/connectionClosed`` if the conneciton is closed.
public func flush() async throws {
logger.debug("flush")
guard let connectionHandler = self.connectionHandler else {
throw NatsError.ClientError.internalError("empty connection handler")
}
if case .closed = connectionHandler.currentState {
throw NatsError.ClientError.connectionClosed
}
connectionHandler.channel?.flush()
}
/// Subscribes to a subject to receive messages.
///
/// - Parameters:
/// - subject:a subject the client want's to subscribe to.
/// - queue: optional queue group name.
///
/// - Returns a ``NatsSubscription`` allowing iteration over incoming messages.
///
/// > **Throws:**
/// > - ``NatsError/ClientError/connectionClosed`` if the conneciton is closed.
/// > - ``NatsError/ClientError/io(_:)`` if there is an error sending the SUB request to the server.
/// > - ``NatsError/SubscriptionError/invalidSubject`` if the provided subject is invalid.
/// > - ``NatsError/SubscriptionError/invalidQueue`` if the provided queue group is invalid.
public func subscribe(subject: String, queue: String? = nil) async throws -> NatsSubscription {
logger.info("subscribe to subject \(subject)")
guard let connectionHandler = self.connectionHandler else {
throw NatsError.ClientError.internalError("empty connection handler")
}
if case .closed = connectionHandler.currentState {
throw NatsError.ClientError.connectionClosed
}
return try await connectionHandler.subscribe(subject, queue: queue)
}
/// Sends a PING to the server, returning the time it took for the server to respond.
///
/// - Returns rtt of the request.
///
/// > **Throws:**
/// > - ``NatsError/ClientError/connectionClosed`` if the conneciton is closed.
/// > - ``NatsError/ClientError/io(_:)`` if there is an error sending the SUB request to the server.
public func rtt() async throws -> TimeInterval {
guard let connectionHandler = self.connectionHandler else {
throw NatsError.ClientError.internalError("empty connection handler")
}
if case .closed = connectionHandler.currentState {
throw NatsError.ClientError.connectionClosed
}
let ping = RttCommand.makeFrom(channel: connectionHandler.channel)
await connectionHandler.sendPing(ping)
return try await ping.getRoundTripTime()
}
}

View File

@@ -0,0 +1,202 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Dispatch
import Foundation
import Logging
import NIO
import NIOFoundationCompat
public class NatsClientOptions {
private var urls: [URL] = []
private var pingInterval: TimeInterval = 60.0
private var reconnectWait: TimeInterval = 2.0
private var maxReconnects: Int?
private var initialReconnect = false
private var noRandomize = false
private var auth: Auth? = nil
private var withTls = false
private var tlsFirst = false
private var rootCertificate: URL? = nil
private var clientCertificate: URL? = nil
private var clientKey: URL? = nil
private var inboxPrefix: String = "_INBOX."
public init() {}
/// Sets the prefix for inbox subjects used for request/reply.
/// Defaults to "_INBOX."
public func inboxPrefix(_ prefix: String) -> NatsClientOptions {
if prefix.isEmpty {
self.inboxPrefix = "_INBOX."
return self
}
if prefix.last != "." {
self.inboxPrefix = prefix + "."
return self
}
self.inboxPrefix = prefix
return self
}
/// A list of server urls that a client can connect to.
public func urls(_ urls: [URL]) -> NatsClientOptions {
self.urls = urls
return self
}
/// A single url that the client can connect to.
public func url(_ url: URL) -> NatsClientOptions {
self.urls = [url]
return self
}
/// The interval with which the client will send pings to NATS server.
/// Defaults to 60s.
public func pingInterval(_ pingInterval: TimeInterval) -> NatsClientOptions {
self.pingInterval = pingInterval
return self
}
/// Wait time between reconnect attempts.
/// Defaults to 2s.
public func reconnectWait(_ reconnectWait: TimeInterval) -> NatsClientOptions {
self.reconnectWait = reconnectWait
return self
}
/// Maximum number of reconnect attempts after each disconnect.
/// Defaults to unlimited.
public func maxReconnects(_ maxReconnects: Int) -> NatsClientOptions {
self.maxReconnects = maxReconnects
return self
}
/// Username and password used to connect to the server.
public func usernameAndPassword(_ username: String, _ password: String) -> NatsClientOptions {
if self.auth == nil {
self.auth = Auth(user: username, password: password)
} else {
self.auth?.user = username
self.auth?.password = password
}
return self
}
/// Token used for token auth to NATS server.
public func token(_ token: String) -> NatsClientOptions {
if self.auth == nil {
self.auth = Auth(token: token)
} else {
self.auth?.token = token
}
return self
}
/// The location of a credentials file containing user JWT and Nkey seed.
public func credentialsFile(_ credentials: URL) -> NatsClientOptions {
if self.auth == nil {
self.auth = Auth.fromCredentials(credentials)
} else {
self.auth?.credentialsPath = credentials
}
return self
}
/// The location of a public nkey file.
/// This and ``NatsClientOptions/nkey(_:)`` are mutually exclusive.
public func nkeyFile(_ nkey: URL) -> NatsClientOptions {
if self.auth == nil {
self.auth = Auth.fromNkey(nkey)
} else {
self.auth?.nkeyPath = nkey
}
return self
}
/// Public nkey.
/// This and ``NatsClientOptions/nkeyFile(_:)`` are mutually exclusive.
public func nkey(_ nkey: String) -> NatsClientOptions {
if self.auth == nil {
self.auth = Auth.fromNkey(nkey)
} else {
self.auth?.nkey = nkey
}
return self
}
/// Indicates whether the client requires an SSL connection.
public func requireTls() -> NatsClientOptions {
self.withTls = true
return self
}
/// Indicates whether the client will attempt to perform a TLS handshake first, that is
/// before receiving the INFO protocol. This requires the server to also be
/// configured with such option, otherwise the connection will fail.
public func withTlsFirst() -> NatsClientOptions {
self.tlsFirst = true
return self
}
/// The location of a root CAs file.
public func rootCertificates(_ rootCertificate: URL) -> NatsClientOptions {
self.rootCertificate = rootCertificate
return self
}
/// The location of a client cert file.
public func clientCertificate(_ clientCertificate: URL, _ clientKey: URL) -> NatsClientOptions {
self.clientCertificate = clientCertificate
self.clientKey = clientKey
return self
}
/// Indicates whether the client will retain the order of URLs to connect to provided in ``NatsClientOptions/urls(_:)``
/// If not set, the client will randomize the server pool.
public func retainServersOrder() -> NatsClientOptions {
self.noRandomize = true
return self
}
/// By default, ``NatsClient/connect()`` will return an error if
/// the connection to the server cannot be established.
///
/// Setting `retryOnfailedConnect()` makes the client
/// establish the connection in the background even if the initial connect fails.
public func retryOnfailedConnect() -> NatsClientOptions {
self.initialReconnect = true
return self
}
public func build() -> NatsClient {
let client = NatsClient()
client.inboxPrefix = inboxPrefix
client.connectionHandler = ConnectionHandler(
inputBuffer: client.buffer,
urls: urls,
reconnectWait: reconnectWait,
maxReconnects: maxReconnects,
retainServersOrder: noRandomize,
pingInterval: pingInterval,
auth: auth,
requireTls: withTls,
tlsFirst: tlsFirst,
clientCertificate: clientCertificate,
clientKey: clientKey,
rootCertificate: rootCertificate,
retryOnFailedConnect: initialReconnect
)
return client
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,244 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
public protocol NatsErrorProtocol: Error, CustomStringConvertible {}
public enum NatsError {
public enum ServerError: NatsErrorProtocol, Equatable {
case staleConnection
case maxConnectionsExceeded
case authorizationViolation
case authenticationExpired
case authenticationRevoked
case authenticationTimeout
case permissionsViolation(Operation, String, String?)
case proto(String)
public var description: String {
switch self {
case .staleConnection:
return "nats: stale connection"
case .maxConnectionsExceeded:
return "nats: maximum connections exceeded"
case .authorizationViolation:
return "nats: authorization violation"
case .authenticationExpired:
return "nats: authentication expired"
case .authenticationRevoked:
return "nats: authentication revoked"
case .authenticationTimeout:
return "nats: authentication timeout"
case .permissionsViolation(let operation, let subject, let queue):
if let queue {
return
"nats: permissions violation for operation \"\(operation)\" on subject \"\(subject)\" using queue \"\(queue)\""
} else {
return
"nats: permissions violation for operation \"\(operation)\" on subject \"\(subject)\""
}
case .proto(let error):
return "nats: \(error)"
}
}
var normalizedError: String {
return description.trimWhitespacesAndApostrophes().lowercased()
}
init(_ error: String) {
let normalizedError = error.trimWhitespacesAndApostrophes().lowercased()
if normalizedError.contains("stale connection") {
self = .staleConnection
} else if normalizedError.contains("maximum connections exceeded") {
self = .maxConnectionsExceeded
} else if normalizedError.contains("authorization violation") {
self = .authorizationViolation
} else if normalizedError.contains("authentication expired") {
self = .authenticationExpired
} else if normalizedError.contains("authentication revoked") {
self = .authenticationRevoked
} else if normalizedError.contains("authentication timeout") {
self = .authenticationTimeout
} else if normalizedError.contains("permissions violation") {
if let (operation, subject, queue) = NatsError.ServerError.parsePermissions(
error: error)
{
self = .permissionsViolation(operation, subject, queue)
} else {
self = .proto(error)
}
} else {
self = .proto(error)
}
}
public enum Operation: String, Equatable {
case publish = "Publish"
case subscribe = "Subscription"
}
internal static func parsePermissions(error: String) -> (Operation, String, String?)? {
let pattern = "(Publish|Subscription) to \"(\\S+)\""
let regex = try! NSRegularExpression(pattern: pattern)
let matches = regex.matches(
in: error, options: [], range: NSRange(location: 0, length: error.utf16.count))
guard let match = matches.first else {
return nil
}
var operation: Operation?
if let operationRange = Range(match.range(at: 1), in: error) {
let operationString = String(error[operationRange])
operation = Operation(rawValue: operationString)
}
var subject: String?
if let subjectRange = Range(match.range(at: 2), in: error) {
subject = String(error[subjectRange])
}
let queuePattern = "using queue \"(\\S+)\""
let queueRegex = try! NSRegularExpression(pattern: queuePattern)
let queueMatches = queueRegex.matches(
in: error, options: [], range: NSRange(location: 0, length: error.utf16.count))
var queue: String?
if let match = queueMatches.first, let queueRange = Range(match.range(at: 1), in: error)
{
queue = String(error[queueRange])
}
if let operation, let subject {
return (operation, subject, queue)
} else {
return nil
}
}
}
public enum ProtocolError: NatsErrorProtocol, Equatable {
case invalidOperation(String)
case parserFailure(String)
public var description: String {
switch self {
case .invalidOperation(let op):
return "nats: unknown server operation: \(op)"
case .parserFailure(let cause):
return "nats: parser failure: \(cause)"
}
}
}
public enum ClientError: NatsErrorProtocol {
case internalError(String)
case maxReconnects
case connectionClosed
case io(Error)
case invalidConnection(String)
case cancelled
case alreadyConnected
public var description: String {
switch self {
case .internalError(let error):
return "nats: internal error: \(error)"
case .maxReconnects:
return "nats: max reconnects exceeded"
case .connectionClosed:
return "nats: connection is closed"
case .io(let error):
return "nats: IO error: \(error)"
case .invalidConnection(let error):
return "nats: \(error)"
case .cancelled:
return "nats: operation cancelled"
case .alreadyConnected:
return "nats: client is already connected or connecting"
}
}
}
public enum ConnectError: NatsErrorProtocol {
case invalidConfig(String)
case tlsFailure(Error)
case timeout
case dns(Error)
case io(Error)
public var description: String {
switch self {
case .invalidConfig(let error):
return "nats: invalid client configuration: \(error)"
case .tlsFailure(let error):
return "nats: TLS error: \(error)"
case .timeout:
return "nats: timed out waiting for connection"
case .dns(let error):
return "nats: DNS lookup error: \(error)"
case .io(let error):
return "nats: error establishing connection: \(error)"
}
}
}
public enum RequestError: NatsErrorProtocol, Equatable {
case noResponders
case timeout
case permissionDenied
public var description: String {
switch self {
case .noResponders:
return "nats: no responders available for request"
case .timeout:
return "nats: request timed out"
case .permissionDenied:
return "nats: permission denied"
}
}
}
public enum SubscriptionError: NatsErrorProtocol, Equatable {
case invalidSubject
case invalidQueue
case permissionDenied
case subscriptionClosed
public var description: String {
switch self {
case .invalidSubject:
return "nats: invalid subject name"
case .invalidQueue:
return "nats: invalid queue group name"
case .permissionDenied:
return "nats: permission denied"
case .subscriptionClosed:
return "nats: subscription closed"
}
}
}
public enum ParseHeaderError: NatsErrorProtocol, Equatable {
case invalidCharacter
public var description: String {
switch self {
case .invalidCharacter:
return
"nats: invalid header name (name cannot contain non-ascii alphanumeric characters other than '-')"
}
}
}
}

View File

@@ -0,0 +1,159 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
// Represents NATS header field value in Swift.
public struct NatsHeaderValue: Equatable, CustomStringConvertible {
private var inner: String
public init(_ value: String) {
self.inner = value
}
public var description: String {
return inner
}
}
// Custom header representation in Swift
public struct NatsHeaderName: Equatable, Hashable, CustomStringConvertible {
private var inner: String
public init(_ value: String) throws {
if value.contains(where: { $0 == ":" || $0.asciiValue! < 33 || $0.asciiValue! > 126 }) {
throw NatsError.ParseHeaderError.invalidCharacter
}
self.inner = value
}
public var description: String {
return inner
}
// Example of standard headers
public static let natsStream = try! NatsHeaderName("Nats-Stream")
public static let natsSequence = try! NatsHeaderName("Nats-Sequence")
public static let natsTimestamp = try! NatsHeaderName("Nats-Time-Stamp")
public static let natsSubject = try! NatsHeaderName("Nats-Subject")
// Add other standard headers as needed...
}
// Represents a NATS header map in Swift.
public struct NatsHeaderMap: Equatable {
private var inner: [NatsHeaderName: [NatsHeaderValue]]
internal var status: StatusCode? = nil
internal var description: String? = nil
public init() {
self.inner = [:]
}
public init(from headersString: String) throws {
self.inner = [:]
let headersArray = headersString.split(separator: "\r\n")
let versionLine = headersArray[0]
guard versionLine.hasPrefix(Data.versionLinePrefix) else {
throw NatsError.ProtocolError.parserFailure(
"header version line does not begin with `NATS/1.0`")
}
let versionLineSuffix =
versionLine
.dropFirst(Data.versionLinePrefix.count)
.trimmingCharacters(in: .whitespacesAndNewlines)
// handle inlines status and description
if versionLineSuffix.count > 0 {
let statusAndDesc = versionLineSuffix.split(
separator: " ", maxSplits: 1)
guard let status = StatusCode(statusAndDesc[0]) else {
throw NatsError.ProtocolError.parserFailure("could not parse status parameter")
}
self.status = status
if statusAndDesc.count > 1 {
self.description = String(statusAndDesc[1])
}
}
for header in headersArray.dropFirst() {
let headerParts = header.split(separator: ":", maxSplits: 1)
if headerParts.count == 2 {
self.append(
try NatsHeaderName(String(headerParts[0])),
NatsHeaderValue(String(headerParts[1]).trimmingCharacters(in: .whitespaces)))
} else {
logger.error("Error parsing header: \(header)")
}
}
}
var isEmpty: Bool {
return inner.isEmpty
}
public mutating func insert(_ name: NatsHeaderName, _ value: NatsHeaderValue) {
self.inner[name] = [value]
}
public mutating func append(_ name: NatsHeaderName, _ value: NatsHeaderValue) {
if inner[name] != nil {
inner[name]?.append(value)
} else {
insert(name, value)
}
}
public func get(_ name: NatsHeaderName) -> NatsHeaderValue? {
return inner[name]?.first
}
public func getAll(_ name: NatsHeaderName) -> [NatsHeaderValue] {
return inner[name] ?? []
}
//TODO(jrm): can we use unsafe methods here? Probably yes.
func toBytes() -> [UInt8] {
var bytes: [UInt8] = []
bytes.append(contentsOf: "NATS/1.0\r\n".utf8)
for (name, values) in inner {
for value in values {
bytes.append(contentsOf: name.description.utf8)
bytes.append(contentsOf: ":".utf8)
bytes.append(contentsOf: value.description.utf8)
bytes.append(contentsOf: "\r\n".utf8)
}
}
bytes.append(contentsOf: "\r\n".utf8)
return bytes
}
// Implementing the == operator to exclude status and desc internal properties
public static func == (lhs: NatsHeaderMap, rhs: NatsHeaderMap) -> Bool {
return lhs.inner == rhs.inner
}
}
extension NatsHeaderMap {
public subscript(name: NatsHeaderName) -> NatsHeaderValue? {
get {
return get(name)
}
set {
if let value = newValue {
insert(name, value)
} else {
inner[name] = nil
}
}
}
}

View File

@@ -0,0 +1,69 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
class JwtUtils {
// This regular expression is equivalent to the one used in Rust.
static let userConfigRE: NSRegularExpression = {
do {
return try NSRegularExpression(
pattern:
"\\s*(?:(?:-{3,}.*-{3,}\\r?\\n)([\\w\\-.=]+)(?:\\r?\\n-{3,}.*-{3,}\\r?\\n))",
options: [])
} catch {
fatalError("Invalid regular expression: \(error)")
}
}()
/// Parses a credentials file and returns its user JWT.
static func parseDecoratedJWT(contents: String) -> String? {
let matches = userConfigRE.matches(
in: contents, options: [], range: NSRange(contents.startIndex..., in: contents))
if let match = matches.first, let range = Range(match.range(at: 1), in: contents) {
return String(contents[range])
}
return nil
}
/// Parses a credentials file and returns its user JWT.
static func parseDecoratedJWT(contents: Data) -> Data? {
guard let contentsString = String(data: contents, encoding: .utf8) else {
return nil
}
if let match = parseDecoratedJWT(contents: contentsString) {
return match.data(using: .utf8)
}
return nil
}
/// Parses a credentials file and returns its nkey.
static func parseDecoratedNKey(contents: String) -> String? {
let matches = userConfigRE.matches(
in: contents, options: [], range: NSRange(contents.startIndex..., in: contents))
if matches.count > 1, let range = Range(matches[1].range(at: 1), in: contents) {
return String(contents[range])
}
return nil
}
/// Parses a credentials file and returns its nkey.
static func parseDecoratedNKey(contents: Data) -> Data? {
guard let contentsString = String(data: contents, encoding: .utf8) else {
return nil
}
if let match = parseDecoratedNKey(contents: contentsString) {
return match.data(using: .utf8)
}
return nil
}
}

View File

@@ -0,0 +1,60 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
public struct NatsMessage {
public let payload: Data?
public let subject: String
public let replySubject: String?
public let length: Int
public let headers: NatsHeaderMap?
public let status: StatusCode?
public let description: String?
}
public struct StatusCode: Equatable {
public static let idleHeartbeat = StatusCode(value: 100)
public static let ok = StatusCode(value: 200)
public static let badRequest = StatusCode(value: 400)
public static let notFound = StatusCode(value: 404)
public static let timeout = StatusCode(value: 408)
public static let noResponders = StatusCode(value: 503)
public static let requestTerminated = StatusCode(value: 409)
let value: UInt16
// non-optional initializer for static status codes
private init(value: UInt16) {
self.value = value
}
init?(_ value: UInt16) {
if !(100..<1000 ~= value) {
return nil
}
self.value = value
}
init?(_ value: any StringProtocol) {
guard let status = UInt16(value) else {
return nil
}
if !(100..<1000 ~= status) {
return nil
}
self.value = status
}
}

View File

@@ -0,0 +1,341 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
import NIO
internal struct NatsOperation: RawRepresentable, Hashable {
let rawValue: String
static let connect = NatsOperation(rawValue: "CONNECT")
static let subscribe = NatsOperation(rawValue: "SUB")
static let unsubscribe = NatsOperation(rawValue: "UNSUB")
static let publish = NatsOperation(rawValue: "PUB")
static let hpublish = NatsOperation(rawValue: "HPUB")
static let message = NatsOperation(rawValue: "MSG")
static let hmessage = NatsOperation(rawValue: "HMSG")
static let info = NatsOperation(rawValue: "INFO")
static let ok = NatsOperation(rawValue: "+OK")
static let error = NatsOperation(rawValue: "-ERR")
static let ping = NatsOperation(rawValue: "PING")
static let pong = NatsOperation(rawValue: "PONG")
var rawBytes: String.UTF8View {
self.rawValue.utf8
}
static func allOperations() -> [NatsOperation] {
return [
.connect, .subscribe, .unsubscribe, .publish, .message, .hmessage, .info, .ok, .error,
.ping, .pong,
]
}
}
enum ServerOp {
case ok
case info(ServerInfo)
case ping
case pong
case error(NatsError.ServerError)
case message(MessageInbound)
case hMessage(HMessageInbound)
static func parse(from msg: Data) throws -> ServerOp {
guard msg.count > 2 else {
throw NatsError.ProtocolError.parserFailure(
"unable to parse inbound message: \(String(data: msg, encoding: .utf8)!)")
}
guard let msgType = msg.getMessageType() else {
throw NatsError.ProtocolError.invalidOperation(String(data: msg, encoding: .utf8)!)
}
switch msgType {
case .message:
return try message(MessageInbound.parse(data: msg))
case .hmessage:
return try hMessage(HMessageInbound.parse(data: msg))
case .info:
return try info(ServerInfo.parse(data: msg))
case .ok:
return ok
case .error:
if let errMsg = msg.removePrefix(Data(NatsOperation.error.rawBytes)).toString() {
return error(NatsError.ServerError(errMsg))
}
return error(NatsError.ServerError("unexpected error"))
case .ping:
return ping
case .pong:
return pong
default:
throw NatsError.ProtocolError.invalidOperation(
"unknown server op: \(String(data: msg, encoding: .utf8)!)")
}
}
}
internal struct HMessageInbound: Equatable {
private static let newline = UInt8(ascii: "\n")
private static let space = UInt8(ascii: " ")
var subject: String
var sid: UInt64
var reply: String?
var payload: Data?
var headers: NatsHeaderMap
var headersLength: Int
var length: Int
var status: StatusCode?
var description: String?
// Parse the operation syntax: HMSG <subject> <sid> [reply-to]
internal static func parse(data: Data) throws -> HMessageInbound {
let protoComponents =
data
.dropFirst(NatsOperation.hmessage.rawValue.count) // Assuming msg starts with "HMSG "
.split(separator: space)
.filter { !$0.isEmpty }
let parseArgs: ((Data, Data, Data?, Data, Data) throws -> HMessageInbound) = {
subjectData, sidData, replyData, lengthHeaders, lengthData in
let subject = String(decoding: subjectData, as: UTF8.self)
guard let sid = UInt64(String(decoding: sidData, as: UTF8.self)) else {
throw NatsError.ProtocolError.parserFailure(
"unable to parse subscription ID as number")
}
var replySubject: String? = nil
if let replyData = replyData {
replySubject = String(decoding: replyData, as: UTF8.self)
}
let headersLength = Int(String(decoding: lengthHeaders, as: UTF8.self)) ?? 0
let length = Int(String(decoding: lengthData, as: UTF8.self)) ?? 0
return HMessageInbound(
subject: subject, sid: sid, reply: replySubject, payload: nil,
headers: NatsHeaderMap(),
headersLength: headersLength, length: length)
}
var msg: HMessageInbound
switch protoComponents.count {
case 4:
msg = try parseArgs(
protoComponents[0], protoComponents[1], nil, protoComponents[2],
protoComponents[3])
case 5:
msg = try parseArgs(
protoComponents[0], protoComponents[1], protoComponents[2], protoComponents[3],
protoComponents[4])
default:
throw NatsError.ProtocolError.parserFailure("unable to parse inbound message header")
}
return msg
}
}
// TODO(pp): add headers and HMSG parsing
internal struct MessageInbound: Equatable {
private static let newline = UInt8(ascii: "\n")
private static let space = UInt8(ascii: " ")
var subject: String
var sid: UInt64
var reply: String?
var payload: Data?
var length: Int
// Parse the operation syntax: MSG <subject> <sid> [reply-to]
internal static func parse(data: Data) throws -> MessageInbound {
let protoComponents =
data
.dropFirst(NatsOperation.message.rawValue.count) // Assuming msg starts with "MSG "
.split(separator: space)
.filter { !$0.isEmpty }
let parseArgs: ((Data, Data, Data?, Data) throws -> MessageInbound) = {
subjectData, sidData, replyData, lengthData in
let subject = String(decoding: subjectData, as: UTF8.self)
guard let sid = UInt64(String(decoding: sidData, as: UTF8.self)) else {
throw NatsError.ProtocolError.parserFailure(
"unable to parse subscription ID as number")
}
var replySubject: String? = nil
if let replyData = replyData {
replySubject = String(decoding: replyData, as: UTF8.self)
}
let length = Int(String(decoding: lengthData, as: UTF8.self)) ?? 0
return MessageInbound(
subject: subject, sid: sid, reply: replySubject, payload: nil, length: length)
}
var msg: MessageInbound
switch protoComponents.count {
case 3:
msg = try parseArgs(protoComponents[0], protoComponents[1], nil, protoComponents[2])
case 4:
msg = try parseArgs(
protoComponents[0], protoComponents[1], protoComponents[2], protoComponents[3])
default:
throw NatsError.ProtocolError.parserFailure("unable to parse inbound message header")
}
return msg
}
}
/// Struct representing server information in NATS.
struct ServerInfo: Codable, Equatable {
/// The unique identifier of the NATS server.
let serverId: String
/// Generated Server Name.
let serverName: String
/// The host specified in the cluster parameter/options.
let host: String
/// The port number specified in the cluster parameter/options.
let port: UInt16
/// The version of the NATS server.
let version: String
/// If this is set, then the server should try to authenticate upon connect.
let authRequired: Bool?
/// If this is set, then the server must authenticate using TLS.
let tlsRequired: Bool?
/// Maximum payload size that the server will accept.
let maxPayload: UInt
/// The protocol version in use.
let proto: Int8
/// The server-assigned client ID. This may change during reconnection.
let clientId: UInt64?
/// The version of golang the NATS server was built with.
let go: String
/// The nonce used for nkeys.
let nonce: String?
/// A list of server urls that a client can connect to.
let connectUrls: [String]?
/// The client IP as known by the server.
let clientIp: String
/// Whether the server supports headers.
let headers: Bool
/// Whether server goes into lame duck
private let _lameDuckMode: Bool?
var lameDuckMode: Bool {
return _lameDuckMode ?? false
}
private static let prefix = NatsOperation.info.rawValue.data(using: .utf8)!
private enum CodingKeys: String, CodingKey {
case serverId = "server_id"
case serverName = "server_name"
case host
case port
case version
case authRequired = "auth_required"
case tlsRequired = "tls_required"
case maxPayload = "max_payload"
case proto
case clientId = "client_id"
case go
case nonce
case connectUrls = "connect_urls"
case clientIp = "client_ip"
case headers
case _lameDuckMode = "ldm"
}
internal static func parse(data: Data) throws -> ServerInfo {
let info = data.removePrefix(prefix)
return try JSONDecoder().decode(self, from: info)
}
}
enum ClientOp {
case publish((subject: String, reply: String?, payload: Data?, headers: NatsHeaderMap?))
case subscribe((sid: UInt64, subject: String, queue: String?))
case unsubscribe((sid: UInt64, max: UInt64?))
case connect(ConnectInfo)
case ping
case pong
}
/// Info to construct a CONNECT message.
struct ConnectInfo: Encodable {
/// Turns on +OK protocol acknowledgments.
var verbose: Bool
/// Turns on additional strict format checking, e.g. for properly formed
/// subjects.
var pedantic: Bool
/// User's JWT.
var userJwt: String?
/// Public nkey.
var nkey: String
/// Signed nonce, encoded to Base64URL.
var signature: String?
/// Optional client name.
var name: String
/// If set to `true`, the server (version 1.2.0+) will not send originating
/// messages from this connection to its own subscriptions. Clients should
/// set this to `true` only for server supporting this feature, which is
/// when proto in the INFO protocol is set to at least 1.
var echo: Bool
/// The implementation language of the client.
var lang: String
/// The version of the client.
var version: String
/// Sending 0 (or absent) indicates client supports original protocol.
/// Sending 1 indicates that the client supports dynamic reconfiguration
/// of cluster topology changes by asynchronously receiving INFO messages
/// with known servers it can reconnect to.
var natsProtocol: NatsProtocol
/// Indicates whether the client requires an SSL connection.
var tlsRequired: Bool
/// Connection username (if `auth_required` is set)
var user: String
/// Connection password (if auth_required is set)
var pass: String
/// Client authorization token (if auth_required is set)
var authToken: String
/// Whether the client supports the usage of headers.
var headers: Bool
/// Whether the client supports no_responders.
var noResponders: Bool
enum CodingKeys: String, CodingKey {
case verbose
case pedantic
case userJwt = "jwt"
case nkey
case signature = "sig" // Custom key name for JSON
case name
case echo
case lang
case version
case natsProtocol = "protocol"
case tlsRequired = "tls_required"
case user
case pass
case authToken = "auth_token"
case headers
case noResponders = "no_responders"
}
}
enum NatsProtocol: Encodable {
case original
case dynamic
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .original:
try container.encode(0)
case .dynamic:
try container.encode(1)
}
}
}

View File

@@ -0,0 +1,185 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
// TODO(pp): Implement slow consumer
public class NatsSubscription: AsyncSequence {
public typealias Element = NatsMessage
public typealias AsyncIterator = SubscriptionIterator
public let subject: String
public let queue: String?
internal var max: UInt64?
internal var delivered: UInt64 = 0
internal let sid: UInt64
private var buffer: [Result<Element, NatsError.SubscriptionError>]
private let capacity: UInt64
private var closed = false
private var continuation:
CheckedContinuation<Result<Element, NatsError.SubscriptionError>?, Never>?
private let lock = NSLock()
private let conn: ConnectionHandler
private static let defaultSubCapacity: UInt64 = 512 * 1024
convenience init(sid: UInt64, subject: String, queue: String?, conn: ConnectionHandler) throws {
try self.init(
sid: sid, subject: subject, queue: queue, capacity: NatsSubscription.defaultSubCapacity,
conn: conn)
}
init(
sid: UInt64, subject: String, queue: String?, capacity: UInt64, conn: ConnectionHandler
) throws {
if !NatsSubscription.validSubject(subject) {
throw NatsError.SubscriptionError.invalidSubject
}
if let queue, !NatsSubscription.validQueue(queue) {
throw NatsError.SubscriptionError.invalidQueue
}
self.sid = sid
self.subject = subject
self.queue = queue
self.capacity = capacity
self.buffer = []
self.conn = conn
}
public func makeAsyncIterator() -> SubscriptionIterator {
return SubscriptionIterator(subscription: self)
}
func receiveMessage(_ message: NatsMessage) {
lock.withLock {
if let continuation = self.continuation {
// Immediately use the continuation if it exists
self.continuation = nil
continuation.resume(returning: .success(message))
} else if buffer.count < capacity {
// Only append to buffer if no continuation is available
// TODO(pp): Hadndle SlowConsumer as subscription event
buffer.append(.success(message))
}
}
}
func receiveError(_ error: NatsError.SubscriptionError) {
lock.withLock {
if let continuation = self.continuation {
// Immediately use the continuation if it exists
self.continuation = nil
continuation.resume(returning: .failure(error))
} else {
buffer.append(.failure(error))
}
}
}
internal func complete() {
lock.withLock {
closed = true
if let continuation {
self.continuation = nil
continuation.resume(returning: nil)
}
}
}
// AsyncIterator implementation
public class SubscriptionIterator: AsyncIteratorProtocol {
private var subscription: NatsSubscription
init(subscription: NatsSubscription) {
self.subscription = subscription
}
public func next() async throws -> Element? {
try await subscription.nextMessage()
}
}
private func nextMessage() async throws -> Element? {
let result: Result<Element, NatsError.SubscriptionError>? = await withCheckedContinuation {
continuation in
lock.withLock {
if closed {
continuation.resume(returning: nil)
return
}
delivered += 1
if let message = buffer.first {
buffer.removeFirst()
continuation.resume(returning: message)
} else {
self.continuation = continuation
}
}
}
if let max, delivered >= max {
conn.removeSub(sub: self)
}
switch result {
case .success(let msg):
return msg
case .failure(let error):
throw error
default:
return nil
}
}
/// Unsubscribes from subscription.
///
/// - Parameter after: If set, unsubscribe will be performed after reaching given number of messages.
/// If it already reached or surpassed the passed value, it will immediately stop.
///
/// > **Throws:**
/// > - ``NatsError/ClientError/connectionClosed`` if the conneciton is closed.
/// > - ``NatsError/SubscriptionError/subscriptionClosed`` if the subscription is already closed
public func unsubscribe(after: UInt64? = nil) async throws {
logger.info("unsubscribe from subject \(subject)")
if case .closed = self.conn.currentState {
throw NatsError.ClientError.connectionClosed
}
if self.closed {
throw NatsError.SubscriptionError.subscriptionClosed
}
return try await self.conn.unsubscribe(sub: self, max: after)
}
// validateSubject will do a basic subject validation.
// Spaces are not allowed and all tokens should be > 0 in length.
private static func validSubject(_ subj: String) -> Bool {
let whitespaceCharacterSet = CharacterSet.whitespacesAndNewlines
if subj.rangeOfCharacter(from: whitespaceCharacterSet) != nil {
return false
}
let tokens = subj.split(separator: ".")
for token in tokens {
if token.isEmpty {
return false
}
}
return true
}
// validQueue will check a queue name for whitespaces.
private static func validQueue(_ queue: String) -> Bool {
let whitespaceCharacterSet = CharacterSet.whitespacesAndNewlines
return queue.rangeOfCharacter(from: whitespaceCharacterSet) == nil
}
}

View File

@@ -0,0 +1,39 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
import NIOCore
internal class RttCommand {
let startTime = DispatchTime.now()
let promise: EventLoopPromise<TimeInterval>?
static func makeFrom(channel: Channel?) -> RttCommand {
RttCommand(promise: channel?.eventLoop.makePromise(of: TimeInterval.self))
}
private init(promise: EventLoopPromise<TimeInterval>?) {
self.promise = promise
}
func setRoundTripTime() {
let now = DispatchTime.now()
let nanoTime = now.uptimeNanoseconds - startTime.uptimeNanoseconds
let rtt = TimeInterval(nanoTime) / 1_000_000_000 // Convert nanos to seconds
promise?.succeed(rtt)
}
func getRoundTripTime() async throws -> TimeInterval {
try await promise?.futureResult.get() ?? 0
}
}

View File

@@ -0,0 +1,206 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
import XCTest
public class NatsServer {
public var port: Int? { return natsServerPort }
public var clientURL: String {
let scheme = tlsEnabled ? "tls://" : "nats://"
if let natsServerPort {
return "\(scheme)localhost:\(natsServerPort)"
} else {
return ""
}
}
public var clientWebsocketURL: String {
let scheme = tlsEnabled ? "wss://" : "ws://"
if let natsWebsocketPort {
return "\(scheme)localhost:\(natsWebsocketPort)"
} else {
return ""
}
}
private var process: Process?
private var natsServerPort: Int?
private var natsWebsocketPort: Int?
private var tlsEnabled = false
private var pidFile: URL?
public init() {}
// TODO: When implementing JetStream, creating and deleting store dir should be handled in start/stop methods
public func start(
port: Int = -1, cfg: String? = nil, file: StaticString = #file, line: UInt = #line
) {
XCTAssertNil(
self.process, "nats-server is already running on port \(port)", file: file, line: line)
let process = Process()
let pipe = Pipe()
let fileManager = FileManager.default
pidFile = fileManager.temporaryDirectory.appendingPathComponent("nats-server.pid")
let tempDir = FileManager.default.temporaryDirectory.appending(component: UUID().uuidString)
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = [
"nats-server", "-p", "\(port)", "-P", pidFile!.path, "--store_dir",
"\(tempDir.absoluteString)",
]
if let cfg {
process.arguments?.append(contentsOf: ["-c", cfg])
}
process.standardError = pipe
process.standardOutput = pipe
let outputHandle = pipe.fileHandleForReading
let semaphore = DispatchSemaphore(value: 0)
var lineCount = 0
let maxLines = 100
var serverError: String?
var outputBuffer = Data()
outputHandle.readabilityHandler = { fileHandle in
let data = fileHandle.availableData
guard data.count > 0 else { return }
outputBuffer.append(data)
guard let output = String(data: outputBuffer, encoding: .utf8) else { return }
let lines = output.split(separator: "\n", omittingEmptySubsequences: false)
let completedLines = lines.dropLast()
for lineSequence in completedLines {
let line = String(lineSequence)
lineCount += 1
let errorLine = self.extracErrorMessage(from: line)
if let port = self.extractPort(from: line, for: "client connections") {
self.natsServerPort = port
}
if let port = self.extractPort(from: line, for: "websocket clients") {
self.natsWebsocketPort = port
}
let ready = line.contains("Server is ready")
if !self.tlsEnabled && self.isTLS(from: line) {
self.tlsEnabled = true
}
if ready || errorLine != nil || lineCount >= maxLines {
serverError = errorLine
semaphore.signal()
outputHandle.readabilityHandler = nil
return
}
}
if output.hasSuffix("\n") {
outputBuffer.removeAll()
} else {
if let lastLine = lines.last, let incompleteLine = lastLine.data(using: .utf8) {
outputBuffer = incompleteLine
}
}
}
XCTAssertNoThrow(
try process.run(), "error starting nats-server on port \(port)", file: file, line: line)
let result = semaphore.wait(timeout: .now() + .seconds(10))
XCTAssertFalse(
result == .timedOut, "timeout waiting for server to be ready", file: file, line: line)
XCTAssertNil(
serverError, "error starting nats-server: \(serverError!)", file: file, line: line)
self.process = process
}
public func stop() {
if process == nil {
return
}
self.process?.terminate()
process?.waitUntilExit()
process = nil
natsServerPort = port
tlsEnabled = false
}
public func sendSignal(_ signal: Signal, file: StaticString = #file, line: UInt = #line) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["nats-server", "--signal", "\(signal.rawValue)=\(self.pidFile!.path)"]
XCTAssertNoThrow(
try process.run(), "error setting signal", file: file, line: line)
self.process = nil
}
private func extractPort(from string: String, for phrase: String) -> Int? {
// Listening for websocket clients on
// Listening for client connections on
let pattern = "Listening for \(phrase) on .*?:(\\d+)$"
let regex = try! NSRegularExpression(pattern: pattern)
let nsrange = NSRange(string.startIndex..<string.endIndex, in: string)
if let match = regex.firstMatch(in: string, options: [], range: nsrange) {
let portRange = match.range(at: 1)
if let swiftRange = Range(portRange, in: string) {
let portString = String(string[swiftRange])
return Int(portString)
}
}
return nil
}
private func extracErrorMessage(from logLine: String) -> String? {
if logLine.contains("nats-server: No such file or directory") {
return "nats-server not found - make sure nats-server can be found in PATH"
}
guard let range = logLine.range(of: "[FTL]") else {
return nil
}
let messageStartIndex = range.upperBound
let message = logLine[messageStartIndex...]
return String(message).trimmingCharacters(in: .whitespaces)
}
private func isTLS(from logLine: String) -> Bool {
return logLine.contains("TLS required for client connections")
|| logLine.contains("websocket clients on wss://")
}
deinit {
stop()
}
public enum Signal: String {
case lameDuckMode = "ldm"
case reload = "reload"
}
}

65
Sources/bench/main.swift Normal file
View File

@@ -0,0 +1,65 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
import Nats
if CommandLine.arguments.count != 4 {
exit(usage())
}
let cmd = CommandLine.arguments[1]
if cmd != "pub" {
exit(usage())
}
let subject = CommandLine.arguments[2]
guard let msgs = Int(CommandLine.arguments[3]) else {
exit(usage())
}
let nats = NatsClientOptions()
.url(URL(string: ProcessInfo.processInfo.environment["NATS_URL"] ?? "nats://localhost:4222")!)
.build()
print("connect")
try await nats.connect()
let data = Data(repeating: 0, count: 128)
print("start")
let now = DispatchTime.now()
if cmd == "pub" {
try await pub()
}
let elapsed = DispatchTime.now().uptimeNanoseconds - now.uptimeNanoseconds
let msgsPerSec: Double = Double(msgs) / (Double(elapsed) / 1_000_000_000)
print("elapsed: \(elapsed / 1_000_000)ms ~ \(msgsPerSec) msgs/s")
func pub() async throws {
print("publish")
for _ in 1...msgs {
try await nats.publish(data, subject: subject)
}
print("flush")
_ = try await nats.rtt()
}
func usage() -> Int32 {
print("Usage: bench pub <subject> <msgs>")
return 2
}