|
|
|
@@ -11,7 +11,10 @@
|
|
|
|
|
* - onError(errorMessage)
|
|
|
|
|
*/
|
|
|
|
|
import { getApiConfig } from "@/core/net/config/api_config";
|
|
|
|
|
import { AppEnvUtil } from "@/utils";
|
|
|
|
|
import { AppEnvUtil, Logger } from "@/utils";
|
|
|
|
|
|
|
|
|
|
const log = new Logger("ChatWebSocket");
|
|
|
|
|
const RECONNECT_DELAY_MS = 3000;
|
|
|
|
|
|
|
|
|
|
export interface SentencePayload {
|
|
|
|
|
index: number;
|
|
|
|
@@ -36,6 +39,7 @@ export interface PaywallStatusPayload {
|
|
|
|
|
export class ChatWebSocket {
|
|
|
|
|
private ws: WebSocket | null = null;
|
|
|
|
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
private connectionUrl: string | null = null;
|
|
|
|
|
private disposed = false;
|
|
|
|
|
|
|
|
|
|
onConnected: ((userId: string) => void) | null = null;
|
|
|
|
@@ -62,25 +66,53 @@ export class ChatWebSocket {
|
|
|
|
|
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) return;
|
|
|
|
|
try {
|
|
|
|
|
const url = buildChatWebSocketUrl(this.serverUrl, this.token);
|
|
|
|
|
this.connectionUrl = url;
|
|
|
|
|
logWebSocketDebug(`↔ WS CONNECT ${redactWebSocketUrl(url)}`);
|
|
|
|
|
this.ws = new WebSocket(url);
|
|
|
|
|
this.ws.onopen = () => {
|
|
|
|
|
logWebSocketDebug(`↔ WS OPEN ${redactWebSocketUrl(url)}`);
|
|
|
|
|
// 连接成功;userId 由后端在第一条消息中下发
|
|
|
|
|
};
|
|
|
|
|
this.ws.onmessage = (e) => this.handleMessage(e.data);
|
|
|
|
|
this.ws.onerror = () => this.onError?.("WebSocket error");
|
|
|
|
|
this.ws.onclose = () => {
|
|
|
|
|
this.ws.onmessage = (e) => {
|
|
|
|
|
logWebSocketFrame("← WS MESSAGE", url, e.data);
|
|
|
|
|
this.handleMessage(e.data);
|
|
|
|
|
};
|
|
|
|
|
this.ws.onerror = () => {
|
|
|
|
|
logWebSocketError(`✕ WS ERROR ${redactWebSocketUrl(url)}`);
|
|
|
|
|
this.onError?.("WebSocket error");
|
|
|
|
|
};
|
|
|
|
|
this.ws.onclose = (event) => {
|
|
|
|
|
logWebSocketDebug(`↔ WS CLOSE ${redactWebSocketUrl(url)}`, {
|
|
|
|
|
code: event.code,
|
|
|
|
|
reason: event.reason,
|
|
|
|
|
wasClean: event.wasClean,
|
|
|
|
|
reconnectInMs: this.disposed ? null : RECONNECT_DELAY_MS,
|
|
|
|
|
});
|
|
|
|
|
if (!this.disposed) {
|
|
|
|
|
this.reconnectTimer = setTimeout(() => this.connect(), 3000);
|
|
|
|
|
this.reconnectTimer = setTimeout(
|
|
|
|
|
() => this.connect(),
|
|
|
|
|
RECONNECT_DELAY_MS,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
} catch (e) {
|
|
|
|
|
logWebSocketError("✕ WS CONNECT FAILED", e);
|
|
|
|
|
this.onError?.(e instanceof Error ? e.message : String(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sendMessage(content: string): boolean {
|
|
|
|
|
if (!this.isConnected) return false;
|
|
|
|
|
this.ws?.send(JSON.stringify({ type: "message", content }));
|
|
|
|
|
const payload = { type: "message", content };
|
|
|
|
|
if (!this.isConnected) {
|
|
|
|
|
logWebSocketWarn("→ WS SEND SKIPPED: socket is not open", payload);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
logWebSocketFrame(
|
|
|
|
|
"→ WS SEND",
|
|
|
|
|
this.connectionUrl ?? this.serverUrl,
|
|
|
|
|
payload,
|
|
|
|
|
);
|
|
|
|
|
this.ws?.send(JSON.stringify(payload));
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -92,10 +124,14 @@ export class ChatWebSocket {
|
|
|
|
|
}
|
|
|
|
|
this.ws?.close();
|
|
|
|
|
this.ws = null;
|
|
|
|
|
this.connectionUrl = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private handleMessage(data: unknown): void {
|
|
|
|
|
if (typeof data !== "string") return;
|
|
|
|
|
if (typeof data !== "string") {
|
|
|
|
|
logWebSocketWarn("← WS MESSAGE IGNORED: non-string payload", data);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let payload: {
|
|
|
|
|
type?: string;
|
|
|
|
|
userId?: string;
|
|
|
|
@@ -119,7 +155,8 @@ export class ChatWebSocket {
|
|
|
|
|
};
|
|
|
|
|
try {
|
|
|
|
|
payload = JSON.parse(data);
|
|
|
|
|
} catch {
|
|
|
|
|
} catch (e) {
|
|
|
|
|
logWebSocketError("← WS MESSAGE PARSE FAILED", e);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
switch (payload.type) {
|
|
|
|
@@ -175,6 +212,59 @@ function buildChatWebSocketUrl(serverUrl: string, token: string): string {
|
|
|
|
|
return `${serverUrl}?token=${encodeURIComponent(token)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function redactWebSocketUrl(url: string): string {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = new URL(url);
|
|
|
|
|
if (parsed.searchParams.has("token")) {
|
|
|
|
|
parsed.searchParams.set("token", "<redacted>");
|
|
|
|
|
}
|
|
|
|
|
return parsed.toString();
|
|
|
|
|
} catch {
|
|
|
|
|
return url.replace(/([?&]token=)[^&]+/i, "$1<redacted>");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function logWebSocketFrame(
|
|
|
|
|
label: string,
|
|
|
|
|
url: string,
|
|
|
|
|
payload: unknown,
|
|
|
|
|
): void {
|
|
|
|
|
if (AppEnvUtil.isProduction()) return;
|
|
|
|
|
const body = formatWebSocketLogValue(payload);
|
|
|
|
|
const bodyLabel = label.startsWith("→") ? "request body" : "response body";
|
|
|
|
|
log.debug(
|
|
|
|
|
`${label} ${redactWebSocketUrl(url)}${body ? `\n${bodyLabel}:\n${body}` : ""}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function logWebSocketDebug(message: string, payload?: unknown): void {
|
|
|
|
|
if (AppEnvUtil.isProduction()) return;
|
|
|
|
|
const body = formatWebSocketLogValue(payload);
|
|
|
|
|
log.debug(`${message}${body ? `\nbody:\n${body}` : ""}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function logWebSocketWarn(message: string, payload?: unknown): void {
|
|
|
|
|
if (AppEnvUtil.isProduction()) return;
|
|
|
|
|
const body = formatWebSocketLogValue(payload);
|
|
|
|
|
log.warn(`${message}${body ? `\nbody:\n${body}` : ""}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function logWebSocketError(message: string, payload?: unknown): void {
|
|
|
|
|
if (AppEnvUtil.isProduction()) return;
|
|
|
|
|
const body = formatWebSocketLogValue(payload);
|
|
|
|
|
log.error(`${message}${body ? `\nbody:\n${body}` : ""}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatWebSocketLogValue(payload: unknown): string {
|
|
|
|
|
if (payload instanceof Error) {
|
|
|
|
|
return Logger.formatValue({
|
|
|
|
|
message: payload.message,
|
|
|
|
|
stack: payload.stack,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return Logger.formatValue(payload);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 默认创建实例:从 env 读取 WS URL。 */
|
|
|
|
|
export function createChatWebSocket(token: string): ChatWebSocket {
|
|
|
|
|
const base = getApiConfig().wsUrl;
|
|
|
|
|