Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 2 additions & 26 deletions src/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,23 +44,13 @@ export function App() {
const [error, setError] = useState<string | null>(null);
const [dreamMsg, setDreamMsg] = useState<string>(DREAMING_MESSAGES[0]);
const [headerSearchDraft, setHeaderSearchDraft] = useState<string>("");
// Title of the currently-rendered article (extracted from the streamed
// <h1>). 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<string>("");
const prevSlugRef = useRef<string | null>(null);
const abortRef = useRef<AbortController | null>(null);

/* ----- Popstate (back/forward) ----- */
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() : "");
};
Expand Down Expand Up @@ -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 <h1> 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;
Expand Down Expand Up @@ -155,21 +140,16 @@ 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 `<h1>` appears in the streamed HTML.
useEffect(() => {
if (!html) {
setArticleTitle("");
return;
}
const m = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
if (m) {
const title = m[1].replace(/<[^>]+>/g, "").trim();
if (title) {
document.title = `${title} — Halupedia`;
setArticleTitle(title);
}
}
}, [html]);
Expand All @@ -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
Expand Down Expand Up @@ -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("");
},
Expand Down
19 changes: 7 additions & 12 deletions src/client/usePresence.ts
Original file line number Diff line number Diff line change
@@ -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
*
Expand Down Expand Up @@ -36,21 +36,18 @@ 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<PresenceTopItem[]>([]);
const [hereCount, setHereCount] = useState<number | null>(null);
const [connected, setConnected] = useState(false);

const wsRef = useRef<WebSocket | null>(null);
const slugRef = useRef<string | null>(slug);
const titleRef = useRef<string>(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(() => {
Expand All @@ -65,7 +62,6 @@ export function usePresence(
JSON.stringify({
t: "r",
s: slugRef.current,
ti: titleRef.current,
})
);
} catch {
Expand Down Expand Up @@ -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 <h1> 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<string | null>(slug);
useEffect(() => {
if (lastSentSlugRef.current !== slug) {
Expand All @@ -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 };
}
2 changes: 1 addition & 1 deletion src/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
/* -------------------------------------------------------------------------- */
Expand Down
99 changes: 54 additions & 45 deletions src/worker/presence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>". slug=null means "connected
* but not on an article" (search / all-entries).
* server → client {"t":"hi"}
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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<string, string> = new Map();

Expand Down Expand Up @@ -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;
}
}

Expand All @@ -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 <h1> 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();
}
Expand Down Expand Up @@ -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<string, number>): 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<string> {
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<void> {
// Backstop only: navigation and departures already broadcast eagerly
// via broadcastAll(). The alarm exists in case a client's view drifts
Expand Down