Files
multica/packages/core/api/ws-client.ts
Bohan Jiang 8a135d2982 fix(ws): truncate unparseable frame payload in client warn log (#2974)
The post-#2946 onmessage guard logs the raw event.data alongside the
warning. A malformed or rogue server can stream arbitrarily large
garbage and bloat the renderer / desktop main-process log buffers, so
cap the logged payload to the first 200 chars and append a
"(truncated, N chars total)" suffix when truncation occurs.

MUL-2490

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 13:37:42 +08:00

188 lines
5.6 KiB
TypeScript

import type { WSMessage, WSEventType } from "../types/events";
import { type Logger, noopLogger } from "../logger";
type EventHandler = (payload: unknown, actorId?: string, actorType?: string) => void;
// Cap how much of an unparseable frame we put into the log. A malformed or
// rogue server can stream arbitrarily large garbage, and the warn handler may
// be a console / IPC bridge whose buffers we don't want to blow.
const UNPARSEABLE_LOG_MAX_CHARS = 200;
function summarizeUnparseable(data: unknown): string {
const text = typeof data === "string" ? data : String(data);
if (text.length <= UNPARSEABLE_LOG_MAX_CHARS) return text;
return `${text.slice(0, UNPARSEABLE_LOG_MAX_CHARS)}… (truncated, ${text.length} chars total)`;
}
/** Identifies the WS client to the server. Sent as `client_platform`,
* `client_version`, and `client_os` query parameters on the upgrade URL —
* browsers cannot set custom headers on WebSocket handshakes, so query
* params are the only portable channel. */
export interface WSClientIdentity {
platform?: string;
version?: string;
os?: string;
}
export class WSClient {
private ws: WebSocket | null = null;
private baseUrl: string;
private token: string | null = null;
private workspaceSlug: string | null = null;
private cookieAuth = false;
private identity: WSClientIdentity | undefined;
private handlers = new Map<WSEventType, Set<EventHandler>>();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private hasConnectedBefore = false;
private onReconnectCallbacks = new Set<() => void>();
private anyHandlers = new Set<(msg: WSMessage) => void>();
private logger: Logger;
constructor(
url: string,
options?: {
logger?: Logger;
cookieAuth?: boolean;
identity?: WSClientIdentity;
},
) {
this.baseUrl = url;
this.logger = options?.logger ?? noopLogger;
this.cookieAuth = options?.cookieAuth ?? false;
this.identity = options?.identity;
}
setAuth(token: string | null, workspaceSlug: string) {
this.token = token;
this.workspaceSlug = workspaceSlug;
}
connect() {
const url = new URL(this.baseUrl);
// Token is never sent as a URL query parameter — it would be logged by
// proxies, CDNs, and browser history. In cookie mode the HttpOnly cookie
// is sent automatically with the upgrade request. In token mode the token
// is delivered as the first WebSocket message after the connection opens.
if (this.workspaceSlug)
url.searchParams.set("workspace_slug", this.workspaceSlug);
if (this.identity?.platform)
url.searchParams.set("client_platform", this.identity.platform);
if (this.identity?.version)
url.searchParams.set("client_version", this.identity.version);
if (this.identity?.os)
url.searchParams.set("client_os", this.identity.os);
this.ws = new WebSocket(url.toString());
this.ws.onopen = () => {
if (!this.cookieAuth && this.token) {
this.ws!.send(
JSON.stringify({ type: "auth", payload: { token: this.token } }),
);
return;
}
this.onAuthenticated();
};
this.ws.onmessage = (event) => {
let msg: WSMessage;
try {
msg = JSON.parse(event.data as string) as WSMessage;
} catch {
this.logger.warn(
"ws: received unparseable message",
summarizeUnparseable(event.data),
);
return;
}
if ((msg as any).type === "auth_ack") {
this.onAuthenticated();
return;
}
this.logger.debug("received", msg.type);
const eventHandlers = this.handlers.get(msg.type);
if (eventHandlers) {
for (const handler of eventHandlers) {
handler(msg.payload, msg.actor_id, msg.actor_type);
}
}
for (const handler of this.anyHandlers) {
handler(msg);
}
};
this.ws.onclose = () => {
this.logger.warn("disconnected, reconnecting in 3s");
this.reconnectTimer = setTimeout(() => this.connect(), 3000);
};
this.ws.onerror = () => {
// Suppress — onclose handles reconnect; errors during StrictMode
// double-fire are expected in dev and harmless.
};
}
private onAuthenticated() {
this.logger.info("connected");
if (this.hasConnectedBefore) {
for (const cb of this.onReconnectCallbacks) {
try {
cb();
} catch {
// ignore reconnect callback errors
}
}
}
this.hasConnectedBefore = true;
}
disconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
// Remove handlers before close to prevent onclose from scheduling a reconnect
this.ws.onclose = null;
this.ws.onerror = null;
this.ws.close();
this.ws = null;
}
this.hasConnectedBefore = false;
this.handlers.clear();
this.anyHandlers.clear();
this.onReconnectCallbacks.clear();
}
on(event: WSEventType, handler: EventHandler) {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler);
return () => {
this.handlers.get(event)?.delete(handler);
};
}
onAny(handler: (msg: WSMessage) => void) {
this.anyHandlers.add(handler);
return () => {
this.anyHandlers.delete(handler);
};
}
onReconnect(callback: () => void) {
this.onReconnectCallbacks.add(callback);
return () => {
this.onReconnectCallbacks.delete(callback);
};
}
send(message: WSMessage) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
}