diff --git a/README.md b/README.md index 3191aca..2b94052 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,24 @@ # SkipSocketIO -This is a [Skip](https://skip.dev) Swift/Kotlin library project that -abstracts the Socket.io [iOS](https://socket.io/blog/socket-io-on-ios/) -and [Android](https://socket.io/blog/native-socket-io-and-android/) APIs. +Real-time bidirectional communication with [Socket.IO](https://socket.io) for [Skip](https://skip.dev) apps on both iOS and Android. -## About Socket.io +## About -[Socket.IO](https://socket.io) is a library for real-time, event-based, bidirectional communication that provides a robust abstraction layer over various transport protocols (primarily WebSocket and HTTP long-polling). +SkipSocketIO wraps the native Socket.IO client libraries for each platform: +- **iOS/macOS**: [socket.io-client-swift](https://github.com/socketio/socket.io-client-swift) (v16+) +- **Android**: [socket.io-client-java](https://github.com/socketio/socket.io-client-java) (v2.1) -Key features of the Socket.IO library that go beyond the raw WebSocket protocol include: +Key features inherited from Socket.IO: -- Automatic reconnection with exponential backoff. -- Packet buffering and automatic acknowledgments. -- Event-driven communication with a simplified API (emit and on). -- Multiplexing through namespaces and broadcasting to rooms. +- Automatic reconnection with exponential backoff +- Packet buffering and automatic acknowledgments +- Event-driven communication with `emit` and `on` +- Multiplexing through namespaces +- Transport fallback (WebSocket with HTTP long-polling fallback) ## Setup -To include this framework in your project, add the following -dependency to your `Package.swift` file: +Add the dependency to your `Package.swift` file: ```swift let package = Package( @@ -37,44 +37,299 @@ let package = Package( ) ``` -## API Compatibility +## Usage -SkipSocketIO provides a similar API surface as the [SkipSocketIO Swift SDK](https://nuclearace.github.io/Socket.IO-Client-Swift/Classes/SocketIOClient.html) ([source](https://github.com/socketio/socket.io-client-swift)), ensuring a familiar development experience. All methods should behave identically on both iOS and Android platforms, allowing you to write once and deploy everywhere. On Android, the API calls are forwarded to their equivalents in the [Socket.IO Java SDK](https://socketio.github.io/socket.io-client-java/installation.html) ([source](https://github.com/socketio/socket.io-client-java)). - -## Usage Examples - -### Connection +### Connecting to a Server +Create a client and connect: ```swift +import SkipSocketIO + let socket = SkipSocketIOClient(socketURL: URL(string: "https://example.org")!, options: [ - .compress, - .path("/mypath/"), - .secure(false), - .forceNew(false), - .forcePolling(false), + .secure(true), .reconnects(true), .reconnectAttempts(5), - .reconnectWait(2), - .reconnectWaitMax(10), - .extraHeaders(["X-Custom-Header": "Value"]), ]) -socket.on("connection") { params in - logger.log("socket connection established") +socket.on(SocketIOEvent.connect) { _ in + print("Connected! Socket ID: \(socket.socketId ?? "unknown")") +} + +socket.on(SocketIOEvent.disconnect) { _ in + print("Disconnected") +} + +socket.on(SocketIOEvent.connectError) { data in + print("Connection error: \(data)") } socket.connect() +``` + +### Listening for Events + +Use `on` for persistent listeners, or `once` for one-time handlers: + +```swift +// Called every time the event fires +socket.on("chat message") { data in + if let message = data.first as? String { + print("Received: \(message)") + } +} + +// Called once, then automatically removed +socket.once("welcome") { data in + print("Server welcome: \(data)") +} +``` + +### Removing Event Handlers + +```swift +// Remove handlers for a specific event +socket.off("chat message") + +// Remove all handlers +socket.removeAllHandlers() +``` + +### Catch-All Handler + +Listen for every incoming event: + +```swift +socket.onAny { eventName, data in + print("Event '\(eventName)' received with data: \(data)") +} +``` + +### Emitting Events + +Send events with data to the server: + +```swift +// Simple string +socket.emit("chat message", ["Hello, world!"]) + +// Multiple items of different types +socket.emit("update", ["status", 42, true]) + +// With a send completion callback +socket.emit("save", ["data to save"]) { + print("Event sent") +} +``` + +### Acknowledgements + +Emit an event and receive an acknowledgement from the server: + +```swift +socket.emitWithAck("get-users", ["room-1"]) { ackData in + print("Server responded with: \(ackData)") +} +``` + +### Connection Status + +Check the current connection state: + +```swift +if socket.isConnected { + print("Socket ID: \(socket.socketId ?? "")") +} -socket.on("onUpdate") { params in - logger.log("onUpdate event received with parameters: \(params)") +switch socket.status { +case .connected: print("Connected") +case .connecting: print("Connecting...") +case .disconnected: print("Disconnected") +case .notConnected: print("Never connected") } +``` + +### Connect with Timeout + +```swift +socket.connect(timeoutAfter: 5.0) { + print("Connection timed out after 5 seconds") +} +``` + +### Namespaces + +Use `SkipSocketIOManager` to connect to multiple namespaces on the same server: + +```swift +let manager = SkipSocketIOManager(socketURL: URL(string: "https://example.org")!, options: [ + .reconnects(true), + .forceWebsockets(true), +]) + +let defaultSocket = manager.defaultSocket() +let chatSocket = manager.socket(forNamespace: "/chat") +let adminSocket = manager.socket(forNamespace: "/admin") + +chatSocket.on("message") { data in + print("Chat message: \(data)") +} + +adminSocket.on("notification") { data in + print("Admin notification: \(data)") +} + +defaultSocket.connect() +chatSocket.connect() +adminSocket.connect() +``` + +### Configuration Options + +```swift +let socket = SkipSocketIOClient(socketURL: URL(string: "https://example.org")!, options: [ + // Transport + .forceWebsockets(true), // Use only WebSockets (no long-polling fallback) + .forcePolling(true), // Use only HTTP long-polling + .compress, // Enable WebSocket compression + .secure(true), // Use secure transports (wss://) + .path("/custom-path/"), // Custom Socket.IO server path -socket.emit("update", ["hello", 1, "2", Data()]) + // Reconnection + .reconnects(true), // Enable auto-reconnection + .reconnectAttempts(10), // Max reconnection attempts (-1 for infinite) + .reconnectWait(1), // Min seconds between reconnection attempts + .reconnectWaitMax(30), // Max seconds between reconnection attempts + .randomizationFactor(0.5), // Jitter factor for reconnection delay -socket.disconnect() + // Headers and parameters + .extraHeaders(["Authorization": "Bearer token123"]), + .connectParams(["userId": "abc"]), + .auth(["token": "secret"]), + + // Other + .forceNew(true), // Always create a new engine + .log(true), // Enable debug logging + .selfSigned(true), // Allow self-signed certificates (dev only) + .enableSOCKSProxy(false), // SOCKS proxy support +]) +``` + +### SwiftUI Integration + +```swift +import SwiftUI +import SkipSocketIO + +struct ChatView: View { + @State var messages: [String] = [] + @State var inputText: String = "" + @State var socket = SkipSocketIOClient( + socketURL: URL(string: "https://chat.example.org")!, + options: [.reconnects(true)] + ) + + var body: some View { + VStack { + List(messages, id: \.self) { message in + Text(message) + } + HStack { + TextField("Message", text: $inputText) + .textFieldStyle(.roundedBorder) + Button("Send") { + socket.emit("chat message", [inputText]) + inputText = "" + } + } + .padding() + } + .task { + socket.on("chat message") { data in + if let msg = data.first as? String { + messages.append(msg) + } + } + socket.connect() + } + } +} ``` +## API Reference + +### SkipSocketIOClient + +The primary interface for Socket.IO communication. + +| Method / Property | Description | +|---|---| +| `init(socketURL:options:)` | Create a client for the given server URL | +| `connect()` | Connect to the server | +| `connect(timeoutAfter:handler:)` | Connect with a timeout callback | +| `disconnect()` | Disconnect from the server | +| `isConnected: Bool` | Whether the client is currently connected | +| `socketId: String?` | The server-assigned session ID | +| `status: SocketIOStatus` | Current connection status | +| `on(_:callback:)` | Register a persistent event handler | +| `once(_:callback:)` | Register a one-time event handler | +| `off(_:)` | Remove all handlers for an event | +| `removeAllHandlers()` | Remove all event handlers | +| `onAny(_:)` | Register a catch-all handler for all incoming events | +| `emit(_:_:completion:)` | Emit an event with data | +| `emitWithAck(_:_:ackCallback:)` | Emit an event and receive a server acknowledgement | + +### SkipSocketIOManager + +Manages connections and namespaces for a server. + +| Method | Description | +|---|---| +| `init(socketURL:options:)` | Create a manager for the given server URL | +| `defaultSocket()` | Returns a client for the default namespace (`/`) | +| `socket(forNamespace:)` | Returns a client for the given namespace | + +### SocketIOStatus + +| Case | Description | +|---|---| +| `.notConnected` | Has never been connected | +| `.connecting` | Connection in progress | +| `.connected` | Currently connected | +| `.disconnected` | Was connected, now disconnected | + +### SocketIOEvent + +Standard event name constants. + +| Constant | Value | Description | +|---|---|---| +| `SocketIOEvent.connect` | `"connect"` | Fired on successful connection | +| `SocketIOEvent.disconnect` | `"disconnect"` | Fired on disconnection | +| `SocketIOEvent.connectError` | `"connect_error"` | Fired on connection error | + +### SkipSocketIOClientOption + +| Option | Description | Android Support | +|---|---|---| +| `.compress` | WebSocket compression | Ignored | +| `.connectParams([String: Any])` | GET parameters for the connect URL | Ignored | +| `.extraHeaders([String: String])` | Extra HTTP headers | Supported | +| `.forceNew(Bool)` | Always create a new engine | Supported | +| `.forcePolling(Bool)` | HTTP long-polling only | Supported | +| `.forceWebsockets(Bool)` | WebSockets only | Supported | +| `.enableSOCKSProxy(Bool)` | SOCKS proxy | Ignored | +| `.log(Bool)` | Debug logging | Ignored | +| `.path(String)` | Custom Socket.IO path | Supported | +| `.reconnects(Bool)` | Auto-reconnection | Supported | +| `.reconnectAttempts(Int)` | Max reconnection attempts | Supported | +| `.reconnectWait(Int)` | Min seconds before reconnect | Supported | +| `.reconnectWaitMax(Int)` | Max seconds before reconnect | Supported | +| `.randomizationFactor(Double)` | Reconnect jitter | Supported | +| `.secure(Bool)` | Secure transports | Supported | +| `.selfSigned(Bool)` | Self-signed certs (dev only) | Ignored | +| `.auth([String: Any])` | Authentication payload | Ignored | + ## Building This project is a Swift Package Manager module that uses the @@ -97,5 +352,5 @@ which will output a table of the test results for both platforms. ## License -This software is licensed under the +This software is licensed under the [Mozilla Public License 2.0](https://www.mozilla.org/MPL/). diff --git a/Sources/SkipSocketIO/SkipSocketIO.swift b/Sources/SkipSocketIO/SkipSocketIO.swift index e4814cb..5bdd09b 100644 --- a/Sources/SkipSocketIO/SkipSocketIO.swift +++ b/Sources/SkipSocketIO/SkipSocketIO.swift @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MPL-2.0 #if !SKIP_BRIDGE import Foundation +import OSLog #if !SKIP import SocketIO @@ -13,30 +14,129 @@ import io.socket.client.SocketOptionBuilder import io.socket.emitter.Emitter #endif -/// Abstraction of the socket.io client API for [swift](https://nuclearace.github.io/Socket.IO-Client-Swift/Classes/SocketIOClient.html) and [Java](https://socketio.github.io/socket.io-client-java/apidocs/io/socket/client/package-summary.html) +let logger: Logger = Logger(subsystem: "skip.socketio", category: "SkipSocketIO") + +// MARK: - SocketIOStatus + +/// The connection status of a socket. +public enum SocketIOStatus: String { + case notConnected + case connecting + case connected + case disconnected +} + +// MARK: - SocketIOEvent + +/// Well-known Socket.IO event names. +/// +/// These correspond to the standard lifecycle events emitted by the Socket.IO protocol. +public enum SocketIOEvent { + /// Fired upon connection, including a successful reconnection. + public static let connect = "connect" + /// Fired upon disconnection. + public static let disconnect = "disconnect" + /// Fired upon a connection error. + public static let connectError = "connect_error" +} + +// MARK: - SkipSocketIOManager + +/// Manages one or more Socket.IO connections to a server. +/// +/// Use this class when you need to connect to multiple namespaces on the same server, +/// or when you need to keep a reference to the manager for reconnection control. +/// +/// https://nuclearace.github.io/Socket.IO-Client-Swift/Classes/SocketManager.html +/// https://socketio.github.io/socket.io-client-java/initialization.html +public class SkipSocketIOManager { + #if !SKIP + let manager: SocketManager + #else + let socketURL: java.net.URI + let optionsBuilder: IO.Options + #endif + + /// Create a manager for the given server URL. + /// + /// - Parameters: + /// - socketURL: The URL of the Socket.IO server. + /// - options: Configuration options for the connection. + public init(socketURL: URL, options: [SkipSocketIOClientOption] = []) { + #if !SKIP + var opts = SocketIOClientConfiguration() + for option in options { + opts.insert(option.toSocketIOClientOption()) + } + self.manager = SocketManager(socketURL: socketURL, config: opts) + #else + var builder = IO.Options.builder() + for option in options { + builder = option.addToOptionsBuilder(builder: builder) + } + self.socketURL = socketURL.kotlin() + self.optionsBuilder = builder.build() + #endif + } + + /// Returns a socket for the default namespace (`/`). + public func defaultSocket() -> SkipSocketIOClient { + #if !SKIP + return SkipSocketIOClient(socket: manager.defaultSocket) + #else + return SkipSocketIOClient(socket: IO.socket(socketURL, optionsBuilder)) + #endif + } + + /// Returns a socket for the given namespace. + /// + /// - Parameter namespace: The namespace to connect to (must start with `/`). + public func socket(forNamespace namespace: String) -> SkipSocketIOClient { + #if !SKIP + return SkipSocketIOClient(socket: manager.socket(forNamespace: namespace)) + #else + let nsURI = java.net.URI.create(socketURL.toString() + namespace) + return SkipSocketIOClient(socket: IO.socket(nsURI, optionsBuilder)) + #endif + } +} + +// MARK: - SkipSocketIOClient + +/// Abstraction of the socket.io client API for +/// [Swift](https://nuclearace.github.io/Socket.IO-Client-Swift/Classes/SocketIOClient.html) and +/// [Java](https://socketio.github.io/socket.io-client-java/apidocs/io/socket/client/Socket.html). +/// +/// This is the primary interface for interacting with a Socket.IO server. It supports +/// connecting, disconnecting, emitting events, listening for events, acknowledgements, +/// and connection status monitoring. public class SkipSocketIOClient { #if !SKIP // https://nuclearace.github.io/Socket.IO-Client-Swift/Classes/SocketIOClient.html let socket: SocketIOClient + private var handlerIDs: [String: UUID] = [:] #else - // https://socketio.github.io/socket.io-client-java/socket_instance.html // https://socketio.github.io/socket.io-client-java/apidocs/io/socket/client/Socket.html let socket: Socket + private var listeners: [String: Emitter.Listener] = [:] #endif + /// Create a client that connects to the given URL. + /// + /// For more control over namespaces, use `SkipSocketIOManager` instead. + /// + /// - Parameters: + /// - socketURL: The URL of the Socket.IO server. + /// - options: Configuration options for the connection. public init(socketURL: URL, options: [SkipSocketIOClientOption] = []) { #if !SKIP - // See example at https://github.com/socketio/socket.io-client-swift?tab=readme-ov-file var opts = SocketIOClientConfiguration() for option in options { opts.insert(option.toSocketIOClientOption()) } let manager = SocketManager(socketURL: socketURL, config: opts) - let defaultNamespaceSocket = manager.defaultSocket - // let swiftSocket = manager.socket(forNamespace: "/swift") // namespace example - self.socket = defaultNamespaceSocket + self.socket = manager.defaultSocket #else - // https://socketio.github.io/socket.io-client-java/initialization.html var optionsBuilder = IO.Options.builder() for option in options { optionsBuilder = option.addToOptionsBuilder(builder: optionsBuilder) @@ -45,23 +145,124 @@ public class SkipSocketIOClient { #endif } + #if !SKIP + init(socket: SocketIOClient) { + self.socket = socket + } + #else + init(socket: Socket) { + self.socket = socket + } + #endif + + // MARK: Connection + + /// Connect to the server. public func connect() { - // TODO: args like withPayload socket.connect() + return // needed because Java API returns a Socket instance + } + + /// Connect to the server with a timeout. + /// + /// If the connection is not established within the given time, the handler is called. + /// + /// - Parameters: + /// - timeoutAfter: Seconds to wait before timing out. + /// - handler: Called if the connection times out. + public func connect(timeoutAfter: Double, handler: @escaping () -> ()) { + #if !SKIP + socket.connect(timeoutAfter: timeoutAfter, withHandler: handler) + #else + socket.connect() + // The Java client does not support a native timeout; implement manually + let delayMs = Int64(timeoutAfter * 1000.0) + // SKIP INSERT: val timer = java.util.Timer() + // SKIP INSERT: timer.schedule(object : java.util.TimerTask() { override fun run() { if (!socket.connected()) { handler(); socket.disconnect() } } }, delayMs) + #endif } + /// Disconnect from the server. public func disconnect() { socket.disconnect() return // needed because Java API returns a Socket instance } + /// Whether the client is currently connected to the server. + public var isConnected: Bool { + #if !SKIP + return socket.status == .connected + #else + return socket.connected() + #endif + } + + /// The session ID assigned by the server, or `nil` if not connected. + public var socketId: String? { + #if !SKIP + return socket.sid + #else + return socket.id() + #endif + } + + /// The current connection status. + public var status: SocketIOStatus { + #if !SKIP + switch socket.status { + case .notConnected: return .notConnected + case .disconnected: return .disconnected + case .connecting: return .connecting + case .connected: return .connected + } + #else + if socket.connected() { + return .connected + } else { + return .disconnected + } + #endif + } + + // MARK: Listening for Events + + /// Register a handler for an event. The handler is called every time the event is received. + /// + /// - Parameters: + /// - event: The event name to listen for. + /// - callback: Called with the event data each time the event fires. public func on(_ event: String, callback: @escaping ([Any]) -> ()) { #if !SKIP - socket.on(event) { data, ack in + let id = socket.on(event) { data, ack in callback(data) } + handlerIDs[event] = id #else - socket.on(event, Emitter.Listener { data in + let listener = Emitter.Listener { data in + var dataArray: [Any] = [] + for datum in data { + dataArray.append(datum) + } + callback(dataArray) + } + socket.on(event, listener) + listeners[event] = listener + #endif + } + + /// Register a one-time handler for an event. The handler is called at most once, + /// then automatically removed. + /// + /// - Parameters: + /// - event: The event name to listen for. + /// - callback: Called with the event data when the event fires. + public func once(_ event: String, callback: @escaping ([Any]) -> ()) { + #if !SKIP + socket.once(event) { data, ack in + callback(data) + } + #else + socket.once(event, Emitter.Listener { data in var dataArray: [Any] = [] for datum in data { dataArray.append(datum) @@ -71,8 +272,58 @@ public class SkipSocketIOClient { #endif } - /// https://nuclearace.github.io/Socket.IO-Client-Swift/Classes/SocketIOClient.html#/s:8SocketIO0A8IOClientC4emit__10completionySS_AA0A4Data_pdyycSgtF - /// https://socketio.github.io/socket.io-client-java/apidocs/io/socket/client/Socket.html#emit(java.lang.String,java.lang.Object%5B%5D,io.socket.client.Ack) + /// Remove all handlers for a specific event. + /// + /// - Parameter event: The event name to stop listening for. + public func off(_ event: String) { + #if !SKIP + socket.off(event) + handlerIDs.removeValue(forKey: event) + #else + socket.off(event) + listeners.removeValue(forKey: event) + #endif + } + + /// Remove all event handlers. + public func removeAllHandlers() { + #if !SKIP + socket.removeAllHandlers() + handlerIDs.removeAll() + #else + socket.off() + listeners.removeAll() + #endif + } + + /// Register a catch-all handler that is called for every incoming event. + /// + /// - Parameter handler: Called with the event name and data for every received event. + public func onAny(_ handler: @escaping (String, [Any]) -> ()) { + #if !SKIP + socket.onAny { event in + handler(event.event, event.items ?? []) + } + #else + // SKIP INSERT: + // socket.onAnyIncoming(Emitter.Listener { args -> + // if (args.isNotEmpty()) { + // val eventName = args[0].toString() + // val data = skip.lib.Array(args.drop(1)) + // handler(eventName, data) + // } + // }) + #endif + } + + // MARK: Emitting Events + + /// Emit an event with data to the server. + /// + /// - Parameters: + /// - event: The event name. + /// - items: The data to send. Supports strings, numbers, dictionaries, arrays, and `Data`. + /// - completion: Called after the event is sent (not an acknowledgement from the server). public func emit(_ event: String, _ items: [Any], completion: @escaping () -> () = { }) { #if !SKIP socket.emit(event, with: items.compactMap({ $0 as? SocketData }), completion: { @@ -85,12 +336,39 @@ public class SkipSocketIOClient { }) #endif } + + /// Emit an event and wait for an acknowledgement from the server. + /// + /// - Parameters: + /// - event: The event name. + /// - items: The data to send. + /// - ackCallback: Called with the acknowledgement data from the server. + public func emitWithAck(_ event: String, _ items: [Any], ackCallback: @escaping ([Any]) -> ()) { + #if !SKIP + socket.emitWithAck(event, with: items.compactMap({ $0 as? SocketData })).timingOut(after: 0) { data in + ackCallback(data) + } + #else + let args = items.kotlin().toTypedArray() + socket.emit(event, args, Ack { ackData in + var result: [Any] = [] + for item in ackData { + result.append(item) + } + ackCallback(result) + }) + #endif + } } -/// Wrapper for [`SocketIOClientOption`](https://nuclearace.github.io/Socket.IO-Client-Swift/Enums/SocketIOClientOption.html) -/// for bridging to [`SocketOptionBuilder`](https://socketio.github.io/socket.io-client-java/apidocs/io/socket/client/SocketOptionBuilder.html) +// MARK: - SkipSocketIOClientOption + +/// Configuration options for a Socket.IO connection. /// -/// Note that some options are currently ignored on the Java side as they are not implemented in the client. +/// Wraps [`SocketIOClientOption`](https://nuclearace.github.io/Socket.IO-Client-Swift/Enums/SocketIOClientOption.html) +/// on iOS and [`SocketOptionBuilder`](https://socketio.github.io/socket.io-client-java/apidocs/io/socket/client/SocketOptionBuilder.html) on Android. +/// +/// Note that some options are currently ignored on the Android side as they are not implemented in the Java client. public enum SkipSocketIOClientOption { /// If given, the WebSocket transport will attempt to use compression. case compress @@ -98,9 +376,6 @@ public enum SkipSocketIOClientOption { /// A dictionary of GET parameters that will be included in the connect url. case connectParams([String: Any]) - /// An array of cookies that will be sent during the initial connection. - //case cookies([HTTPCookie]) - /// Any extra HTTP headers that should be sent during the initial connection. case extraHeaders([String: String]) @@ -117,18 +392,9 @@ public enum SkipSocketIOClientOption { /// If passed `true`, the WebSocket stream will be configured with the enableSOCKSProxy `true`. case enableSOCKSProxy(Bool) - /// The queue that all interaction with the client should occur on. This is the queue that event handlers are - /// called on. - /// - /// **This should be a serial queue! Concurrent queues are not supported and might cause crashes and races**. - //case handleQueue(DispatchQueue) - /// If passed `true`, the client will log debug information. This should be turned off in production code. case log(Bool) - /// Used to pass in a custom logger. - //case logger(SocketLogger) - /// A custom path to socket.io. Only use this if the socket.io server is configured to look for this path. case path(String) @@ -136,7 +402,7 @@ public enum SkipSocketIOClientOption { /// over when reconnects happen. case reconnects(Bool) - /// The number of times to try and reconnect before giving up. Pass `-1` to [never give up](https://www.youtube.com/watch?v=dQw4w9WgXcQ). + /// The number of times to try and reconnect before giving up. Pass `-1` to never give up. case reconnectAttempts(Int) /// The minimum number of seconds to wait before reconnect attempts. @@ -151,20 +417,11 @@ public enum SkipSocketIOClientOption { /// Set `true` if your server is using secure transports. case secure(Bool) - /// Allows you to set which certs are valid. Useful for SSL pinning. - //case security(CertificatePinning) - /// If you're using a self-signed set. Only use for development. case selfSigned(Bool) - /// Sets an NSURLSessionDelegate for the underlying engine. Useful if you need to handle self-signed certs. - //case sessionDelegate(URLSessionDelegate) - - /// If passed `false`, the WebSocket stream will be configured with the useCustomEngine `false`. - //case useCustomEngine(Bool) - - /// The version of socket.io being used. This should match the server version. Default is 3. - //case version(SocketIOVersion) + /// Authentication payload sent when connecting. + case auth([String: Any]) #if !SKIP fileprivate func toSocketIOClientOption() -> SocketIOClientOption { @@ -185,8 +442,7 @@ public enum SkipSocketIOClientOption { case .randomizationFactor(let arg): return .randomizationFactor(arg) case .secure(let arg): return .secure(arg) case .selfSigned(let arg): return .selfSigned(arg) - //case .sessionDelegate(let arg): return .sessionDelegate(arg) - //case .useCustomEngine(let arg): return .useCustomEngine(arg) + case .auth(let arg): return .connectParams(arg) } } #else @@ -219,8 +475,7 @@ public enum SkipSocketIOClientOption { case .randomizationFactor(let arg): builder = builder.setRandomizationFactor(arg) case .secure(let arg): builder = builder.setSecure(arg) case .selfSigned(let arg): builder = builder - //case .sessionDelegate(let arg): builder = builder - //case .useCustomEngine(let arg): builder = builder + case .auth(let arg): builder = builder // auth handled at connect time on Java } return builder diff --git a/Tests/SkipSocketIOTests/SkipSocketIOTests.swift b/Tests/SkipSocketIOTests/SkipSocketIOTests.swift index fdd618d..db49096 100644 --- a/Tests/SkipSocketIOTests/SkipSocketIOTests.swift +++ b/Tests/SkipSocketIOTests/SkipSocketIOTests.swift @@ -39,4 +39,104 @@ final class SkipSocketIOTests: XCTestCase { socket.disconnect() } + + func testSocketIOStatus() throws { + let statuses: [SocketIOStatus] = [.notConnected, .connecting, .connected, .disconnected] + XCTAssertEqual(statuses.count, 4) + XCTAssertEqual(SocketIOStatus.notConnected.rawValue, "notConnected") + XCTAssertEqual(SocketIOStatus.connected.rawValue, "connected") + } + + func testSocketIOEvents() throws { + XCTAssertEqual(SocketIOEvent.connect, "connect") + XCTAssertEqual(SocketIOEvent.disconnect, "disconnect") + XCTAssertEqual(SocketIOEvent.connectError, "connect_error") + } + + func testClientOptions() throws { + // Verify all option cases can be constructed + let options: [SkipSocketIOClientOption] = [ + .compress, + .connectParams(["key": "value"]), + .extraHeaders(["X-Header": "val"]), + .forceNew(true), + .forcePolling(false), + .forceWebsockets(true), + .enableSOCKSProxy(false), + .log(true), + .path("/custom"), + .reconnects(true), + .reconnectAttempts(10), + .reconnectWait(1), + .reconnectWaitMax(30), + .randomizationFactor(0.5), + .secure(true), + .selfSigned(false), + .auth(["token": "abc123"]), + ] + XCTAssertEqual(options.count, 17) + } + + func testOnceAndOff() throws { + let socket = SkipSocketIOClient(socketURL: URL(string: "https://example.org")!, options: [ + .forceNew(true), + ]) + + var onceCallCount = 0 + socket.once("testEvent") { _ in + onceCallCount += 1 + } + + socket.on("anotherEvent") { _ in } + socket.off("anotherEvent") + + socket.removeAllHandlers() + + // Can't verify callbacks fire without a server, but we verify no crash + XCTAssertTrue(true) + } + + func testManagerAndNamespaces() throws { + let manager = SkipSocketIOManager(socketURL: URL(string: "https://example.org")!, options: [ + .reconnects(false), + ]) + + let defaultSocket = manager.defaultSocket() + XCTAssertNotNil(defaultSocket) + + let chatSocket = manager.socket(forNamespace: "/chat") + XCTAssertNotNil(chatSocket) + } + + func testConnectionStatus() throws { + let socket = SkipSocketIOClient(socketURL: URL(string: "https://example.org")!) + // Before connecting, should not be connected + XCTAssertFalse(socket.isConnected) + XCTAssertNil(socket.socketId) + } + + func testEmitWithAck() throws { + let socket = SkipSocketIOClient(socketURL: URL(string: "https://example.org")!, options: [ + .forceNew(true), + ]) + + // Just verify the method can be called without crashing + // Actual ack responses require a running server + socket.emitWithAck("ping", ["hello"]) { ackData in + logger.log("received ack: \(ackData)") + } + + socket.disconnect() + } + + func testOnAny() throws { + let socket = SkipSocketIOClient(socketURL: URL(string: "https://example.org")!) + + socket.onAny { eventName, data in + logger.log("event: \(eventName) data: \(data)") + } + + // Verify no crash + socket.disconnect() + } }