Files
websocket-cj/src/client/WebSocketClient.cj
T

259 lines
8.9 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* simcu::websocket.client —— 事件回调式 WebSocket 客户端(RFC 6455)。
*
* 用法:
* let client = WebSocketClient("127.0.0.1", 8080)
* client.onOpen = { println("已连接") }
* client.onMessage = { (m) => println(m.text) }
* client.onClose = { (code, reason) => println("关闭: ${code} ${reason}") }
* client.connect()
* client.sendText("hello")
* client.close() // 或 client.terminate() 立即断开
*
* 说明:
* - connect() 抛出的异常表示握手失败;连接建立后的错误走 onError。
* - close(code, reason) 发起 RFC 6455 关闭握手(等待对端回 close 帧,5 秒兜底);
* terminate() 直接断开底层连接。
* - 回调在后台读线程触发,禁止在回调中直接做耗时操作。
*/
package simcu::websocket.client
import std.net.TcpSocket
import std.time.*
import std.sync.Mutex
import simcu::websocket.common.FrameStream
import simcu::websocket.common.MessageType
import simcu::websocket.common.ReadyState
import simcu::websocket.common.WebSocketMessage
import simcu::websocket.common.WebSocketException
/// 事件回调式 WebSocket 客户端。
public class WebSocketClient {
private let host: String
private let port: UInt16
private let path: String
private let maxPayload: Int64
private var frame: ?FrameStream = None
private var state: ReadyState = ReadyState.Closed
private let stateLock = Mutex()
/// 连接建立(握手完成)后触发,无参数。
public var onOpen: () -> Unit = { => () }
/// 收到文本/二进制消息时触发,参数为消息。
public var onMessage: (WebSocketMessage) -> Unit = { m => () }
/// 连接建立后的运行时错误触发,参数为异常。
public var onError: (Exception) -> Unit = { e => () }
/// 连接关闭时触发,参数为 (关闭码, 关闭原因)。
public var onClose: (Int64, String) -> Unit = { c, r => () }
/// @param host 服务端主机(IP 或域名)。
/// @param port 服务端端口。
/// @param path 请求路径,默认 "/"。
/// @param maxPayload 单条消息最大字节数,默认 64KB,超出触发 onError(code=1009)。
public init(host: String, port: UInt16, path!: String = "/", maxPayload!: Int64 = 65536) {
this.host = host
this.port = port
this.path = path
this.maxPayload = maxPayload
}
/// 当前连接状态(Connecting/Open/Closing/Closed)。
public prop readyState: ReadyState {
get() { state }
}
/// 连接是否处于 Open。
public func isOpen(): Bool {
state == ReadyState.Open
}
/// 建立连接:TCP + WebSocket 握手。
/// 成功 → 状态 Open 并触发 onOpen,随后后台线程持续读取消息。
/// 失败(网络/握手被拒)→ 抛出 WebSocketException。
public func connect(timeout!: ?Duration = None): Unit {
if (state != ReadyState.Closed) {
throw WebSocketException("重复 connect:当前 readyState=${state}")
}
state = ReadyState.Connecting
let sock = TcpSocket(host, port)
sock.connect(timeout: timeout)
let fs = FrameStream(sock, maxPayload, true)
// 发送握手请求
let key = FrameStream.generateWebSocketKey()
let req = StringBuilder()
req.append("GET ${path} HTTP/1.1\r\n")
req.append("Host: ${host}:${port}\r\n")
req.append("Upgrade: websocket\r\n")
req.append("Connection: Upgrade\r\n")
req.append("Sec-WebSocket-Key: ${key}\r\n")
req.append("Sec-WebSocket-Version: 13\r\n\r\n")
sock.write(req.toString().toArray())
sock.flush()
// 读取并校验握手响应
let response = fs.readHttpHeader()
if (!validateHandshake(response, key)) {
fs.close()
sock.close()
state = ReadyState.Closed
throw WebSocketException("WebSocket 握手失败(服务端拒绝升级)")
}
frame = Some(fs)
state = ReadyState.Open
onOpen()
spawn { => readLoop() }
}
/// 发送消息(按消息类型自动选择 text/binary 帧)。
public func send(message: WebSocketMessage): Unit {
let f = frame.getOrThrow()
if (message.`type` == MessageType.Text) {
f.writeFrame(1, message.bytes)
} else {
f.writeFrame(2, message.bytes)
}
}
/// 发送文本消息。
public func sendText(text: String): Unit {
frame.getOrThrow().writeText(text)
}
/// 发送二进制消息。
public func sendBinary(data: Array<Byte>): Unit {
frame.getOrThrow().writeBinary(data)
}
/// 发送 Ping(心跳保活),对端 Pong 由帧层自动忽略。
public func ping(): Unit {
frame.getOrThrow().writePing(Array<Byte>(0, repeat: 0))
}
/// 发送 Ping(携带 payload)。
public func ping(payload: Array<Byte>): Unit {
frame.getOrThrow().writePing(payload)
}
/// 发起关闭握手:发送 close 帧(code + reason),等待对端回执后触发 onClose。
/// 对端 5 秒内不回执则强制断开。
public func close(code!: Int64 = 1000, reason!: String = ""): Unit {
synchronized(stateLock) {
if (state != ReadyState.Open) {
return
}
state = ReadyState.Closing
}
if (let Some(f) <- frame) {
try {
f.writeClose(code, reason)
} catch (_) {
terminate()
return
}
}
spawn { =>
sleep(Duration.second * 5)
if (state == ReadyState.Closing) {
terminate()
}
}
}
/// 立即断开底层连接(不发送 close 帧),读线程会触发 onClose(1006, "")。
public func terminate(): Unit {
synchronized(stateLock) {
if (state == ReadyState.Closed) {
return
}
state = ReadyState.Closed
}
if (let Some(f) <- frame) {
try {
f.close()
} catch (_) {
()
}
}
}
/// 后台读循环:数据帧 → onMessageclose 帧 → 回执并 onCloseEOF/异常 → onError(仅异常)+onClose。
private func readLoop(): Unit {
var code: Int64 = 1006
var reason: String = ""
try {
while (true) {
let m = frame.getOrThrow().readMessage()
match (m) {
case None =>
break // EOF:对端直接断连
case Some(f) =>
if (f.opcode == 8) {
let (c, r) = FrameStream.parseClosePayload(f.payload)
code = c
reason = r
if (state == ReadyState.Open) {
try {
frame.getOrThrow().writeClose(c, r)
} catch (_) {
()
}
}
break
} else {
let msg = if (f.opcode == 1) {
WebSocketMessage(MessageType.Text, f.payload)
} else {
WebSocketMessage(MessageType.Binary, f.payload)
}
onMessage(msg)
}
}
}
} catch (e: Exception) {
code = 1006
if (state != ReadyState.Closing) {
onError(e)
}
}
synchronized(stateLock) {
state = ReadyState.Closed
}
if (let Some(f) <- frame) {
try {
f.close()
} catch (_) {
()
}
}
onClose(code, reason)
}
/// 校验服务端握手响应:状态行 101 + Upgrade/Connection/Accept 头。
private func validateHandshake(response: String, key: String): Bool {
if (!response.startsWith("HTTP/1.1 101")) {
return false
}
let expected = FrameStream.computeAccept(key)
var foundUpgrade = false
var foundAccept = false
let lines = response.split("\r\n")
for (line in lines) {
let ci = line.indexOf(":")
if (let Some(c) <- ci) {
let name = line[0..c].toAsciiLower()
let value = line[c + 1..].trimAscii()
if (name == "upgrade" && value.toAsciiLower().contains("websocket")) {
foundUpgrade = true
}
if (name == "sec-websocket-accept" && value == expected) {
foundAccept = true
}
}
}
foundUpgrade && foundAccept
}
}