/** * Chat client logic: everything the island decides, none of what it touches. * * No DOM, no network, no timers. The island calls into this to answer "is this * body sendable", "have I already seen this message", "is this frame the echo * of something I sent", and then does the rendering itself. Splitting it that * way is what lets `node --test` exercise the parts where the bugs actually * are without a browser. * * Backoff is not here. `reconnect.logic.ts` owns it and is imported by the * island directly; folding the two together would make one module that two * different concerns both edit. * * Three numbers and one wire format in this file are the server's, not ours. * They are restated rather than fetched, and every one carries the Rust it * mirrors, because a client that disagrees with the server about the length * limit shows a message being accepted and then rejected. */ /** * Longest message a room accepts. * * Mirrors `MAX_MESSAGE_LEN` in `MNW/shared/livechat/src/message.rs`. The server * is the authority; this exists so the composer can say so before the round * trip, and the page also carries it as `data-max-length` so a change on the * server is visible to a client built before it. */ export const MAX_MESSAGE_LEN = 500; /** * Longest nonce the server will accept. * * Mirrors `Nonce::MAX_LEN` in `MNW/shared/livechat/src/ids.rs`, where an * overlong nonce is dropped rather than rejected: the send would succeed and * the echo would come back without it, leaving the optimistic message grey * forever. Generating short ones is the only thing keeping us on the right * side of that. */ export const MAX_NONCE_LEN = 64; /** What a body is, once trimmed. */ export type BodyCheck = | { kind: "ok"; body: string; length: number } | { kind: "empty" } | { kind: "too-long"; length: number }; /** * Trim, then classify, exactly as `MessageBody::parse` does server-side. * * Length is counted in code points rather than in `String.length`, which counts * UTF-16 units. Rust counts `chars()`, so anything outside the basic plane — * an emoji, most of the historic scripts — would otherwise read as two * characters here and one there, and the client would refuse a message the * server would have taken. */ export function checkBody(raw: string): BodyCheck { const body = raw.trim(); const length = [...body].length; if (length === 0) return { kind: "empty" }; if (length > MAX_MESSAGE_LEN) return { kind: "too-long", length }; return { kind: "ok", body, length }; } /** * A nonce for one optimistic send. * * Only has to be unique among a single tab's in-flight sends, of which there * are a handful at most, so this is not a place that wants a CSPRNG. `random` * is injected so a test can force a collision and see what happens. */ export function newNonce(random: () => number = Math.random): string { const part = (): string => Math.floor(random() * 0xffff_ffff) .toString(36) .padStart(7, "0"); return `${part()}${part()}`; } /** Who wrote a message, as the room should show them. */ export interface Author { display_name: string; avatar_url?: string; flair?: string; } /** * A message as it arrives on the stream. * * `body_html` is already rendered and sanitized by the server through * docengine's chat preset. The client never renders markdown and never * sanitizes; if this string is not safe to insert, the bug is on the server and * escaping it here would only hide it. * * `author` is absent when the host could not resolve the account (a deletion, * mid-flight). A room stays readable in that case rather than emptying out. */ export interface IncomingMessage { id: number; author_id: string; body_html: string; created_at: number; nonce?: string; author?: Author; } /** * One frame off the stream. * * The message variant is flat rather than nested because serde's internally * tagged enum writes the tag into the message object itself * (`ChatEvent` in `MNW/shared/livechat/src/event.rs`). */ export type Frame = | ({ type: "message" } & IncomingMessage) | { type: "delete"; id: number } | { type: "purge"; author_id: string } | { type: "wipe" } | { type: "gap" }; /** * Parse one SSE payload, or return null. * * Null covers malformed JSON, a frame type this client does not know, and a * known type missing a field it needs. All three are the same decision for the * caller: ignore the frame and keep the connection. A client that threw here * would tear down a working stream over one bad frame, and the SSE `event:` * name is not trusted to match the payload — the tag inside it is what * dispatch reads. */ export function parseFrame(data: string): Frame | null { let raw: unknown; try { raw = JSON.parse(data); } catch { return null; } if (typeof raw !== "object" || raw === null) return null; const frame = raw as Record; switch (frame["type"]) { case "message": return isIncomingMessage(frame) ? ({ ...frame, type: "message" } as Frame) : null; case "delete": return typeof frame["id"] === "number" ? { type: "delete", id: frame["id"] } : null; case "purge": return typeof frame["author_id"] === "string" ? { type: "purge", author_id: frame["author_id"] } : null; case "wipe": return { type: "wipe" }; case "gap": return { type: "gap" }; default: return null; } } function isIncomingMessage(frame: Record): boolean { return ( typeof frame["id"] === "number" && typeof frame["author_id"] === "string" && typeof frame["body_html"] === "string" && typeof frame["created_at"] === "number" ); } /** What the island should do with a message frame. */ export type Outcome = | { kind: "append" } | { kind: "reconcile"; nonce: string } | { kind: "duplicate" }; /** * The cursor and the in-flight sends, which are one object because they answer * one question between them: is this frame new, mine, or something I already * have on screen. * * The cursor is a high-water mark, not a set of seen ids. That is enough * because `ChatStream::open` subscribes before it fetches the backlog, so a * reconnect replays a window that may overlap the live feed but never skips * anything: every duplicate is an id at or below where the client already got * to, and the mark stays one number no matter how long the tab lives. */ export class ChatLedger { #cursor: number; readonly #pending = new Set(); constructor(cursor: number) { this.#cursor = sanitizeCursor(cursor); } /** Highest message id handled. What a reconnect asks to resume after. */ get cursor(): number { return this.#cursor; } /** How many optimistic sends are still waiting for their echo. */ get pending(): number { return this.#pending.size; } /** Start waiting for the echo of a message rendered optimistically. */ track(nonce: string): void { this.#pending.add(nonce); } /** Stop waiting: the POST failed, or the user gave up on it. */ untrack(nonce: string): void { this.#pending.delete(nonce); } /** * Classify a message frame. * * The nonce is checked before the cursor, and the order is load-bearing. Two * people sending at once can have their inserts ordered one way and their * broadcasts the other, so our own echo can arrive with an id below a message * we have already displayed. Checking the cursor first would call that a * duplicate and leave the sender's own message greyed out permanently, which * is the one failure the whole nonce mechanism exists to prevent. */ receive(message: { id: number; nonce?: string }): Outcome { const nonce = message.nonce; if (nonce !== undefined && this.#pending.delete(nonce)) { this.#advance(message.id); return { kind: "reconcile", nonce }; } if (message.id <= this.#cursor) return { kind: "duplicate" }; this.#advance(message.id); return { kind: "append" }; } #advance(id: number): void { if (id > this.#cursor) this.#cursor = id; } } /** * A cursor that is not a usable message id reads as "start from the beginning". * * The value arrives as a data attribute, so it is a string the server wrote and * a string anything else could have written. `Number("")` is 0 and * `Number("x")` is NaN, and NaN in the query string would make the server's * `after` parse fail on every reconnect. */ function sanitizeCursor(cursor: number): number { return Number.isFinite(cursor) && cursor > 0 ? Math.trunc(cursor) : 0; } /** * Usernames, exactly as docengine reads them. * * Mirrors the pattern in `Libraries/docengine/src/mentions.rs`. The two have to * agree: a client that accepted `@bob.smith` would highlight on a boundary the * forum's own mention resolution does not recognize, so the same text would * count as naming someone here and not there. */ const MENTION_RE = /@([A-Za-z0-9_-]+)/g; /** * Whether a rendered message names this viewer. * * Highlight only, never a notification. Chat expires, so a notification about * something that will be gone in a week is noise, and the design settled that * mentions are for whoever is in the room to see. * * Works on the rendered HTML rather than on the source markdown, because the * source never reaches the client: `render_chat` deliberately leaves `@name` as * plain text (it does not enable the mention link-resolution path), so the text * survives into `body_html` verbatim and this is where it can be found. * * Two things are excluded, both matching docengine. Code spans, because a * username inside backticks is being written about rather than addressed. And * anything inside a tag, so an `@` that happens to appear in an attribute — a * link to a mailto:, most obviously — is not read as naming anyone. */ export function mentionsViewer(bodyHtml: string, username: string | null): boolean { if (username === null || username === "") return false; const target = username.toLowerCase(); for (const [, name] of stripMarkup(bodyHtml).matchAll(MENTION_RE)) { if (name !== undefined && name.toLowerCase() === target) return true; } return false; } /** * Drop tags and the contents of code spans, leaving the prose. * * A hand-written scan rather than a parse: this runs once per message on the * arrival path, the input is server-rendered by a sanitizer we control, and * the only question being asked of it is whether one word appears in the text. * Setting `innerHTML` on a scratch element to reuse the browser's parser would * be both slower and a place where a future change starts executing markup. */ function stripMarkup(html: string): string { let text = ""; let inTag = false; let codeDepth = 0; for (let i = 0; i < html.length; i += 1) { const char = html[i]; if (char === "<") { // Depth rather than a flag, so markup nested inside a `` does not // end the exclusion at its first closing tag. if (html.startsWith(" 0) codeDepth -= 1; inTag = true; continue; } if (inTag) { // Every tag becomes one space, so a name markup splits stays split. // `@ali*ce*` renders as `@alice`, and docengine reading the // source finds the mention `ali`; rejoining the halves here would find // `alice` and highlight for a different person entirely. if (char === ">") { inTag = false; text += " "; } continue; } text += codeDepth > 0 ? " " : char; } return text; } /** * Where to open the stream. * * `after` is omitted rather than sent as 0 for a first connection: the server * distinguishes absent (send the recent window) from present (replay strictly * after this id), and 0 would ask it to replay the whole retention window a * page at a time. */ export function streamUrl(slug: string, cursor: number): string { const base = `/p/${encodeURIComponent(slug)}/chat/stream`; const after = sanitizeCursor(cursor); return after > 0 ? `${base}?after=${after}` : base; }