diff --git a/src/client/App.tsx b/src/client/App.tsx index 48c4bff..1d509c7 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -44,11 +44,6 @@ export function App() { const [error, setError] = useState(null); const [dreamMsg, setDreamMsg] = useState(DREAMING_MESSAGES[0]); const [headerSearchDraft, setHeaderSearchDraft] = useState(""); - // Title of the currently-rendered article (extracted from the streamed - //

). Declared up here because the slug-change fetch effect needs to - // reset it synchronously to avoid leaking a stale title into the next - // article's presence broadcast. - const [articleTitle, setArticleTitle] = useState(""); const prevSlugRef = useRef(null); const abortRef = useRef(null); @@ -56,11 +51,6 @@ export function App() { useEffect(() => { const onPop = () => { const s = currentSlug(); - // Clear the title in the same render as the slug change so the - // presence effect below never sees the {new slug, old title} pair - // mid-transition. (Backend is now resilient to that, but there's - // no reason to ship known-wrong data.) - setArticleTitle(""); setSlug(s); setSearchQuery(s === RESERVED_SEARCH ? currentSearchQuery() : ""); }; @@ -88,11 +78,6 @@ export function App() { setHtml(""); setError(null); setStatus("loading"); - // Clear the previous article's title in the same tick the slug changes - // so usePresence doesn't broadcast {s:newSlug, ti:oldTitle} during the - // window before the new article's

streams in. That stale pairing - // was poisoning the server-side title cache for the new slug. - setArticleTitle(""); setDreamMsg(DREAMING_MESSAGES[Math.floor(Math.random() * DREAMING_MESSAGES.length)]); const from = prevSlugRef.current; @@ -155,13 +140,9 @@ export function App() { }, [slug]); /* ----- Extract h1 title from the article HTML ----- */ - // Used both for `document.title` and for the presence broadcast (so the - // "currently being read" panel shows real titles instead of slugified - // fallbacks). State declared above with the rest because the slug-change - // fetch effect resets it. + // Update page title when the first `

