feat(chat): log websocket traffic
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user