259 lines
8.9 KiB
Plaintext
259 lines
8.9 KiB
Plaintext
/*
|
||
* 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 (_) {
|
||
()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 后台读循环:数据帧 → onMessage;close 帧 → 回执并 onClose;EOF/异常 → 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
|
||
}
|
||
}
|