Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 46 additions & 10 deletions app/src/lib/channels/use-channel-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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.
Expand Down
80 changes: 80 additions & 0 deletions app/tests/channel-socket-message.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
}
});