| 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 |
|