@@ -2,45 +2,236 @@
import { useLoadingStore } from "@/stores/loadingStore" ;
import { io , Socket as SocketIOClient } from "socket.io-client" ;
interface SocketOptions {
reconnectionAttempts? : number ; // 最大重连次数
reconnectionDelay? : number ; // 重连延迟时间(ms)
timeout? : number ; // 连接超时时间(ms)
autoConnect? : boolean ; // 是否自动连接
forceNew? : boolean ; // 是否强制创建新连接
transports? : string [ ] ; // 传输方式
// ─── 会话加密接口 ───────────────────────────────────────────────
export interface SessionCrypto {
aesKey : CryptoKey ; // AES-128-GCM, 不可导出
}
// ─── AES-GCM 加密 / 解密 ───────────────────────────────────────
async function encryptPayload ( plain : string , aesKey : CryptoKey ) : Promise < string > {
const iv = crypto . getRandomValues ( new Uint8Array ( 12 ) ) ;
const encoded = new TextEncoder ( ) . encode ( plain ) ;
const cipher = await crypto . subtle . encrypt ( { name : "AES-GCM" , iv } , aesKey , encoded ) ;
const out = new Uint8Array ( iv . byteLength + cipher . byteLength ) ;
out . set ( iv , 0 ) ;
out . set ( new Uint8Array ( cipher ) , iv . byteLength ) ;
let binary = "" ;
for ( let i = 0 ; i < out . length ; i ++ ) binary += String . fromCharCode ( out [ i ] ) ;
return JSON . stringify ( { data : btoa ( binary ) } ) ;
}
async function decryptPayload ( raw : unknown , aesKey : CryptoKey ) : Promise < string > {
const rawStr =
typeof raw === "string" ? raw :
raw && typeof raw === "object" ? JSON . stringify ( raw ) : String ( raw ? ? "" ) ;
let envelope : { data? : string } ;
try { envelope = JSON . parse ( rawStr ) ; } catch { return rawStr ; }
if ( ! envelope ? . data ) return rawStr ;
const bytes = Uint8Array . from ( atob ( envelope . data ) , c = > c . charCodeAt ( 0 ) ) ;
const iv = bytes . slice ( 0 , 12 ) ;
const cipher = bytes . slice ( 12 ) ;
try {
const plain = await crypto . subtle . decrypt ( { name : "AES-GCM" , iv } , aesKey , cipher ) ;
return new TextDecoder ( ) . decode ( plain ) ;
} catch {
return rawStr ;
}
}
// ─── ECDH 密钥协商工具 ─────────────────────────────────────────
/**
* 生成 P-256 临时密钥对,返回 { keyPair, clientPublicKeyB64 }
*/
export async function generateECDHKeyPair ( ) : Promise < {
keyPair : CryptoKeyPair ;
clientPublicKeyB64 : string ;
} > {
const keyPair = await crypto . subtle . generateKey (
{ name : "ECDH" , namedCurve : "P-256" } ,
true ,
[ "deriveBits" ]
) ;
const pubKeyRaw = await crypto . subtle . exportKey ( "raw" , keyPair . publicKey ) ;
const clientPublicKeyB64 = btoa (
Array . from ( new Uint8Array ( pubKeyRaw ) ) . map ( b = > String . fromCharCode ( b ) ) . join ( "" )
) ;
return { keyPair , clientPublicKeyB64 } ;
}
/**
* 用服务端公钥( base64 raw P-256) 与给定的客户端私钥推导 AES-128-GCM 会话密钥
*/
export async function deriveSessionKey (
serverPublicKeyB64 : string ,
clientPrivateKey : CryptoKey
) : Promise < SessionCrypto > {
const serverPubKeyBytes = Uint8Array . from ( atob ( serverPublicKeyB64 ) , c = > c . charCodeAt ( 0 ) ) ;
const serverPublicKey = await crypto . subtle . importKey (
"raw" , serverPubKeyBytes ,
{ name : "ECDH" , namedCurve : "P-256" } , false , [ ]
) ;
const sharedBits = await crypto . subtle . deriveBits (
{ name : "ECDH" , public : serverPublicKey } , clientPrivateKey , 256
) ;
const hkdfKey = await crypto . subtle . importKey ( "raw" , sharedBits , "HKDF" , false , [ "deriveKey" ] ) ;
const aesKey = await crypto . subtle . deriveKey (
{
name : "HKDF" , hash : "SHA-256" ,
salt : new Uint8Array ( 32 ) ,
info : new TextEncoder ( ) . encode ( "socket-aes-key" ) ,
} ,
hkdfKey ,
{ name : "AES-GCM" , length : 128 } ,
false ,
[ "encrypt" , "decrypt" ]
) ;
return { aesKey } ;
}
/** 断线/握手阶段队列最大长度,防止内存无限增长 */
const MAX_QUEUE_SIZE = 200 ;
class Socket {
url : string ;
private token : string ;
private sessionCrypto : SessionCrypto | null ;
private ecdhKeyPair : CryptoKeyPair | null = null ;
private clientPublicKeyB64 : string | null = null ;
/** 握手全部完成( ECDH + login) 后才为 true, 期间消息也入队 */
private isReady = false ;
socket : SocketIOClient | null = null ;
listeners : { [ key : string ] : Function [ ] } = { } ;
private messageQueue : any [ ] = [ ] ; // 断连时 暂存消息的简化 队列
private messageQueue : any [ ] = [ ] ; // 断连/握手期间 暂存消息的队列
constructor ( url : string ) {
constructor ( url : string , token = "" , sessionCrypto : SessionCrypto | null = null ) {
this . url = url ;
this . token = token ;
this . sessionCrypto = sessionCrypto ;
this . init ( ) ;
this . setupVisibilityListener ( ) ;
}
/** 懒初始化 ECDH 密钥对(只生成一次,重连时复用) */
private async initECDH() {
if ( ! this . ecdhKeyPair ) {
const { keyPair , clientPublicKeyB64 } = await generateECDHKeyPair ( ) ;
this . ecdhKeyPair = keyPair ;
this . clientPublicKeyB64 = clientPublicKeyB64 ;
}
}
/**
* 通过 Socket.IO key_exchange 事件与服务端协商会话密钥。
* 每次 connect( 包括服务端重启后重连) 都调用, 确保密钥始终有效。
*/
private performKeyExchange ( ) : Promise < void > {
return new Promise < void > ( ( resolve ) = > {
// 3 秒超时:若服务端不响应则无加密继续
const timeout = setTimeout ( ( ) = > {
this . socket ? . off ( 'key_exchange_result' , onResult ) ;
this . sessionCrypto = null ;
resolve ( ) ;
} , 3000 ) ;
const onResult = async ( serverPubKeyB64 : string ) = > {
clearTimeout ( timeout ) ;
try {
this . sessionCrypto = await deriveSessionKey ( serverPubKeyB64 , this . ecdhKeyPair ! . privateKey ) ;
} catch ( e ) {
console . error ( '[Socket] key derivation failed:' , e ) ;
this . sessionCrypto = null ;
}
resolve ( ) ;
} ;
this . socket ? . once ( 'key_exchange_result' , onResult ) ;
this . socket ? . emit ( 'key_exchange' , this . clientPublicKeyB64 ) ;
} ) ;
}
/**
* 发送 login 并等待服务端回 {event:"login",content:"success"}。
* 服务端发送 login success 时 client.State 已同步设置完毕,
* 之后再冲刷队列才能保证消息不被 "State == nil" 守卫丢弃。
*/
private async sendLoginAndWait ( ) : Promise < void > {
await new Promise < void > ( ( resolve ) = > {
let settled = false ;
const settle = ( ) = > {
if ( settled ) return ;
settled = true ;
this . socket ? . off ( 'message' , onRawMessage ) ;
clearTimeout ( timer ) ;
resolve ( ) ;
} ;
// 3 秒兜底:即使没收到确认也继续
const timer = setTimeout ( settle , 3000 ) ;
const onRawMessage = async ( raw : unknown ) = > {
try {
let text : string ;
if ( this . sessionCrypto ) {
text = await decryptPayload ( raw , this . sessionCrypto . aesKey ) ;
} else {
text = typeof raw === 'string' ? raw : JSON.stringify ( raw ) ;
}
const parsed = JSON . parse ( text ) ;
if ( parsed ? . event === 'login' ) settle ( ) ;
} catch { /* 忽略解析失败 */ }
} ;
// 先注册监听,再发 login, 避免极速响应漏掉
this . socket ? . on ( 'message' , onRawMessage ) ;
this . sendRaw ( JSON . stringify ( { event : 'login' , content : { token : this.token } , timestamp : Date.now ( ) } ) ) . catch ( ( ) = > settle ( ) ) ;
} ) ;
}
init() {
if ( this . socket ) {
return ;
}
console . log ( "Socket initialized with URL:" , this . url ) ;
this . socket = io ( this . url , {
randomizationFactor : 0.5 ,
path : "/socket.io" ,
query : this.token ? { token : this.token } : undefined ,
reconnectionDelay : 1500 ,
reconnectionAttempts : Infinity , // 服务端重启后持续重连,不放弃
} ) ;
// 连接事件处理
this . socket . on ( 'connect' , ( ) = > {
this . flushMessageQueue ( ) ; // 连接后发送排队消息
this . emit ( 'open' , { type : 'open' } ) ;
// 连接事件处理(含重连):每次都重新做 ECDH, 解决服务端重启后密钥失效问题
this . socket . on ( 'connect' , async ( ) = > {
this . isReady = false ; // 握手期间暂停直接发送,新消息继续入队
// 清理上一次连接残留的 key_exchange_result 监听器,避免多次重连后堆积
this . socket ? . off ( 'key_exchange_result' ) ;
try {
await this . initECDH ( ) ;
await this . performKeyExchange ( ) ;
// 等待服务端 login success 确认后再冲刷队列
// 保证 client.State 已在服务端设置,避免消息被 "State==nil" 守卫丢弃
await this . sendLoginAndWait ( ) ;
} catch ( e ) {
console . error ( '[Socket] 握手阶段异常,将以无加密方式继续:' , e ) ;
this . sessionCrypto = null ;
} finally {
// 无论握手是否成功,都必须就绪并冲刷队列,避免消息永久滞留
this . isReady = true ;
await this . flushMessageQueue ( ) ; // 连接后按序发送排队消息
this . emit ( 'open' , { type : 'open' } ) ;
}
} ) ;
// 消息接收
this . socket . on ( 'message' , ( data ) = > {
this . emit ( 'message' , data ) ;
// 消息接收(支持 AES-GCM 解密)
this . socket . on ( 'message' , async ( data ) = > {
let plainText : string ;
if ( this . sessionCrypto ) {
plainText = await decryptPayload ( data , this . sessionCrypto . aesKey ) ;
} else {
plainText = typeof data === 'string' ? data : JSON.stringify ( data ) ;
}
this . emit ( 'message' , plainText ) ;
} ) ;
// 连接错误
@@ -50,6 +241,7 @@ class Socket {
// 断开连接
this . socket . on ( 'disconnect' , ( reason ) = > {
this . isReady = false ; // 断开后消息重新入队
this . emit ( 'close' , { reason } ) ;
} ) ;
@@ -82,10 +274,19 @@ class Socket {
return this . socket ? . connected ? ? false ;
}
/** 用于握手阶段的 login 事件,同样走加密通道 */
private async sendRaw ( data : string ) {
if ( this . sessionCrypto ) {
const encrypted = await encryptPayload ( data , this . sessionCrypto . aesKey ) ;
this . socket ? . emit ( 'message' , encrypted ) ;
} else {
this . socket ? . emit ( 'message' , data ) ;
}
}
async send ( data : string ) {
try {
const payload = JSON . parse ( data ) ;
const event = payload . event || 'message' ;
// 添加时间戳
const messageData = {
@@ -93,24 +294,53 @@ class Socket {
timestamp : payload.timestamp || Date . now ( )
} ;
if ( this . isConnected ( ) ) {
this . socket ? . emit ( "message" , JSON . stringify ( messageData ) ) ;
} else {
// 未连接时,将消息加入队列
this . messageQueue . push ( { event , data : messageData } ) ;
// 未就绪(断连中或 ECDH/login 握手中)时统一入队,保证消息不丢失且顺序正确
if ( ! this . isReady ) {
if ( this . messageQueue . length < MAX_QUEUE_SIZE ) {
this . messageQueue . push ( messageData ) ;
} else {
console . warn ( '[Socket] 消息队列已满,丢弃消息:' , messageData . event ) ;
}
this . reconnectIfNeeded ( ) ;
return ;
}
const serialized = JSON . stringify ( messageData ) ;
if ( this . sessionCrypto ) {
const encrypted = await encryptPayload ( serialized , this . sessionCrypto . aesKey ) ;
this . socket ? . emit ( 'message' , encrypted ) ;
} else {
this . socket ? . emit ( 'message' , serialized ) ;
}
} catch ( error ) {
console . error ( " Invalid message format. Must be a valid JSON string." , error ) ;
console . error ( ' Invalid message format. Must be a valid JSON string.' , error ) ;
}
}
flushMessageQueue() {
if ( this . messageQueue . length > 0 && this . socket ? . connected ) {
this . messageQueue . forEach ( msg = > {
this . socket ? . emit ( "message" , JSON . stringify ( msg ) ) ;
} ) ;
this . messageQueue = [ ] ;
/** 按顺序逐条发送积压消息,保证 FIFO 且不会因并发导致乱序 */
async flushMessageQueue() {
if ( this . messageQueue . length === 0 ) return ;
const queue = this . messageQueue . splice ( 0 ) ; // 原子取出,避免发送期间新消息混入
for ( const msg of queue ) {
if ( ! this . isReady || ! this . socket ? . connected ) {
// 发送途中再次断开,将剩余消息放回队首
this . messageQueue . unshift ( . . . queue . slice ( queue . indexOf ( msg ) ) ) ;
break ;
}
try {
const serialized = JSON . stringify ( msg ) ;
if ( this . sessionCrypto ) {
const encrypted = await encryptPayload ( serialized , this . sessionCrypto . aesKey ) ;
this . socket ? . emit ( 'message' , encrypted ) ;
} else {
this . socket ? . emit ( 'message' , serialized ) ;
}
} catch ( e ) {
console . error ( '[Socket] flushMessageQueue 发送失败:' , e ) ;
// 发送失败也放回队首
this . messageQueue . unshift ( . . . queue . slice ( queue . indexOf ( msg ) ) ) ;
break ;
}
}
}
@@ -121,14 +351,14 @@ class Socket {
}
on ( event : string , callback : Function ) {
// 直接处理的事件
if ( [ 'open' , 'close' , 'error' , 'reconnect' , 'reconnect_attempt' , 'reconnect_failed' ] . includes ( event ) ) {
// 需要经过本层中间件(如解密)的事件,统一走 this.listeners
if ( [ 'open' , 'close' , 'error' , 'reconnect' , 'reconnect_attempt' , 'reconnect_failed' , 'message' ]. includes ( event ) ) {
if ( ! this . listeners [ event ] ) {
this . listeners [ event ] = [ ] ;
}
this . listeners [ event ] . push ( callback ) ;
} else {
// Socket.IO 事件
// 其他 Socket.IO 原生 事件
this . socket ? . on ( event , ( . . . args ) = > callback ( . . . args ) ) ;
}
}
@@ -146,23 +376,24 @@ class Socket {
}
}
setupVisibilityListener() {
const handleVisibilityChange = ( ) = > {
if ( document . visibilityState === "visible" && ! this . isC onnected ( ) && this . socket ) {
this . socket . connect ( ) ;
}
} ;
private handleVisibilityChange = ( ) = > {
if ( document . visibilityState === "visible" && ! this . isConnected ( ) && this . socket ) {
this . socket . c onnect( ) ;
}
} ;
document . addEventListener ( "visibilitychange" , handleVisibilityChange ) ;
setupVisibilityListener() {
document . addEventListener ( "visibilitychange" , this . handleVisibilityChange ) ;
}
disconnect() {
document . removeEventListener ( "visibilitychange" , this . handleVisibilityChange ) ;
this . socket ? . disconnect ( ) ;
}
}
function useSocketIo ( url : string ) {
const socket = new Socket ( url ) ;
function useSocketIo ( url : string , token = "" , sessionCrypto : SessionCrypto | null = null ) {
const socket = new Socket ( url , token , sessionCrypto );
return {
socket ,