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,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
}
}