diff --git a/packages/collaboration/src/websocket/core/websocket.ts b/packages/collaboration/src/websocket/core/websocket.ts index 14e5e77d921..e9bcfc73a3e 100644 --- a/packages/collaboration/src/websocket/core/websocket.ts +++ b/packages/collaboration/src/websocket/core/websocket.ts @@ -31,6 +31,26 @@ import { type UrlResolver, } from './websocket-url-resolver'; +/** Frame shape for the deserialize-failure log, without reading the bytes. */ +function describeFrameData(data: unknown): string { + if (typeof data === 'string') return `string of ${data.length} chars`; + if (data instanceof ArrayBuffer) + return `ArrayBuffer of ${data.byteLength} bytes`; + if (typeof Blob !== 'undefined' && data instanceof Blob) { + return `Blob of ${data.size} bytes`; + } + if (ArrayBuffer.isView(data)) { + return `${data.constructor.name} of ${data.byteLength} bytes`; + } + return typeof data; +} + +/** Strips the query (auth tokens live there) so the URL is safe to log. */ +function redactUrl(url: string): string { + const queryStart = url.indexOf('?'); + return queryStart === -1 ? url : url.slice(0, queryStart); +} + /** * A websocket wrapper that can be configured to reconnect automatically and buffer messages when the websocket is not connected. */ @@ -588,13 +608,32 @@ export class Websocket { this.dispatchEvent(type, event); }) .with({ type: WebsocketEvent.Message }, ({ event }) => { - const data = deserializeIfNeeded(event.data, this._options.serializer); - const newEvent = new MessageEvent('message', { - data, - origin: event.origin, - lastEventId: event.lastEventId, - source: event.source, - }); + let data: Receive | WebsocketData; + try { + data = deserializeIfNeeded(event.data, this._options.serializer); + } catch (error) { + console.error( + `websocket: failed to deserialize incoming message (${describeFrameData(event.data)}) from ${redactUrl(this._url)}:`, + error + ); + throw error; + } + // Copying origin/lastEventId/source can throw on runtimes with + // stricter Event internals (Cloudflare Workers): the accessors or the + // MessageEvent init validation reject non-browser shapes, and a throw + // here escapes into the native dispatch and errors out the socket. + // Consumers only read `data`, so fall back to a data-only event. + let newEvent: MessageEvent; + try { + newEvent = new MessageEvent('message', { + data, + origin: event.origin, + lastEventId: event.lastEventId, + source: event.source, + }); + } catch { + newEvent = new MessageEvent('message', { data }); + } this.dispatchEvent(WebsocketEvent.Message, newEvent); }) .with(