Skip to content

Instantly share code, notes, and snippets.

@ajayjapan
Created December 8, 2025 06:25
Show Gist options
  • Select an option

  • Save ajayjapan/b009ae7c43ac84cf67ed6ce1758d001b to your computer and use it in GitHub Desktop.

Select an option

Save ajayjapan/b009ae7c43ac84cf67ed6ce1758d001b to your computer and use it in GitHub Desktop.
A minimal Socket.IO-compatible client built on URLSessionWebSocketTask.
//
// 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