mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 05:49:12 +02:00
* fix(realtime): drop WS frames without a string type (MUL-3418)
The onmessage handler dispatched every parsed frame to onAny and the
ws.on subscribers without validating its shape. A frame whose parsed JSON
lacked a string `type` (an out-of-protocol frame injected by a proxy /
extension, or a bare JSON primitive) reached the realtime-sync onAny
dispatcher, where `msg.type.split(":")` threw an uncaught TypeError out of
onmessage. The browser reported it via window.onerror, flooding PostHog
`$exception` (~12k events). Non-fatal (no crash, connection stays up), but
pure noise.
Validate once at this trust boundary: a frame must be an object carrying a
string `type`, otherwise drop it as a no-op. The bad-frame log is
rate-limited to one entry per connection — a misbehaving source can repeat
the frame hundreds of times per session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(analytics): drop benign ResizeObserver exceptions from telemetry
ResizeObserver "loop ..." errors are the dominant `$exception` bucket
(~31k events / ~1.5k users). They are a benign, self-recovering browser
quirk with no actionable signal and otherwise drown real failures and burn
the event budget. Drop them entirely in before_send (ahead of redaction
and the dedupe fuse, which only caps repeats). The match is narrow — only
the benign "loop" phrasing — so a genuine ResizeObserver bug still reports.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
212 lines
6.9 KiB
TypeScript
212 lines
6.9 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;
|
|
// One-shot per connection. A non-conforming frame can repeat hundreds of
|
|
// times per session, so we log the first drop and suppress the rest. Reset
|
|
// on each connect() so a fresh connection logs once again.
|
|
private badFrameLogged = 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() {
|
|
this.badFrameLogged = false;
|
|
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;
|
|
}
|
|
// Trust boundary: a frame must be an object carrying a string `type`.
|
|
// The server protocol guarantees this for every frame, but a
|
|
// non-conforming frame — an out-of-protocol frame injected by a proxy /
|
|
// browser extension, or a bare JSON primitive — must degrade to a no-op
|
|
// here. Without this guard every downstream consumer (the onAny
|
|
// dispatcher and every ws.on subscriber) runs against a bad shape;
|
|
// `msg.type.split(...)` in the realtime sync threw an uncaught TypeError
|
|
// out of onmessage and surfaced as a flood of global `$exception` events
|
|
// (MUL-3418). Validate once at the boundary, trust the shape downstream.
|
|
if (!msg || typeof (msg as { type?: unknown }).type !== "string") {
|
|
if (!this.badFrameLogged) {
|
|
this.badFrameLogged = true;
|
|
this.logger.warn(
|
|
"ws: dropping frame without a string type",
|
|
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));
|
|
}
|
|
}
|
|
}
|