diff --git a/app/src/lib/channels/use-channel-events.ts b/app/src/lib/channels/use-channel-events.ts index 51958ebb8..ed714cc50 100644 --- a/app/src/lib/channels/use-channel-events.ts +++ b/app/src/lib/channels/use-channel-events.ts @@ -20,10 +20,50 @@ export type ChannelResyncEvent = { resync: true }; /** What arrives on the socket. `resync` is the discriminant; an activity event never carries it. */ export type ChannelSocketMessage = ChannelActivityEvent | ChannelResyncEvent; -export function isResync( - message: ChannelSocketMessage, -): message is ChannelResyncEvent { - return (message as ChannelResyncEvent).resync === true; +export function isResync(message: unknown): message is ChannelResyncEvent { + return ( + typeof message === "object" && + message !== null && + (message as ChannelResyncEvent).resync === true + ); +} + +/** + * Whether a parsed socket payload has the shape of anything this roster handles. + * + * The `try` around `JSON.parse` is not enough: `JSON.parse("null")` succeeds with + * `null`, and `JSON.parse("5")` succeeds with `5`, and both used to reach `isResync` + * — `null.resync` throwing a `TypeError` inside `onmessage` for the first, and a + * spurious roster-wide refetch for the second when the channel id came back + * `undefined`. Binary frames arrive as `Blob` rather than text and never parse. + */ +export function isChannelSocketMessage( + value: unknown, +): value is ChannelSocketMessage { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Parse one socket frame into something the roster can act on, or `null` to drop it. + * + * Pure and exported so the drop rules are provable without a socket: unparseable + * text, non-object JSON (`null`, numbers, strings, arrays) and activity events with + * no string channel id are all ignored rather than crashing or refetching the roster. + */ +export function parseChannelSocketMessage( + data: unknown, +): ChannelSocketMessage | null { + if (typeof data !== "string") return null; + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + return null; + } + if (!isChannelSocketMessage(parsed)) return null; + if (isResync(parsed)) return parsed; + if (typeof parsed.channelId !== "string") return null; + return parsed; } export type ChannelActivityEvent = { @@ -183,12 +223,8 @@ export function useChannelEvents() { }; socket.onmessage = (message) => { - let parsed: ChannelSocketMessage; - try { - parsed = JSON.parse(message.data as string); - } catch { - return; - } + const parsed = parseChannelSocketMessage(message.data); + if (!parsed) return; // Refetch rather than patch: there is no delta to apply. Checked before anything reads // `channelId`, because this message has none. diff --git a/app/tests/channel-socket-message.test.ts b/app/tests/channel-socket-message.test.ts new file mode 100644 index 000000000..7a9330b25 --- /dev/null +++ b/app/tests/channel-socket-message.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test"; +import { + isChannelSocketMessage, + isResync, + parseChannelSocketMessage, +} from "../src/lib/channels/use-channel-events"; + +const activity = { + channelId: "a", + lastMessage: "Said something.", + lastMessageAt: "2024-04-01T00:00:00.000Z", + lastMessageAgentId: null, +}; + +describe("isResync", () => { + test("matches a resync event", () => { + expect(isResync({ resync: true })).toBe(true); + }); + + test("rejects an activity event", () => { + expect(isResync(activity)).toBe(false); + }); + + for (const value of [null, undefined, 5, "x", [], {}, { resync: false }]) { + test(`does not throw for ${String(JSON.stringify(value))}`, () => { + expect(isResync(value)).toBe(false); + }); + } +}); + +describe("isChannelSocketMessage", () => { + test.each([{ resync: true }, activity, { channelId: "a" }])( + "accepts %p", + (value) => { + expect(isChannelSocketMessage(value)).toBe(true); + }, + ); + + for (const value of [null, undefined, 5, "x", [], [activity]]) { + test(`rejects ${String(JSON.stringify(value))}`, () => { + expect(isChannelSocketMessage(value)).toBe(false); + }); + } +}); + +describe("parseChannelSocketMessage", () => { + test("parses a resync event", () => { + expect(parseChannelSocketMessage('{"resync":true}')).toEqual({ + resync: true, + }); + }); + + test("parses an activity event", () => { + expect(parseChannelSocketMessage(JSON.stringify(activity))).toEqual( + activity, + ); + }); + + for (const raw of ["null", "5", '"x"', "[1]", ""]) { + test(`drops JSON payload ${raw}`, () => { + expect(parseChannelSocketMessage(raw)).toBeNull(); + }); + } + + test("drops unparseable text", () => { + expect(parseChannelSocketMessage("not json {")).toBeNull(); + }); + + test("drops an activity event with no channel id", () => { + expect( + parseChannelSocketMessage(JSON.stringify({ lastMessage: "hi" })), + ).toBeNull(); + }); + + for (const data of [null, undefined, 5, {}, []]) { + test(`drops non-string frame ${String(JSON.stringify(data))}`, () => { + expect(parseChannelSocketMessage(data)).toBeNull(); + }); + } +});