Created
December 8, 2025 06:25
-
-
Save ajayjapan/b009ae7c43ac84cf67ed6ce1758d001b to your computer and use it in GitHub Desktop.
A minimal Socket.IO-compatible client built on URLSessionWebSocketTask.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // LightweightSocketIO.swift | |
| // fantrance | |
| // | |
| // A minimal Socket.IO-compatible client built on URLSessionWebSocketTask. | |
| // It supports basic connect/disconnect, ping/pong, and event emit/on needed | |
| // by FantranceSocketManager without pulling in the SocketIO dependency. | |
| // | |
| // Limitations (vs full Socket.IO client): | |
| // - WebSocket-only: no HTTP long-polling fallback or upgrade flow. | |
| // - Fire-and-forget events only: no emitWithAck/ack support. | |
| // - No binary payload handling. | |
| // - Single namespace (default "/") only. | |
| // - Minimal error reporting; relies on reconnect with backoff and ping timeouts. | |
| // | |
| import Foundation | |
| #if canImport(FoundationNetworking) | |
| import FoundationNetworking | |
| #endif | |
| public enum SocketClientEvent { | |
| case connect | |
| case disconnect | |
| case reconnect | |
| case reconnectAttempt | |
| } | |
| public struct SocketAckEmitter : Sendable { | |
| public func with(_ items: Any...) { } | |
| } | |
| public enum SocketIOClientOption: Sendable { | |
| case log(Bool) | |
| case compress | |
| case connectParams([String: any Sendable]) | |
| case extraHeaders([String: String]) | |
| } | |
| public actor SocketIOClient { | |
| public typealias NormalCallback = @Sendable ([any Sendable], SocketAckEmitter) -> Void | |
| private let url: URL | |
| private let session: URLSession | |
| private var webSocketTask: URLSessionWebSocketTask? | |
| private var isConnected = false | |
| private var pendingMessages: [String] = [] | |
| private var eventHandlers: [String: [(id: UUID, handler: NormalCallback)]] = [:] | |
| private var clientHandlers: [SocketClientEvent: [(id: UUID, handler: NormalCallback)]] = [:] | |
| private var reconnectTask: Task<Void, Never>? | |
| private var reconnectAttempts = 0 | |
| private var manualDisconnect = false | |
| private var pingTimerTask: Task<Void, Never>? | |
| private var lastPingOrPong = Date() | |
| private let pingTimeoutGrace: TimeInterval = 30 // seconds | |
| private let logEnabled: Bool | |
| private let connectParams: [String: any Sendable] | |
| private let extraHeaders: [String: String] | |
| public init( | |
| url: URL, | |
| session: URLSession = .shared, | |
| logEnabled: Bool = false, | |
| connectParams: [String: any Sendable] = [:], | |
| extraHeaders: [String: String] = [:] | |
| ) { | |
| self.url = url | |
| self.logEnabled = logEnabled | |
| self.session = session | |
| self.connectParams = connectParams | |
| self.extraHeaders = extraHeaders | |
| } | |
| // MARK: - Public API | |
| @discardableResult | |
| public func on(_ event: String, callback: @escaping NormalCallback) -> UUID { | |
| let id = UUID() | |
| eventHandlers[event, default: []].append((id, callback)) | |
| return id | |
| } | |
| @discardableResult | |
| public func on(clientEvent event: SocketClientEvent, callback: @escaping NormalCallback) -> UUID { | |
| let id = UUID() | |
| clientHandlers[event, default: []].append((id, callback)) | |
| return id | |
| } | |
| public func connect() { | |
| guard webSocketTask == nil else { return } | |
| manualDisconnect = false | |
| reconnectAttempts = 0 | |
| let request = buildWebSocketRequest(from: url) | |
| let task = session.webSocketTask(with: request) | |
| webSocketTask = task | |
| task.resume() | |
| lastPingOrPong = Date() | |
| startReceiveLoop() | |
| } | |
| public func disconnect() { | |
| sendRaw("41") // socket.io close packet | |
| manualDisconnect = true | |
| handleDisconnect(triggerEvent: true, shouldReconnect: false) | |
| } | |
| public func emit(_ event: String, _ items: any Sendable...) { | |
| emit(event, with: items) | |
| } | |
| public func emit(_ event: String, with items: [any Sendable]) { | |
| sendEvent(event: event, items: items) | |
| } | |
| private func buildWebSocketRequest(from baseURL: URL) -> URLRequest { | |
| var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) ?? URLComponents() | |
| if components.scheme == "http" { | |
| components.scheme = "ws" | |
| } else if components.scheme == "https" { | |
| components.scheme = "wss" | |
| } | |
| var path = components.path | |
| if path.isEmpty || path == "/" { | |
| path = "/socket.io/" | |
| } else if !path.hasSuffix("/") { | |
| path += "/" | |
| } | |
| if !path.contains("socket.io") { | |
| path += "socket.io/" | |
| } | |
| components.path = path | |
| var queryItems = components.queryItems ?? [] | |
| queryItems.append(URLQueryItem(name: "EIO", value: "4")) | |
| queryItems.append(URLQueryItem(name: "transport", value: "websocket")) | |
| for (key, value) in connectParams { | |
| queryItems.append(URLQueryItem(name: key, value: "\(value)")) | |
| } | |
| components.queryItems = queryItems | |
| var req = URLRequest(url: components.url ?? baseURL) | |
| for (header, value) in extraHeaders { | |
| req.setValue(value, forHTTPHeaderField: header) | |
| } | |
| return req | |
| } | |
| private func startReceiveLoop() { | |
| Task { [weak self] in | |
| await self?.readMessage() | |
| } | |
| } | |
| private func readMessage() async { | |
| guard let task = webSocketTask else { return } | |
| do { | |
| let message = try await task.receive() | |
| handle(message: message) | |
| await readMessage() | |
| } catch { | |
| log("receive error: \(error)") | |
| handleDisconnect(triggerEvent: true, shouldReconnect: true) | |
| } | |
| } | |
| private func handle(message: URLSessionWebSocketTask.Message) { | |
| switch message { | |
| case .string(let text): | |
| handleText(text) | |
| case .data(let data): | |
| if let text = String(data: data, encoding: .utf8) { | |
| handleText(text) | |
| } else { | |
| log("received non-UTF8 data message") | |
| } | |
| @unknown default: | |
| log("unknown WebSocket message type") | |
| break | |
| } | |
| } | |
| private func handleText(_ text: String) { | |
| if text == "2" { // ping | |
| lastPingOrPong = Date() | |
| sendRaw("3") // pong | |
| log("recv ping -> pong") | |
| return | |
| } | |
| if text.hasPrefix("0") { // engine open | |
| log("recv engine open") | |
| sendRaw("40") // request/confirm namespace connect | |
| return | |
| } | |
| if text.hasPrefix("40") { // namespace connected | |
| isConnected = true | |
| flushPendingMessages() | |
| reconnectTask?.cancel() | |
| reconnectTask = nil | |
| reconnectAttempts = 0 | |
| lastPingOrPong = Date() | |
| startPingTimeoutMonitor() | |
| log("connected (40) – pending emits: \(pendingMessages.count)") | |
| triggerClientEvent(.connect) | |
| return | |
| } | |
| if text.hasPrefix("41") { // namespace close | |
| handleDisconnect(triggerEvent: true, shouldReconnect: true) | |
| return | |
| } | |
| if text.hasPrefix("42") { // event | |
| log("recv event: \(text)") | |
| handleIncomingEvent(text) | |
| return | |
| } | |
| log("unknown message: \(text)") | |
| } | |
| private func handleIncomingEvent(_ text: String) { | |
| // Format: 42<json> | |
| let payloadString = String(text.dropFirst(2)) | |
| guard | |
| let data = payloadString.data(using: .utf8), | |
| let jsonArray = try? JSONSerialization.jsonObject(with: data) as? [any Sendable], | |
| let eventName = jsonArray.first as? String | |
| else { return } | |
| let payload = Array(jsonArray.dropFirst()) | |
| trigger(eventName, data: payload) | |
| } | |
| private func sendRaw(_ string: String) { | |
| Task { [weak self, webSocketTask] in | |
| do { | |
| try await webSocketTask?.send(.string(string)) | |
| } catch { | |
| await self?.log("WebSocket send error: \(error)") | |
| } | |
| } | |
| } | |
| private func flushPendingMessages() { | |
| guard isConnected else { return } | |
| pendingMessages.forEach { sendRaw($0) } | |
| pendingMessages.removeAll() | |
| } | |
| private func trigger(_ event: String, data: [any Sendable]) { | |
| let callbacks = eventHandlers[event] ?? [] | |
| let ackEmitter = SocketAckEmitter() | |
| for callback in callbacks { | |
| DispatchQueue.main.async { | |
| callback.handler(data, ackEmitter) | |
| } | |
| } | |
| } | |
| private func triggerClientEvent(_ event: SocketClientEvent) { | |
| triggerClientEvent(event, payload: []) | |
| } | |
| private func triggerClientEvent(_ event: SocketClientEvent, payload: [any Sendable]) { | |
| let callbacks = clientHandlers[event] ?? [] | |
| let ack = SocketAckEmitter() | |
| for callback in callbacks { | |
| DispatchQueue.main.async { | |
| callback.handler(payload, ack) | |
| } | |
| } | |
| } | |
| private func handleDisconnect(triggerEvent: Bool, shouldReconnect: Bool = false) { | |
| isConnected = false | |
| webSocketTask?.cancel(with: .normalClosure, reason: nil) | |
| webSocketTask = nil | |
| pingTimerTask?.cancel() | |
| pingTimerTask = nil | |
| if triggerEvent { | |
| triggerClientEvent(.disconnect) | |
| } | |
| guard shouldReconnect, !manualDisconnect else { return } | |
| scheduleReconnect() | |
| } | |
| private func scheduleReconnect() { | |
| guard reconnectTask == nil else { return } | |
| let delay = backoffDelay(attempt: reconnectAttempts) | |
| reconnectAttempts += 1 | |
| triggerClientEvent(.reconnectAttempt, payload: [reconnectAttempts]) | |
| log("reconnect attempt \(reconnectAttempts) in \(String(format: "%.2f", delay))s") | |
| reconnectTask = Task { [weak self] in | |
| try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) | |
| guard let self else { return } | |
| if await self.manualDisconnect { | |
| return | |
| } | |
| await self.log("reconnecting now (attempt \(await self.reconnectAttempts))") | |
| await self.triggerClientEvent(.reconnect) | |
| await self.connect() | |
| await self.clearReconnectTask() | |
| } | |
| } | |
| private func clearReconnectTask() { | |
| reconnectTask = nil | |
| } | |
| private nonisolated func backoffDelay(attempt: Int) -> Double { | |
| let base = 1.0 | |
| let maxDelay = 10.0 | |
| let exp = min(maxDelay, base * pow(1.5, Double(attempt))) | |
| let jitter = Double.random(in: 0...(exp * 0.5)) | |
| return min(maxDelay, exp + jitter) | |
| } | |
| private func startPingTimeoutMonitor() { | |
| pingTimerTask?.cancel() | |
| pingTimerTask = Task { [weak self] in | |
| guard let self else { return } | |
| while !Task.isCancelled { | |
| try? await Task.sleep(nanoseconds: 5 * 1_000_000_000) | |
| let lastPing = await self.lastPingOrPong | |
| let timeout = self.pingTimeoutGrace | |
| if Date().timeIntervalSince(lastPing) > timeout { | |
| await self.handleDisconnect(triggerEvent: true, shouldReconnect: true) | |
| await self.log("ping timeout -> reconnect") | |
| break | |
| } | |
| } | |
| } | |
| } | |
| private func sendEvent(event: String, items: [any Sendable]) { | |
| let payload: [Any] = [event] + items | |
| guard | |
| let data = try? JSONSerialization.data(withJSONObject: payload), | |
| let encoded = String(data: data, encoding: .utf8) | |
| else { return } | |
| let message = "42" + encoded | |
| if isConnected { | |
| sendRaw(message) | |
| } else { | |
| pendingMessages.append(message) | |
| } | |
| } | |
| private func log(_ message: String) { | |
| guard logEnabled else { return } | |
| print("[LightweightSocket] \(message)") | |
| } | |
| } | |
| public final class SocketManager: Sendable { | |
| public let defaultSocket: SocketIOClient | |
| public init(socketURL: URL, config: [SocketIOClientOption] = []) { | |
| var enableLog = false | |
| var connectParams: [String: any Sendable] = [:] | |
| var extraHeaders: [String: String] = [:] | |
| for opt in config { | |
| switch opt { | |
| case let .log(val): | |
| enableLog = val | |
| case let .connectParams(params): | |
| connectParams = params | |
| case let .extraHeaders(headers): | |
| extraHeaders = headers | |
| case .compress: | |
| break | |
| } | |
| } | |
| self.defaultSocket = SocketIOClient( | |
| url: socketURL, | |
| logEnabled: enableLog, | |
| connectParams: connectParams, | |
| extraHeaders: extraHeaders | |
| ) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment