Skip to main content

max / makenotwork

8.9 KB · 260 lines History Blame Raw
1 /**
2 * Chat client logic: everything the island decides, none of what it touches.
3 *
4 * No DOM, no network, no timers. The island calls into this to answer "is this
5 * body sendable", "have I already seen this message", "is this frame the echo
6 * of something I sent", and then does the rendering itself. Splitting it that
7 * way is what lets `node --test` exercise the parts where the bugs actually
8 * are without a browser.
9 *
10 * Backoff is not here. `reconnect.logic.ts` owns it and is imported by the
11 * island directly; folding the two together would make one module that two
12 * different concerns both edit.
13 *
14 * Three numbers and one wire format in this file are the server's, not ours.
15 * They are restated rather than fetched, and every one carries the Rust it
16 * mirrors, because a client that disagrees with the server about the length
17 * limit shows a message being accepted and then rejected.
18 */
19
20 /**
21 * Longest message a room accepts.
22 *
23 * Mirrors `MAX_MESSAGE_LEN` in `MNW/shared/livechat/src/message.rs`. The server
24 * is the authority; this exists so the composer can say so before the round
25 * trip, and the page also carries it as `data-max-length` so a change on the
26 * server is visible to a client built before it.
27 */
28 export const MAX_MESSAGE_LEN = 500;
29
30 /**
31 * Longest nonce the server will accept.
32 *
33 * Mirrors `Nonce::MAX_LEN` in `MNW/shared/livechat/src/ids.rs`, where an
34 * overlong nonce is dropped rather than rejected: the send would succeed and
35 * the echo would come back without it, leaving the optimistic message grey
36 * forever. Generating short ones is the only thing keeping us on the right
37 * side of that.
38 */
39 export const MAX_NONCE_LEN = 64;
40
41 /** What a body is, once trimmed. */
42 export type BodyCheck =
43 | { kind: "ok"; body: string; length: number }
44 | { kind: "empty" }
45 | { kind: "too-long"; length: number };
46
47 /**
48 * Trim, then classify, exactly as `MessageBody::parse` does server-side.
49 *
50 * Length is counted in code points rather than in `String.length`, which counts
51 * UTF-16 units. Rust counts `chars()`, so anything outside the basic plane —
52 * an emoji, most of the historic scripts — would otherwise read as two
53 * characters here and one there, and the client would refuse a message the
54 * server would have taken.
55 */
56 export function checkBody(raw: string): BodyCheck {
57 const body = raw.trim();
58 const length = [...body].length;
59 if (length === 0) return { kind: "empty" };
60 if (length > MAX_MESSAGE_LEN) return { kind: "too-long", length };
61 return { kind: "ok", body, length };
62 }
63
64 /**
65 * A nonce for one optimistic send.
66 *
67 * Only has to be unique among a single tab's in-flight sends, of which there
68 * are a handful at most, so this is not a place that wants a CSPRNG. `random`
69 * is injected so a test can force a collision and see what happens.
70 */
71 export function newNonce(random: () => number = Math.random): string {
72 const part = (): string =>
73 Math.floor(random() * 0xffff_ffff)
74 .toString(36)
75 .padStart(7, "0");
76 return `${part()}${part()}`;
77 }
78
79 /** Who wrote a message, as the room should show them. */
80 export interface Author {
81 display_name: string;
82 avatar_url?: string;
83 flair?: string;
84 }
85
86 /**
87 * A message as it arrives on the stream.
88 *
89 * `body_html` is already rendered and sanitized by the server through
90 * docengine's chat preset. The client never renders markdown and never
91 * sanitizes; if this string is not safe to insert, the bug is on the server and
92 * escaping it here would only hide it.
93 *
94 * `author` is absent when the host could not resolve the account (a deletion,
95 * mid-flight). A room stays readable in that case rather than emptying out.
96 */
97 export interface IncomingMessage {
98 id: number;
99 author_id: string;
100 body_html: string;
101 created_at: number;
102 nonce?: string;
103 author?: Author;
104 }
105
106 /**
107 * One frame off the stream.
108 *
109 * The message variant is flat rather than nested because serde's internally
110 * tagged enum writes the tag into the message object itself
111 * (`ChatEvent` in `MNW/shared/livechat/src/event.rs`).
112 */
113 export type Frame =
114 | ({ type: "message" } & IncomingMessage)
115 | { type: "delete"; id: number }
116 | { type: "purge"; author_id: string }
117 | { type: "gap" };
118
119 /**
120 * Parse one SSE payload, or return null.
121 *
122 * Null covers malformed JSON, a frame type this client does not know, and a
123 * known type missing a field it needs. All three are the same decision for the
124 * caller: ignore the frame and keep the connection. A client that threw here
125 * would tear down a working stream over one bad frame, and the SSE `event:`
126 * name is not trusted to match the payload — the tag inside it is what
127 * dispatch reads.
128 */
129 export function parseFrame(data: string): Frame | null {
130 let raw: unknown;
131 try {
132 raw = JSON.parse(data);
133 } catch {
134 return null;
135 }
136 if (typeof raw !== "object" || raw === null) return null;
137 const frame = raw as Record<string, unknown>;
138
139 switch (frame["type"]) {
140 case "message":
141 return isIncomingMessage(frame) ? ({ ...frame, type: "message" } as Frame) : null;
142 case "delete":
143 return typeof frame["id"] === "number" ? { type: "delete", id: frame["id"] } : null;
144 case "purge":
145 return typeof frame["author_id"] === "string"
146 ? { type: "purge", author_id: frame["author_id"] }
147 : null;
148 case "gap":
149 return { type: "gap" };
150 default:
151 return null;
152 }
153 }
154
155 function isIncomingMessage(frame: Record<string, unknown>): boolean {
156 return (
157 typeof frame["id"] === "number" &&
158 typeof frame["author_id"] === "string" &&
159 typeof frame["body_html"] === "string" &&
160 typeof frame["created_at"] === "number"
161 );
162 }
163
164 /** What the island should do with a message frame. */
165 export type Outcome =
166 | { kind: "append" }
167 | { kind: "reconcile"; nonce: string }
168 | { kind: "duplicate" };
169
170 /**
171 * The cursor and the in-flight sends, which are one object because they answer
172 * one question between them: is this frame new, mine, or something I already
173 * have on screen.
174 *
175 * The cursor is a high-water mark, not a set of seen ids. That is enough
176 * because `ChatStream::open` subscribes before it fetches the backlog, so a
177 * reconnect replays a window that may overlap the live feed but never skips
178 * anything: every duplicate is an id at or below where the client already got
179 * to, and the mark stays one number no matter how long the tab lives.
180 */
181 export class ChatLedger {
182 #cursor: number;
183 readonly #pending = new Set<string>();
184
185 constructor(cursor: number) {
186 this.#cursor = sanitizeCursor(cursor);
187 }
188
189 /** Highest message id handled. What a reconnect asks to resume after. */
190 get cursor(): number {
191 return this.#cursor;
192 }
193
194 /** How many optimistic sends are still waiting for their echo. */
195 get pending(): number {
196 return this.#pending.size;
197 }
198
199 /** Start waiting for the echo of a message rendered optimistically. */
200 track(nonce: string): void {
201 this.#pending.add(nonce);
202 }
203
204 /** Stop waiting: the POST failed, or the user gave up on it. */
205 untrack(nonce: string): void {
206 this.#pending.delete(nonce);
207 }
208
209 /**
210 * Classify a message frame.
211 *
212 * The nonce is checked before the cursor, and the order is load-bearing. Two
213 * people sending at once can have their inserts ordered one way and their
214 * broadcasts the other, so our own echo can arrive with an id below a message
215 * we have already displayed. Checking the cursor first would call that a
216 * duplicate and leave the sender's own message greyed out permanently, which
217 * is the one failure the whole nonce mechanism exists to prevent.
218 */
219 receive(message: { id: number; nonce?: string }): Outcome {
220 const nonce = message.nonce;
221 if (nonce !== undefined && this.#pending.delete(nonce)) {
222 this.#advance(message.id);
223 return { kind: "reconcile", nonce };
224 }
225 if (message.id <= this.#cursor) return { kind: "duplicate" };
226 this.#advance(message.id);
227 return { kind: "append" };
228 }
229
230 #advance(id: number): void {
231 if (id > this.#cursor) this.#cursor = id;
232 }
233 }
234
235 /**
236 * A cursor that is not a usable message id reads as "start from the beginning".
237 *
238 * The value arrives as a data attribute, so it is a string the server wrote and
239 * a string anything else could have written. `Number("")` is 0 and
240 * `Number("x")` is NaN, and NaN in the query string would make the server's
241 * `after` parse fail on every reconnect.
242 */
243 function sanitizeCursor(cursor: number): number {
244 return Number.isFinite(cursor) && cursor > 0 ? Math.trunc(cursor) : 0;
245 }
246
247 /**
248 * Where to open the stream.
249 *
250 * `after` is omitted rather than sent as 0 for a first connection: the server
251 * distinguishes absent (send the recent window) from present (replay strictly
252 * after this id), and 0 would ask it to replay the whole retention window a
253 * page at a time.
254 */
255 export function streamUrl(slug: string, cursor: number): string {
256 const base = `/p/${encodeURIComponent(slug)}/chat/stream`;
257 const after = sanitizeCursor(cursor);
258 return after > 0 ? `${base}?after=${after}` : base;
259 }
260