Skip to main content

max / makenotwork

12.0 KB · 343 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: "wipe" }
118 | { type: "gap" };
119
120 /**
121 * Parse one SSE payload, or return null.
122 *
123 * Null covers malformed JSON, a frame type this client does not know, and a
124 * known type missing a field it needs. All three are the same decision for the
125 * caller: ignore the frame and keep the connection. A client that threw here
126 * would tear down a working stream over one bad frame, and the SSE `event:`
127 * name is not trusted to match the payload — the tag inside it is what
128 * dispatch reads.
129 */
130 export function parseFrame(data: string): Frame | null {
131 let raw: unknown;
132 try {
133 raw = JSON.parse(data);
134 } catch {
135 return null;
136 }
137 if (typeof raw !== "object" || raw === null) return null;
138 const frame = raw as Record<string, unknown>;
139
140 switch (frame["type"]) {
141 case "message":
142 return isIncomingMessage(frame) ? ({ ...frame, type: "message" } as Frame) : null;
143 case "delete":
144 return typeof frame["id"] === "number" ? { type: "delete", id: frame["id"] } : null;
145 case "purge":
146 return typeof frame["author_id"] === "string"
147 ? { type: "purge", author_id: frame["author_id"] }
148 : null;
149 case "wipe":
150 return { type: "wipe" };
151 case "gap":
152 return { type: "gap" };
153 default:
154 return null;
155 }
156 }
157
158 function isIncomingMessage(frame: Record<string, unknown>): boolean {
159 return (
160 typeof frame["id"] === "number" &&
161 typeof frame["author_id"] === "string" &&
162 typeof frame["body_html"] === "string" &&
163 typeof frame["created_at"] === "number"
164 );
165 }
166
167 /** What the island should do with a message frame. */
168 export type Outcome =
169 | { kind: "append" }
170 | { kind: "reconcile"; nonce: string }
171 | { kind: "duplicate" };
172
173 /**
174 * The cursor and the in-flight sends, which are one object because they answer
175 * one question between them: is this frame new, mine, or something I already
176 * have on screen.
177 *
178 * The cursor is a high-water mark, not a set of seen ids. That is enough
179 * because `ChatStream::open` subscribes before it fetches the backlog, so a
180 * reconnect replays a window that may overlap the live feed but never skips
181 * anything: every duplicate is an id at or below where the client already got
182 * to, and the mark stays one number no matter how long the tab lives.
183 */
184 export class ChatLedger {
185 #cursor: number;
186 readonly #pending = new Set<string>();
187
188 constructor(cursor: number) {
189 this.#cursor = sanitizeCursor(cursor);
190 }
191
192 /** Highest message id handled. What a reconnect asks to resume after. */
193 get cursor(): number {
194 return this.#cursor;
195 }
196
197 /** How many optimistic sends are still waiting for their echo. */
198 get pending(): number {
199 return this.#pending.size;
200 }
201
202 /** Start waiting for the echo of a message rendered optimistically. */
203 track(nonce: string): void {
204 this.#pending.add(nonce);
205 }
206
207 /** Stop waiting: the POST failed, or the user gave up on it. */
208 untrack(nonce: string): void {
209 this.#pending.delete(nonce);
210 }
211
212 /**
213 * Classify a message frame.
214 *
215 * The nonce is checked before the cursor, and the order is load-bearing. Two
216 * people sending at once can have their inserts ordered one way and their
217 * broadcasts the other, so our own echo can arrive with an id below a message
218 * we have already displayed. Checking the cursor first would call that a
219 * duplicate and leave the sender's own message greyed out permanently, which
220 * is the one failure the whole nonce mechanism exists to prevent.
221 */
222 receive(message: { id: number; nonce?: string }): Outcome {
223 const nonce = message.nonce;
224 if (nonce !== undefined && this.#pending.delete(nonce)) {
225 this.#advance(message.id);
226 return { kind: "reconcile", nonce };
227 }
228 if (message.id <= this.#cursor) return { kind: "duplicate" };
229 this.#advance(message.id);
230 return { kind: "append" };
231 }
232
233 #advance(id: number): void {
234 if (id > this.#cursor) this.#cursor = id;
235 }
236 }
237
238 /**
239 * A cursor that is not a usable message id reads as "start from the beginning".
240 *
241 * The value arrives as a data attribute, so it is a string the server wrote and
242 * a string anything else could have written. `Number("")` is 0 and
243 * `Number("x")` is NaN, and NaN in the query string would make the server's
244 * `after` parse fail on every reconnect.
245 */
246 function sanitizeCursor(cursor: number): number {
247 return Number.isFinite(cursor) && cursor > 0 ? Math.trunc(cursor) : 0;
248 }
249
250 /**
251 * Usernames, exactly as docengine reads them.
252 *
253 * Mirrors the pattern in `Libraries/docengine/src/mentions.rs`. The two have to
254 * agree: a client that accepted `@bob.smith` would highlight on a boundary the
255 * forum's own mention resolution does not recognize, so the same text would
256 * count as naming someone here and not there.
257 */
258 const MENTION_RE = /@([A-Za-z0-9_-]+)/g;
259
260 /**
261 * Whether a rendered message names this viewer.
262 *
263 * Highlight only, never a notification. Chat expires, so a notification about
264 * something that will be gone in a week is noise, and the design settled that
265 * mentions are for whoever is in the room to see.
266 *
267 * Works on the rendered HTML rather than on the source markdown, because the
268 * source never reaches the client: `render_chat` deliberately leaves `@name` as
269 * plain text (it does not enable the mention link-resolution path), so the text
270 * survives into `body_html` verbatim and this is where it can be found.
271 *
272 * Two things are excluded, both matching docengine. Code spans, because a
273 * username inside backticks is being written about rather than addressed. And
274 * anything inside a tag, so an `@` that happens to appear in an attribute — a
275 * link to a mailto:, most obviously — is not read as naming anyone.
276 */
277 export function mentionsViewer(bodyHtml: string, username: string | null): boolean {
278 if (username === null || username === "") return false;
279 const target = username.toLowerCase();
280
281 for (const [, name] of stripMarkup(bodyHtml).matchAll(MENTION_RE)) {
282 if (name !== undefined && name.toLowerCase() === target) return true;
283 }
284 return false;
285 }
286
287 /**
288 * Drop tags and the contents of code spans, leaving the prose.
289 *
290 * A hand-written scan rather than a parse: this runs once per message on the
291 * arrival path, the input is server-rendered by a sanitizer we control, and
292 * the only question being asked of it is whether one word appears in the text.
293 * Setting `innerHTML` on a scratch element to reuse the browser's parser would
294 * be both slower and a place where a future change starts executing markup.
295 */
296 function stripMarkup(html: string): string {
297 let text = "";
298 let inTag = false;
299 let codeDepth = 0;
300
301 for (let i = 0; i < html.length; i += 1) {
302 const char = html[i];
303
304 if (char === "<") {
305 // Depth rather than a flag, so markup nested inside a `<code>` does not
306 // end the exclusion at its first closing tag.
307 if (html.startsWith("<code", i)) codeDepth += 1;
308 else if (html.startsWith("</code", i) && codeDepth > 0) codeDepth -= 1;
309 inTag = true;
310 continue;
311 }
312
313 if (inTag) {
314 // Every tag becomes one space, so a name markup splits stays split.
315 // `@ali*ce*` renders as `@ali<em>ce</em>`, and docengine reading the
316 // source finds the mention `ali`; rejoining the halves here would find
317 // `alice` and highlight for a different person entirely.
318 if (char === ">") {
319 inTag = false;
320 text += " ";
321 }
322 continue;
323 }
324
325 text += codeDepth > 0 ? " " : char;
326 }
327 return text;
328 }
329
330 /**
331 * Where to open the stream.
332 *
333 * `after` is omitted rather than sent as 0 for a first connection: the server
334 * distinguishes absent (send the recent window) from present (replay strictly
335 * after this id), and 0 would ask it to replay the whole retention window a
336 * page at a time.
337 */
338 export function streamUrl(slug: string, cursor: number): string {
339 const base = `/p/${encodeURIComponent(slug)}/chat/stream`;
340 const after = sanitizeCursor(cursor);
341 return after > 0 ? `${base}?after=${after}` : base;
342 }
343