` appears in the streamed HTML. useEffect(() => { if (!html) { - setArticleTitle(""); return; } const m = html.match(/]*>([\s\S]*?)<\/h1>/i); @@ -169,7 +150,6 @@ export function App() { const title = m[1].replace(/<[^>]+>/g, "").trim(); if (title) { document.title = `${title} — Halupedia`; - setArticleTitle(title); } } }, [html]); @@ -185,7 +165,7 @@ export function App() { slug === HOMEPAGE_SLUG ? null : slug; - const presence = usePresence(presenceSlug, articleTitle); + const presence = usePresence(presenceSlug); /* ----- Top folios (all-time, by upvotes) ----- */ // Plain D1-backed list, no real-time bells. Refetched on first SPA load @@ -231,10 +211,6 @@ export function App() { const url = clean === "halupedia" ? "/" : `/${clean}`; window.history.pushState({}, "", url); window.scrollTo({ top: 0, behavior: "instant" as ScrollBehavior }); - // Batch the title clear with the slug change. React renders both - // state updates together, so the presence effect sees the new slug - // with an empty title rather than the previous article's title. - setArticleTitle(""); setSlug(clean); setSearchQuery(""); }, diff --git a/src/client/usePresence.ts b/src/client/usePresence.ts index 1180e82..e5add83 100644 --- a/src/client/usePresence.ts +++ b/src/client/usePresence.ts @@ -1,7 +1,7 @@ /** * usePresence — single WebSocket to /api/presence for the lifetime of the SPA. * - * Sends one `{t:"r", s, ti}` message per navigation. The server fans back: + * Sends one `{t:"r", s}` message per navigation. The server fans back: * - `top` — global top-N {slug,title,count}, refreshed every ~3s when changed * - `here` — count of readers on the current slug, when it changes * @@ -36,8 +36,7 @@ const RECONNECT_BASE_MS = 1000; const RECONNECT_MAX_MS = 30_000; export function usePresence( - slug: string | null, - title: string + slug: string | null ): PresenceState { const [top, setTop] = useState([]); const [hereCount, setHereCount] = useState(null); @@ -45,12 +44,10 @@ export function usePresence( const wsRef = useRef(null); const slugRef = useRef(slug); - const titleRef = useRef(title); const reconnectAttemptRef = useRef(0); // Keep refs in sync so the WS open handler always sends the *current* slug. slugRef.current = slug; - titleRef.current = title; // Open the WS once for the SPA's lifetime; reconnect on drop. useEffect(() => { @@ -65,7 +62,6 @@ export function usePresence( JSON.stringify({ t: "r", s: slugRef.current, - ti: titleRef.current, }) ); } catch { @@ -152,10 +148,9 @@ export function usePresence( }; }, []); - // Whenever slug or title changes, push an `r` if the socket is open. - // Only reset hereCount when the SLUG actually changes — a title-only - // update (e.g. when the new article's

finishes streaming) must not - // wipe the count we just received from the server for the same slug. + // Push an `r` whenever slug changes if the socket is already open. + // Only reset hereCount when the SLUG actually changes — that signals a + // different location, so the prior count no longer applies. const lastSentSlugRef = useRef(slug); useEffect(() => { if (lastSentSlugRef.current !== slug) { @@ -165,12 +160,12 @@ export function usePresence( const ws = wsRef.current; if (ws && ws.readyState === WebSocket.OPEN) { try { - ws.send(JSON.stringify({ t: "r", s: slug, ti: title })); + ws.send(JSON.stringify({ t: "r", s: slug })); } catch { /* ignore */ } } - }, [slug, title]); + }, [slug]); return { top, hereCount, connected }; } diff --git a/src/worker/index.ts b/src/worker/index.ts index 5326b23..fea4a44 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -776,7 +776,7 @@ app.get("/api/moderate", async (c) => { /* GET /api/presence — single global Durable Object, WS-only */ /* */ /* Clients open exactly one WebSocket for the lifetime of the SPA and send */ -/* a `{t:"r", s, ti}` message whenever they navigate to a new slug. The DO */ +/* a `{t:"r", s}` message whenever they navigate to a new slug. The DO */ /* fans back two stream types: a global top-N broadcast and a per-client */ /* count for the slug they're on. Closing the WS removes them from counts. */ /* -------------------------------------------------------------------------- */ diff --git a/src/worker/presence.ts b/src/worker/presence.ts index bc21bf9..9da917d 100644 --- a/src/worker/presence.ts +++ b/src/worker/presence.ts @@ -17,7 +17,7 @@ * need to keep a separate Map in sync with hibernation. * * Protocol: - * client → server {"t":"r","s":"slug-or-null","ti":"Title"} + * client → server {"t":"r","s":"slug-or-null"} * "I'm now reading ". slug=null means "connected * but not on an article" (search / all-entries). * server → client {"t":"hi"} @@ -34,15 +34,14 @@ * doesn't auto-reschedule. Idle DO has no alarm running. */ +import { slugToTitle, slugify } from "./slug"; + interface PresenceEnv { - // No bindings used today. Reserved for future per-IP rate limiting, - // logging, etc. + ARTICLES: KVNamespace; } const BROADCAST_INTERVAL_MS = 3000; const TOP_N = 5; -const MAX_SLUG_LEN = 200; -const MAX_TITLE_LEN = 200; const MAX_MSG_BYTES = 1000; const RATE_BURST = 10; // messages per second per WS before we close const TOTAL_WS_CAP = 30000; // hard ceiling per DO @@ -57,21 +56,9 @@ interface Attachment { } // NOTE on titles: -// Titles are NOT stored per-socket. The old design kept `ti` on each -// websocket attachment and then picked "the first non-empty title from -// any live socket on that slug" when building the top-N. That had two -// fatal flaws: -// 1. During a slug change, the client briefly held the *previous* -// article's title in state, so it shipped `{s: newSlug, -// ti: oldTitle}` on the navigation frame. That stale pair won the -// first-non-empty race and got frozen into `lastTop`. -// 2. The subsequent corrected message `{s: newSlug, ti: realTitle}` -// never triggered a fresh broadcast (only slug *changes* did), so -// the wrong title persisted on every other reader's sidebar. -// The fix is structural: titles live in a single DO-level map, indexed -// by slug, last-non-empty-write-wins. Counts are derived from live -// sockets; titles are derived from this map. The two concerns are now -// independent, which is what the architecture wanted in the first place. +// Titles are derived from server-owned article metadata; if not available, +// we fall back to deterministic slug formatting. This prevents sidebar +// poisoning from untrusted text. interface TopItem { s: string; @@ -85,12 +72,10 @@ export class PresenceDO implements DurableObject { /** * Authoritative slug → title map. Single source of truth for what to - * render in "Currently Being Consulted". Updated on every `r` message - * with a non-empty `ti`, last-write-wins; entries are evicted when the - * slug has zero live readers. In-memory only — the DO can hibernate - * and lose this map; the very next `r` for an affected slug repopulates - * it. We never persist it because doing so would just re-introduce the - * "stale title outlives the reader" failure mode we just fixed. + * render in "Currently Being Consulted". Updated from canonicalized slugs + * on every `r` message; entries are evicted when the slug has zero live + * readers. In-memory only — the DO can hibernate and lose this map; + * the very next `r` for an affected slug repopulates it. */ private titles: Map = new Map(); @@ -176,12 +161,10 @@ export class PresenceDO implements DurableObject { } let s: string | null = null; - let ti = ""; if (parsed.s != null) { - const sRaw = String(parsed.s).slice(0, MAX_SLUG_LEN).trim(); - if (sRaw) { - s = sRaw; - ti = String(parsed.ti ?? "").slice(0, MAX_TITLE_LEN).trim(); + const canonical = slugify(String(parsed.s)); + if (canonical) { + s = canonical; } } @@ -195,20 +178,19 @@ export class PresenceDO implements DurableObject { }; ws.serializeAttachment(newAtt); - // Title bookkeeping. A non-empty title overwrites whatever we had - // (last-write-wins). An empty title is *ignored* — never let a client - // that hasn't yet streamed the new article's

wipe a perfectly - // good title that another reader just supplied. + // Title bookkeeping: titles are derived from server-owned article metadata + // only, never from client-provided text. let titleChanged = false; - if (s && ti && this.titles.get(s) !== ti) { - this.titles.set(s, ti); - titleChanged = true; + if (s) { + const canonicalTitle = await this.resolveTitle(s); + if (this.titles.get(s) !== canonicalTitle) { + this.titles.set(s, canonicalTitle); + titleChanged = true; + } } - // Broadcast on slug change (someone joined/left a slug) OR title - // change (a reader corrected a title we were showing). Title-only - // updates that don't affect the top-N are essentially free: the - // topChanged check inside broadcastAll() will turn them into no-ops. + // Broadcast on slug change (someone joined/left a slug). Title changes + // here are effectively no-ops unless they alter the top list. if (slugChanged || titleChanged) { await this.broadcastAll(); } @@ -349,15 +331,42 @@ export class PresenceDO implements DurableObject { } /** Build a sorted top-N from a precomputed counts map. Titles come - * from the DO-level `titles` map (last-non-empty-write-wins), NOT - * from socket attachments — that's the whole point of the refactor. */ + * from the DO-level `titles` map (canonical slug -> title), NOT from + * socket attachments — that's the whole point of the refactor. */ private snapshotTop(counts: Map): TopItem[] { const arr: TopItem[] = []; - for (const [s, n] of counts) arr.push({ s, ti: this.titles.get(s) ?? "", n }); + for (const [s, n] of counts) { + const ti = this.titles.get(s) ?? slugToTitle(s); + this.titles.set(s, ti); + arr.push({ s, ti, n }); + } arr.sort((a, b) => b.n - a.n || a.s.localeCompare(b.s)); return arr.slice(0, TOP_N); } + /** Load a trusted title for this slug from KV metadata or fallback. */ + private async resolveTitle(slug: string): Promise { + const cached = this.titles.get(slug); + if (cached) return cached; + + try { + const fromKv = await this.env.ARTICLES.get(slug, "json") as + | { title?: string } + | null; + const title = fromKv?.title?.trim(); + if (title) { + this.titles.set(slug, title); + return title; + } + } catch { + /* ignored; fallback below */ + } + + const fallback = slugToTitle(slug); + this.titles.set(slug, fallback); + return fallback; + } + async alarm(): Promise { // Backstop only: navigation and departures already broadcast eagerly // via broadcastAll(). The alarm exists in case a client's view drifts