diff --git a/Package.resolved b/Package.resolved index 54a94f5c..c30ae33c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,15 +1,6 @@ { "object": { "pins": [ - { - "package": "NWWebSocket", - "repositoryURL": "https://github.com/pusher/NWWebSocket.git", - "state": { - "branch": null, - "revision": "19d23951c8099304ad98e2fc762fa23d6bfab0b9", - "version": "0.5.3" - } - }, { "package": "TweetNacl", "repositoryURL": "https://github.com/bitmark-inc/tweetnacl-swiftwrap", diff --git a/Package.swift b/Package.swift index 6def1958..8453d1d9 100644 --- a/Package.swift +++ b/Package.swift @@ -9,14 +9,12 @@ let package = Package( .library(name: "PusherSwift", targets: ["PusherSwift"]) ], dependencies: [ - .package(url: "https://github.com/pusher/NWWebSocket.git", .upToNextMajor(from: "0.5.4")), .package(url: "https://github.com/bitmark-inc/tweetnacl-swiftwrap", .upToNextMajor(from: "1.0.0")), ], targets: [ .target( name: "PusherSwift", dependencies: [ - "NWWebSocket", "TweetNacl", ], path: "Sources" diff --git a/Sources/Extensions/PusherConnection+WebsocketDelegate.swift b/Sources/Extensions/PusherConnection+WebsocketDelegate.swift index 8ed7beb8..a569bb3d 100644 --- a/Sources/Extensions/PusherConnection+WebsocketDelegate.swift +++ b/Sources/Extensions/PusherConnection+WebsocketDelegate.swift @@ -1,6 +1,5 @@ import Foundation import Network -import NWWebSocket extension PusherConnection: WebSocketConnectionDelegate { @@ -48,7 +47,7 @@ extension PusherConnection: WebSocketConnectionDelegate { - parameter reason: Optional further information on the connection closure. */ public func webSocketDidDisconnect(connection: WebSocketConnection, - closeCode: NWProtocolWebSocket.CloseCode, + closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { resetConnection() @@ -64,7 +63,8 @@ extension PusherConnection: WebSocketConnectionDelegate { // Attempt reconnect if possible // `autoReconnect` option is ignored if the closure code is within the 4000-4999 range - if case .privateCode = closeCode {} else { + + if (4000...4999).contains(closeCode.rawValue) {} else { guard self.options.autoReconnect else { return } @@ -86,7 +86,7 @@ extension PusherConnection: WebSocketConnectionDelegate { } } - public func webSocketDidAttemptBetterPathMigration(result: Result) { + public func webSocketDidAttemptBetterPathMigration(result: Result) { switch result { case .success: updateConnectionState(to: .reconnecting) @@ -94,7 +94,7 @@ extension PusherConnection: WebSocketConnectionDelegate { case .failure(let error): Logger.shared.debug(for: .errorReceived, context: """ - Path migration error: \(error.debugDescription) + Path migration error: \(error) """) } } @@ -106,7 +106,7 @@ extension PusherConnection: WebSocketConnectionDelegate { `PusherChannelsProtocolCloseCode.ReconnectionStrategy`. - Parameter closeCode: The closure code received by the WebSocket connection. */ - func attemptReconnect(closeCode: NWProtocolWebSocket.CloseCode = .protocolCode(.normalClosure)) { + func attemptReconnect(closeCode: URLSessionWebSocketTask.CloseCode = .normalClosure) { guard connectionState != .connected else { return } @@ -118,8 +118,8 @@ extension PusherConnection: WebSocketConnectionDelegate { // Reconnect attempt according to Pusher Channels Protocol close code (if present). // (Otherwise, the default behavior is to attempt reconnection after backing off). var channelsCloseCode: ChannelsProtocolCloseCode? - if case let .privateCode(code) = closeCode { - channelsCloseCode = ChannelsProtocolCloseCode(rawValue: code) + if (4000...4999).contains(closeCode.rawValue) { + channelsCloseCode = ChannelsProtocolCloseCode(rawValue: UInt16(closeCode.rawValue)) } let strategy = channelsCloseCode?.reconnectionStrategy ?? .reconnectAfterBackingOff @@ -186,22 +186,8 @@ extension PusherConnection: WebSocketConnectionDelegate { /// - Parameters: /// - closeCode: The closure code for the websocket connection. /// - reason: Optional further information on the connection closure. - func logDisconnection(closeCode: NWProtocolWebSocket.CloseCode, reason: Data?) { - var rawCode: UInt16! - switch closeCode { - case .protocolCode(let definedCode): - rawCode = definedCode.rawValue - - case .applicationCode(let applicationCode): - rawCode = applicationCode - - case .privateCode(let protocolCode): - rawCode = protocolCode - @unknown default: - fatalError() - } - - var closeMessage: String = "Close code: \(String(describing: rawCode))." + func logDisconnection(closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + var closeMessage: String = "Close code: \(String(describing: closeCode.rawValue))." if let reason = reason, let reasonString = String(data: reason, encoding: .utf8) { closeMessage += " Reason: \(reasonString)." @@ -224,15 +210,15 @@ extension PusherConnection: WebSocketConnectionDelegate { // } - public func webSocketDidReceiveError(connection: WebSocketConnection, error: NWError) { + public func webSocketDidReceiveError(connection: WebSocketConnection, error: Error) { Logger.shared.debug(for: .errorReceived, context: """ - Error: \(error.debugDescription) + Error: \(error) """) // Resetting connection if we receive another POSIXError // than ENOTCONN (57 - Socket is not connected) - if case .posix(let code) = error, code != .ENOTCONN { + if let urlError = error as? URLError, urlError.code.rawValue != POSIXError.ENOTCONN.rawValue { resetConnection() guard !intentionalDisconnect else { diff --git a/Sources/Protocols/WebSocketConnection.swift b/Sources/Protocols/WebSocketConnection.swift new file mode 100644 index 00000000..5081fadb --- /dev/null +++ b/Sources/Protocols/WebSocketConnection.swift @@ -0,0 +1,93 @@ +import Foundation + +/// Defines a WebSocket connection. +public protocol WebSocketConnection { + /// Connect to the WebSocket. + func connect() + + /// Send a UTF-8 formatted `String` over the WebSocket. + /// - Parameter string: The `String` that will be sent. + func send(string: String) + + /// Send some `Data` over the WebSocket. + /// - Parameter data: The `Data` that will be sent. + func send(data: Data) + + /// Start listening for messages over the WebSocket. + func listen() + + /// Ping the WebSocket periodically. + /// - Parameter interval: The `TimeInterval` (in seconds) with which to ping the server. + func ping(interval: TimeInterval) + + /// Ping the WebSocket once. + func ping() + + /// Disconnect from the WebSocket. + /// - Parameter closeCode: The code to use when closing the WebSocket connection. + func disconnect(closeCode: URLSessionWebSocketTask.CloseCode) + + /// The WebSocket connection delegate. + var delegate: WebSocketConnectionDelegate? { get set } +} + +/// Defines a delegate for a WebSocket connection. +public protocol WebSocketConnectionDelegate: AnyObject { + /// Tells the delegate that the WebSocket did connect successfully. + /// - Parameter connection: The active `WebSocketConnection`. + func webSocketDidConnect(connection: WebSocketConnection) + + /// Tells the delegate that the WebSocket did disconnect. + /// - Parameters: + /// - connection: The `WebSocketConnection` that disconnected. + /// - closeCode: A `URLSessionWebSocketTask.CloseCode` describing how the connection closed. + /// - reason: Optional extra information explaining the disconnection. (Formatted as UTF-8 encoded `Data`). + func webSocketDidDisconnect(connection: WebSocketConnection, + closeCode: URLSessionWebSocketTask.CloseCode, + reason: Data?) + + /// Tells the delegate that the WebSocket connection viability has changed. + /// + /// An example scenario of when this method would be called is a Wi-Fi connection being lost due to a device + /// moving out of signal range, and then the method would be called again once the device moved back in range. + /// - Parameters: + /// - connection: The `WebSocketConnection` whose viability has changed. + /// - isViable: A `Bool` indicating if the connection is viable or not. + func webSocketViabilityDidChange(connection: WebSocketConnection, + isViable: Bool) + + /// Tells the delegate that the WebSocket has attempted a migration based on a better network path becoming available. + /// + /// An example of when this method would be called is if a device is using a cellular connection, and a Wi-Fi connection + /// becomes available. This method will also be called if a device loses a Wi-Fi connection, and a cellular connection is available. + /// - Parameter result: A `Result` containing the `WebSocketConnection` if the migration was successful, or a + /// `NWError` if the migration failed for some reason. + func webSocketDidAttemptBetterPathMigration(result: Result) + + /// Tells the delegate that the WebSocket received an error. + /// + /// An error received by a WebSocket is not necessarily fatal. + /// - Parameters: + /// - connection: The `WebSocketConnection` that received an error. + /// - error: The `Error` that was received. + func webSocketDidReceiveError(connection: WebSocketConnection, + error: Error) + + /// Tells the delegate that the WebSocket received a 'pong' from the server. + /// - Parameter connection: The active `WebSocketConnection`. + func webSocketDidReceivePong(connection: WebSocketConnection) + + /// Tells the delegate that the WebSocket received a `String` message. + /// - Parameters: + /// - connection: The active `WebSocketConnection`. + /// - string: The UTF-8 formatted `String` that was received. + func webSocketDidReceiveMessage(connection: WebSocketConnection, + string: String) + + /// Tells the delegate that the WebSocket received a binary `Data` message. + /// - Parameters: + /// - connection: The active `WebSocketConnection`. + /// - data: The `Data` that was received. + func webSocketDidReceiveMessage(connection: WebSocketConnection, + data: Data) +} diff --git a/Sources/PusherSwift.swift b/Sources/PusherSwift.swift index 11261916..b0e783a0 100644 --- a/Sources/PusherSwift.swift +++ b/Sources/PusherSwift.swift @@ -1,5 +1,4 @@ import Foundation -import NWWebSocket let PROTOCOL = 7 let VERSION = "10.1.5" @@ -27,9 +26,11 @@ let CLIENT_NAME = "pusher-websocket-swift" public init(key: String, options: PusherClientOptions = PusherClientOptions()) { self.key = key let urlString = URL.channelsSocketUrl(key: key, options: options) - let wsOptions = NWWebSocket.defaultOptions - wsOptions.setSubprotocols(["pusher-channels-protocol-\(PROTOCOL)"]) - let ws = NWWebSocket(url: URL(string: urlString)!, options: wsOptions) + let config = URLSessionConfiguration.default + config.httpAdditionalHeaders = [ + "Sec-WebSocket-Protocol": "pusher-channels-protocol-\(PROTOCOL)" + ] + let ws = WebSocketClient(url: URL(string: urlString)!, options: config) connection = PusherConnection(key: key, socket: ws, url: urlString, options: options) connection.createGlobalChannel() } diff --git a/Sources/Services/PusherConnection.swift b/Sources/Services/PusherConnection.swift index 16c55cb3..7934e5cb 100644 --- a/Sources/Services/PusherConnection.swift +++ b/Sources/Services/PusherConnection.swift @@ -1,5 +1,4 @@ import Foundation -import NWWebSocket // swiftlint:disable file_length type_body_length @@ -12,7 +11,7 @@ import NWWebSocket open var socketId: String? open var connectionState = ConnectionState.disconnected open var channels = PusherChannels() - open var socket: NWWebSocket! + open var socket: WebSocketClient! open var URLSession: Foundation.URLSession open var userDataFetcher: (() -> PusherPresenceChannelMember)? open var reconnectAttemptsMax: Int? @@ -60,7 +59,7 @@ import NWWebSocket */ public init( key: String, - socket: NWWebSocket, + socket: WebSocketClient, url: String, options: PusherClientOptions, URLSession: Foundation.URLSession = Foundation.URLSession.shared diff --git a/Sources/Services/WebSocketClient.swift b/Sources/Services/WebSocketClient.swift new file mode 100644 index 00000000..b2a54f3e --- /dev/null +++ b/Sources/Services/WebSocketClient.swift @@ -0,0 +1,191 @@ +import Foundation + +/// A WebSocket client that manages a socket connection. +open class WebSocketClient: WebSocketConnection { + + // MARK: - Public properties + + /// The WebSocket connection delegate. + public weak var delegate: WebSocketConnectionDelegate? + + // MARK: - Private properties + + private var webSocketTask: URLSessionWebSocketTask? + private let url: URL + private let session: URLSession + private let connectionQueue: DispatchQueue + private var pingTimer: Timer? + private var isConnected = false + private var isIntentionalDisconnection = false + private var errorWhileWaitingCount = 0 + private let errorWhileWaitingLimit = 20 + private var disconnectionWorkItem: DispatchWorkItem? + private var activeListeners = Set() + + // MARK: - Initialization + + /// Creates a `NWWebSocket` instance which connects to a socket `url`. + /// - Parameters: + /// - request: The `URLRequest` containing the connection endpoint `URL`. + /// - connectAutomatically: Determines if a connection should occur automatically on initialization. + /// The default value is `false`. + /// - connectionQueue: A `DispatchQueue` on which to deliver all connection events. The default value is `.main`. + public convenience init(request: URLRequest, + options: URLSessionConfiguration = .default, + connectAutomatically: Bool = false, + connectionQueue: DispatchQueue = .main) { + self.init(url: request.url!, + options: options, + connectAutomatically: connectAutomatically, + connectionQueue: connectionQueue) + } + + /// Creates a `NWWebSocket` instance which connects to a socket `url`. + /// - Parameters: + /// - url: The connection endpoint `URL`. + /// - connectAutomatically: Determines if a connection should occur automatically on initialization. + /// The default value is `false`. + /// - connectionQueue: A `DispatchQueue` on which to deliver all connection events. The default value is `.main`. + public init(url: URL, + options: URLSessionConfiguration, + connectAutomatically: Bool = false, + connectionQueue: DispatchQueue = .main) { + self.url = url + self.connectionQueue = connectionQueue + self.session = URLSession(configuration: options) + + if connectAutomatically { + connect() + } + } + + deinit { + disconnect(closeCode: .normalClosure) + } + + // MARK: - WebSocketConnection conformance + + /// Connect to the WebSocket. + open func connect() { + guard !isConnected else { return } + + if webSocketTask != nil { + disconnect(closeCode: .normalClosure) + } + + webSocketTask = session.webSocketTask(with: url) + webSocketTask?.resume() + isConnected = true + activeListeners.removeAll() + delegate?.webSocketDidConnect(connection: self) + listen() + } + + /// Send a UTF-8 formatted `String` over the WebSocket. + /// - Parameter string: The `String` that will be sent. + open func send(string: String) { + let message = URLSessionWebSocketTask.Message.string(string) + webSocketTask?.send(message) { [weak self] error in + if let error = error { + self?.delegate?.webSocketDidReceiveError(connection: self!, error: error) + } + } + } + + /// Send some `Data` over the WebSocket. + /// - Parameter data: The `Data` that will be sent. + open func send(data: Data) { + let message = URLSessionWebSocketTask.Message.data(data) + webSocketTask?.send(message) { [weak self] error in + if let error = error { + self?.delegate?.webSocketDidReceiveError(connection: self!, error: error) + } + } + } + + /// Start listening for messages over the WebSocket. + public func listen() { + guard isConnected else { return } + + let listenerId = UUID() + activeListeners.insert(listenerId) + + webSocketTask?.receive { [weak self] result in + guard let self = self, + self.isConnected, + self.activeListeners.contains(listenerId) else { return } + + switch result { + case .success(let message): + switch message { + case .string(let string): + self.delegate?.webSocketDidReceiveMessage(connection: self, string: string) + case .data(let data): + self.delegate?.webSocketDidReceiveMessage(connection: self, data: data) + @unknown default: + break + } + + if self.isConnected && self.activeListeners.contains(listenerId) { + self.listen() + } + + case .failure(let error): + self.delegate?.webSocketDidReceiveError(connection: self, error: error) + if !self.isIntentionalDisconnection { + self.disconnect(closeCode: .abnormalClosure) + } + } + } + } + + /// Ping the WebSocket periodically. + /// - Parameter interval: The `TimeInterval` (in seconds) with which to ping the server. + open func ping(interval: TimeInterval) { + pingTimer = .scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + guard let self = self else { return } + self.ping() + } + pingTimer?.tolerance = 0.01 + } + + /// Ping the WebSocket once. + open func ping() { + webSocketTask?.sendPing { [weak self] error in + guard let self = self else { return } + + if let error = error { + self.delegate?.webSocketDidReceiveError(connection: self, error: error) + } else { + self.delegate?.webSocketDidReceivePong(connection: self) + } + } + } + + /// Disconnect from the WebSocket. + /// - Parameter closeCode: The code to use when closing the WebSocket connection. + open func disconnect(closeCode: URLSessionWebSocketTask.CloseCode = .normalClosure) { + guard isConnected || webSocketTask != nil else { return } + + isIntentionalDisconnection = true + isConnected = false + + activeListeners.removeAll() + + pingTimer?.invalidate() + pingTimer = nil + + webSocketTask?.cancel(with: closeCode, reason: nil) + webSocketTask = nil + + connectionQueue.async { [weak self] in + guard let self = self else { return } + self.delegate?.webSocketDidDisconnect(connection: self, + closeCode: closeCode, + reason: nil) + self.isIntentionalDisconnection = false + } + } +} + + diff --git a/Tests/Helpers/Mocks.swift b/Tests/Helpers/Mocks.swift index 57a3ee9f..365c4211 100644 --- a/Tests/Helpers/Mocks.swift +++ b/Tests/Helpers/Mocks.swift @@ -1,17 +1,16 @@ import Foundation import Network -import NWWebSocket @testable import PusherSwift -class MockWebSocket: NWWebSocket { +class MockWebSocket: WebSocketClient { let stubber = StubberForMocks() var callbackCheckString: String = "" var objectGivenToCallback: Any? var eventGivenToCallback: PusherEvent? init() { - super.init(url: URL(string: "test")!) + super.init(url: URL(string: "test")!, options: .default) } func appendToCallbackCheckString(_ str: String) { @@ -42,7 +41,7 @@ class MockWebSocket: NWWebSocket { ) } - override func disconnect(closeCode: NWProtocolWebSocket.CloseCode = .protocolCode(.normalClosure)) { + override func disconnect(closeCode: URLSessionWebSocketTask.CloseCode = .normalClosure) { _ = stubber.stub( functionName: "disconnect", args: nil, diff --git a/Tests/Unit/Protocols/PusherConnectionDelegateTests.swift b/Tests/Unit/Protocols/PusherConnectionDelegateTests.swift index 652d5a36..6f94fa5f 100644 --- a/Tests/Unit/Protocols/PusherConnectionDelegateTests.swift +++ b/Tests/Unit/Protocols/PusherConnectionDelegateTests.swift @@ -81,7 +81,12 @@ class PusherConnectionDelegateTests: XCTestCase { isConnected.fulfill() // Spoof an unintentional disconnection event (that should not reconnect) - self.socket.disconnect(closeCode: .privateCode(ChannelsProtocolCloseCode.connectionIsUnauthorized.rawValue)) + let closeCode = URLSessionWebSocketTask.CloseCode( + rawValue: Int(ChannelsProtocolCloseCode.connectionIsUnauthorized.rawValue) + ) + self.socket.disconnect( + closeCode: closeCode! + ) } else if calls.count == 3 { XCTAssertEqual(calls[0].name, "connectionChange") XCTAssertEqual(calls[0].args?.first as? ConnectionState, ConnectionState.disconnected) @@ -115,7 +120,12 @@ class PusherConnectionDelegateTests: XCTestCase { isConnected.fulfill() // Spoof an unintentional disconnection event (that should attempt a reconnect) - self.socket.disconnect(closeCode: .privateCode(ChannelsProtocolCloseCode.genericReconnectImmediately.rawValue)) + let closeCode = URLSessionWebSocketTask.CloseCode( + rawValue: Int(ChannelsProtocolCloseCode.genericReconnectImmediately.rawValue) + ) + self.socket.disconnect( + closeCode: closeCode! + ) } else if calls.count == 6 { XCTAssertEqual(calls[0].name, "connectionChange") XCTAssertEqual(calls[0].args?.first as? ConnectionState, ConnectionState.disconnected) diff --git a/Tests/Unit/Services/Server/NWWebSocketServer.swift b/Tests/Unit/Services/Server/NWWebSocketServer.swift new file mode 100644 index 00000000..ab1f088a --- /dev/null +++ b/Tests/Unit/Services/Server/NWWebSocketServer.swift @@ -0,0 +1,106 @@ +import Foundation +import Network + +internal class WebSocketServer { + + // MARK: - Private properties + + private let port: NWEndpoint.Port + private var listener: NWListener? + private let parameters: NWParameters + private var connectionsByID: [Int: WebSocketServerConnection] = [:] + + // MARK: - Lifecycle + + init(port: UInt16) { + self.port = NWEndpoint.Port(rawValue: port)! + parameters = NWParameters(tls: nil) + parameters.allowLocalEndpointReuse = true + parameters.includePeerToPeer = true + let wsOptions = NWProtocolWebSocket.Options() + wsOptions.autoReplyPing = true + parameters.defaultProtocolStack.applicationProtocols.insert(wsOptions, at: 0) + } + + // MARK: - Public methods + + func start() throws { + print("Server starting...") + if listener == nil { + listener = try! NWListener(using: parameters, on: self.port) + } + listener?.stateUpdateHandler = self.stateDidChange(to:) + listener?.newConnectionHandler = self.didAccept(nwConnection:) + listener?.start(queue: .main) + } + + func stop() { + listener?.cancel() + } + + // MARK: - Private methods + + private func didAccept(nwConnection: NWConnection) { + let connection = WebSocketServerConnection(nwConnection: nwConnection) + connectionsByID[connection.id] = connection + + connection.start() + + connection.didStopHandler = { err in + if let err = err { + print(err) + } + self.connectionDidStop(connection) + } + connection.didReceiveStringHandler = { string in + self.connectionsByID.values.forEach { connection in + print("sent \(string) to open connection \(connection.id)") + connection.send(string: string) + } + } + connection.didReceiveDataHandler = { data in + self.connectionsByID.values.forEach { connection in + print("sent \(String(data: data, encoding: .utf8) ?? "NOTHING") to open connection \(connection.id)") + connection.send(data: data) + } + } + + print("server did open connection \(connection.id)") + } + + private func stateDidChange(to newState: NWListener.State) { + switch newState { + case .setup: + print("Server is setup.") + case .waiting(let error): + print("Server is waiting to start, non-fatal error: \(error.debugDescription)") + case .ready: + print("Server ready.") + case .cancelled: + self.stopSever(error: nil) + case .failed(let error): + self.stopSever(error: error) + @unknown default: + fatalError() + } + } + + private func connectionDidStop(_ connection: WebSocketServerConnection) { + self.connectionsByID.removeValue(forKey: connection.id) + print("server did close connection \(connection.id)") + } + + private func stopSever(error: NWError?) { + self.listener = nil + for connection in self.connectionsByID.values { + connection.didStopHandler = nil + connection.stop() + } + self.connectionsByID.removeAll() + if let error = error { + print("Server failure, error: \(error.debugDescription)") + } else { + print("Server stopped normally.") + } + } +} diff --git a/Tests/Unit/Services/Server/WebSocketServerConnection.swift b/Tests/Unit/Services/Server/WebSocketServerConnection.swift new file mode 100644 index 00000000..a6a63d8e --- /dev/null +++ b/Tests/Unit/Services/Server/WebSocketServerConnection.swift @@ -0,0 +1,159 @@ +import Foundation +import Network + +internal class WebSocketServerConnection { + + // MARK: - Public properties + + let id: Int + + var didStopHandler: ((Error?) -> Void)? = nil + var didReceiveStringHandler: ((String) -> ())? = nil + var didReceiveDataHandler: ((Data) -> ())? = nil + + // MARK: - Private properties + + private static var nextID: Int = 0 + private let connection: NWConnection + + // MARK: - Lifecycle + + init(nwConnection: NWConnection) { + connection = nwConnection + id = Self.nextID + Self.nextID += 1 + } + + deinit { + print("deinit") + } + + // MARK: - Public methods + + func start() { + print("connection \(id) will start") + connection.stateUpdateHandler = self.stateDidChange(to:) + listen() + connection.start(queue: .main) + } + + func receiveMessage(data: Data, context: NWConnection.ContentContext) { + guard let metadata = context.protocolMetadata.first as? NWProtocolWebSocket.Metadata else { + return + } + + switch metadata.opcode { + case .binary: + didReceiveDataHandler?(data) + case .cont: + // + break + case .text: + guard let string = String(data: data, encoding: .utf8) else { + return + } + didReceiveStringHandler?(string) + case .close: + // + break + case .ping: + pong() + break + case .pong: + // + break + @unknown default: + fatalError() + } + } + + func send(string: String) { + let metaData = NWProtocolWebSocket.Metadata(opcode: .text) + let context = NWConnection.ContentContext(identifier: "textContext", + metadata: [metaData]) + self.send(data: string.data(using: .utf8), context: context) + } + + func send(data: Data) { + let metaData = NWProtocolWebSocket.Metadata(opcode: .binary) + let context = NWConnection.ContentContext(identifier: "binaryContext", + metadata: [metaData]) + self.send(data: data, context: context) + } + + func stop() { + print("connection \(id) will stop") + } + + // MARK: - Private methods + + private func stateDidChange(to state: NWConnection.State) { + switch state { + case .setup: + print("connection is setup.") + case .waiting(let error): + connectionDidReceiveError(error) + case .preparing: + print("connection is being establised.") + case .ready: + print("connection \(id) ready") + case .cancelled: + stopConnection(error: nil) + case .failed(let error): + stopConnection(error: error) + @unknown default: + fatalError() + } + } + + private func listen() { + connection.receiveMessage() { (data, context, isComplete, error) in + if let data = data, let context = context, !data.isEmpty { + self.receiveMessage(data: data, context: context) + } + if let error = error { + self.connectionDidReceiveError(error) + } else { + self.listen() + } + } + } + + private func pong() { + let metaData = NWProtocolWebSocket.Metadata(opcode: .pong) + let context = NWConnection.ContentContext(identifier: "pongContext", + metadata: [metaData]) + self.send(data: Data(), context: context) + } + + private func send(data: Data?, context: NWConnection.ContentContext) { + self.connection.send(content: data, + contentContext: context, + isComplete: true, + completion: .contentProcessed( { error in + if let error = error { + self.connectionDidReceiveError(error) + return + } + print("connection \(self.id) did send, data: \(String(describing: data))") + })) + } + + private func connectionDidReceiveError(_ error: NWError) { + print("connection did receive error: \(error.debugDescription)") + } + + private func stopConnection(error: Error?) { + connection.stateUpdateHandler = nil + if let didStopHandler = didStopHandler { + self.didStopHandler = nil + didStopHandler(error) + } + if let error = error { + print("connection \(id) did fail, error: \(error)") + } else { + print("connection \(id) did end") + } + } +} + diff --git a/Tests/Unit/Services/WebSocketClientTests.swift b/Tests/Unit/Services/WebSocketClientTests.swift new file mode 100644 index 00000000..0dbe1eec --- /dev/null +++ b/Tests/Unit/Services/WebSocketClientTests.swift @@ -0,0 +1,174 @@ +import XCTest +import Network +@testable import PusherSwift + +class WebSocketClientTests: XCTestCase { + static var socket: WebSocketClient! + static var server: WebSocketServer! + + static var connectExpectation: XCTestExpectation? { + didSet { + Self.shouldDisconnectImmediately = false + } + } + static var disconnectExpectation: XCTestExpectation! { + didSet { + Self.shouldDisconnectImmediately = true + } + } + static var stringMessageExpectation: XCTestExpectation! { + didSet { + Self.shouldDisconnectImmediately = false + } + } + static var dataMessageExpectation: XCTestExpectation! { + didSet { + Self.shouldDisconnectImmediately = false + } + } + static var pongExpectation: XCTestExpectation? { + didSet { + Self.shouldDisconnectImmediately = false + } + } + static var pingsWithIntervalExpectation: XCTestExpectation? { + didSet { + Self.shouldDisconnectImmediately = false + } + } + static var errorExpectation: XCTestExpectation? { + didSet { + Self.shouldDisconnectImmediately = false + } + } + + static var shouldDisconnectImmediately: Bool! + static var receivedPongTimestamps: [Date]! + + static let expectationTimeout = 5.0 + static let stringMessage = "This is a string message!" + static let dataMessage = "This is a data message!".data(using: .utf8)! + static let expectedReceivedPongsCount = 3 + static let repeatedPingInterval = 0.5 + static let validLocalhostServerPort: UInt16 = 3000 + static let invalidLocalhostServerPort: UInt16 = 2000 + + override func setUp() { + super.setUp() + + Self.server = WebSocketServer(port: Self.validLocalhostServerPort) + try! Self.server.start() + let serverURL = URL(string: "ws://localhost:\(Self.validLocalhostServerPort)")! + Self.socket = WebSocketClient(url: serverURL, options: .default) + Self.socket.delegate = self + Self.receivedPongTimestamps = [] + } + + // MARK: - Test methods + + func testConnect() { + Self.connectExpectation = XCTestExpectation(description: "connectExpectation") + Self.socket.connect() + wait(for: [Self.connectExpectation!], timeout: Self.expectationTimeout) + } + + func testDisconnect() { + Self.disconnectExpectation = XCTestExpectation(description: "disconnectExpectation") + Self.socket.connect() + wait(for: [Self.disconnectExpectation], timeout: Self.expectationTimeout) + } + + func testReceiveStringMessage() { + Self.stringMessageExpectation = XCTestExpectation(description: "stringMessageExpectation") + Self.socket.connect() + Self.socket.send(string: Self.stringMessage) + wait(for: [Self.stringMessageExpectation], timeout: Self.expectationTimeout) + } + + func testReceiveDataMessage() { + Self.dataMessageExpectation = XCTestExpectation(description: "dataMessageExpectation") + Self.socket.connect() + Self.socket.send(data: Self.dataMessage) + wait(for: [Self.dataMessageExpectation], timeout: Self.expectationTimeout) + } + + func testReceivePong() { + Self.pongExpectation = XCTestExpectation(description: "pongExpectation") + Self.socket.connect() + Self.socket.ping() + wait(for: [Self.pongExpectation!], timeout: Self.expectationTimeout) + } + + func testPingsWithInterval() { + Self.pingsWithIntervalExpectation = XCTestExpectation(description: "pingsWithIntervalExpectation") + Self.socket.connect() + Self.socket.ping(interval: Self.repeatedPingInterval) + wait(for: [Self.pingsWithIntervalExpectation!], timeout: Self.expectationTimeout) + } + + func testReceiveError() { + // Redefine socket with invalid path + Self.socket = WebSocketClient(request: URLRequest(url: URL(string: "ws://localhost:\(Self.invalidLocalhostServerPort)")!)) + Self.socket.delegate = self + + Self.errorExpectation = XCTestExpectation(description: "errorExpectation") + Self.socket.connect() + wait(for: [Self.errorExpectation!], timeout: Self.expectationTimeout) + } + +} + +// MARK: - WebSocketConnectionDelegate conformance + +extension WebSocketClientTests: WebSocketConnectionDelegate { + func webSocketDidConnect(connection: WebSocketConnection) { + Self.connectExpectation?.fulfill() + + if Self.shouldDisconnectImmediately { + Self.socket.disconnect() + } + } + + func webSocketDidDisconnect(connection: WebSocketConnection, + closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + Self.disconnectExpectation?.fulfill() + } + + func webSocketViabilityDidChange(connection: WebSocketConnection, isViable: Bool) { + if isViable == false { + XCTFail("WebSocket should not become unviable during testing.") + } + } + + func webSocketDidAttemptBetterPathMigration(result: Result) { + XCTFail("WebSocket should not attempt to migrate to a better path during testing.") + } + + func webSocketDidReceiveError(connection: WebSocketConnection, error: Error) { + Self.errorExpectation?.fulfill() + } + + func webSocketDidReceivePong(connection: WebSocketConnection) { + Self.pongExpectation?.fulfill() + + guard Self.pingsWithIntervalExpectation != nil else { + return + } + + if Self.receivedPongTimestamps.count == Self.expectedReceivedPongsCount { + Self.pingsWithIntervalExpectation?.fulfill() + } + Self.receivedPongTimestamps.append(Date()) + } + + func webSocketDidReceiveMessage(connection: WebSocketConnection, string: String) { + XCTAssertEqual(string, Self.stringMessage) + Self.stringMessageExpectation.fulfill() + } + + func webSocketDidReceiveMessage(connection: WebSocketConnection, data: Data) { + XCTAssertEqual(data, Self.dataMessage) + Self.dataMessageExpectation.fulfill() + } +} +