Skip to content

Commit db783db

Browse files
committed
fix(realtime): believe a refusal only once a fresh token has been refused too
"Access ended" is terminal for the hook's lifetime, so it was being reached by three things that are not the end of anyone's access. A realtime token lives at most 60 seconds. A backgrounded tab has its timers throttled and can be frozen outright, so the gap between minting one and opening the stream with it can outlast it, and the 401 that answers is about the token rather than the person. A 404 is what an edge answers while the realtime service is being replaced: no route right now, not "not your site". And access_revoked is the control frame sent whenever an epoch moves, which a member removal, a share-settings change, a deletion start and the worker replaying any of those from its outbox all do. The hub's own comment spells out the client's part of that contract, "stop, re-mint, and find out why from the API", and calls over-bumping safe precisely because of it. So each of those now costs one more round trip with a new token, and only the second refusal is believed. A 403 still is believed at once: the gateway read the token and answered about the person. The allowance resets on every stream that opens, so it is one per healthy connection, not one per page. The status also moves into the card's header, where the live count already sits. A caveat about every number in the card belongs beside the card's name rather than in a strip over the rows, and a live count printed next to a stream that is not live is the one thing that header must never say.
1 parent 6925187 commit db783db

2 files changed

Lines changed: 102 additions & 45 deletions

File tree

‎apps/web/components/dashboard/realtime-card.tsx‎

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -146,12 +146,23 @@ export function RealtimeCard() {
146146
// through the shell's header slot: it is a fact about the whole card, not
147147
// the first row of its list. Keyed on the number so a snapshot tick with
148148
// the same count never re-registers.
149+
//
150+
// An unhealthy feed takes that same slot, and takes it from the count
151+
// (2026-08-26): "Reconnecting" or "Access ended" is a caveat about every
152+
// number in the card, so it belongs where the card is named rather than in
153+
// a strip over the rows, and a live count printed beside a stream that is
154+
// not live is the one thing the header must never say.
149155
const liveCount = snapshot === null ? null : snapshot.active_visitors;
150156
React.useEffect(() => {
151-
if (setHeaderChip === null || liveCount === null) return;
157+
if (setHeaderChip === null) return;
158+
if (status !== "live") {
159+
setHeaderChip(<RealtimeStatusChip status={status} />);
160+
return () => setHeaderChip(null);
161+
}
162+
if (liveCount === null) return;
152163
setHeaderChip(<LiveBadge count={liveCount} />);
153164
return () => setHeaderChip(null);
154-
}, [setHeaderChip, liveCount]);
165+
}, [setHeaderChip, liveCount, status]);
155166

156167
// The same number, for the "Overview" heading (see the store above). The
157168
// card is the screen's one realtime subscriber, so it is also the one
@@ -181,26 +192,21 @@ export function RealtimeCard() {
181192
);
182193

183194
if (snapshot === null) {
184-
// No data yet: connecting, first reconnect, or access already lost.
195+
// No data yet: connecting, first reconnect, or access already lost. The
196+
// state itself is named in the header now, so what is left here is the
197+
// sentence that explains it, and while connecting there is none to give.
185198
return (
186-
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center">
187-
<RealtimeStatusChip status={status} />
199+
<div className="flex h-full flex-col items-center justify-center px-6 text-center">
188200
<RealtimeStatusNote status={status} />
189201
</div>
190202
);
191203
}
192204

193205
return (
194206
<div className="flex h-full flex-col">
195-
{/* No green "Live" badge row: a populated card is its own live signal,
196-
and the count now sits beside the title. The chip returns only when
197-
the feed is NOT healthy, because "Live paused" or "Reconnecting" is
198-
a caveat about every number below it. */}
199-
{status !== "live" && (
200-
<div className="flex items-center justify-end px-5 pb-1 pt-0.5">
201-
<RealtimeStatusChip status={status} />
202-
</div>
203-
)}
207+
{/* Nothing above the rows any more, in either direction: the count and
208+
the caveat both live in the header, and a strip here only ever
209+
pushed the list down by a row. */}
204210
{latest.length === 0 ? (
205211
<p className="flex flex-1 items-center justify-center px-6 pb-4 text-center text-sm leading-6 text-muted-foreground">
206212
No one is browsing right now.

‎apps/web/hooks/use-realtime.ts‎

Lines changed: 82 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,28 @@ import {
2929
*
3030
* Reconnects mint a fresh token (the old one has expired by then) and resume
3131
* with `Last-Event-ID`, backing off around the stream's own `retry:` hint.
32-
* `access_lost` is terminal for the hook's lifetime: the server said this
33-
* user no longer sees this site, and only a remount re-arbitrates that.
32+
*
33+
* `access_lost` is terminal for the hook's lifetime, and for that reason it is
34+
* only reached by a refusal that **survives a fresh token** (2026-08-26).
35+
* Every path into it used to be believed on the spot, which painted a red
36+
* "Access ended" over three ordinary things:
37+
*
38+
* - A token is good for at most 60 seconds. A backgrounded tab has its timers
39+
* throttled and can be frozen outright, so the gap between minting one and
40+
* opening the stream with it can outlast it. The `401` that answers is about
41+
* the token, not the person.
42+
* - A `404` is what an edge answers while the realtime service is being
43+
* replaced. It means "no route right now", not "not your site".
44+
* - `access_revoked` is the control frame the server sends when an epoch moves
45+
* (a member removed, share settings changed, a deletion started, or the
46+
* worker replaying that bump from its outbox). The realtime hub's own
47+
* comment states the client's part: "stop, re-mint, and find out why from
48+
* the API" is the contract, and over-bumping is called safe precisely
49+
* because of it.
50+
*
51+
* So each of those costs one more round trip with a new token, and only the
52+
* second refusal is believed. A `403` still is: the gateway read the token and
53+
* said this person may not have this site.
3454
*/
3555
export function useRealtime(slug: string): {
3656
status: RealtimeStatus;
@@ -68,6 +88,12 @@ export function useRealtime(slug: string): {
6888
let attempt = 0;
6989
let lastId: string | null = null;
7090
let retryHintMs = DEFAULT_RETRY_MS;
91+
/**
92+
* Whether a refusal is already standing, unanswered by a fresh token.
93+
* Cleared by any stream that opens, so the allowance is one per healthy
94+
* connection rather than one per page.
95+
*/
96+
let refusedOnce = false;
7197

7298
while (!abort.signal.aborted) {
7399
try {
@@ -82,43 +108,68 @@ export function useRealtime(slug: string): {
82108
signal: abort.signal,
83109
});
84110

85-
if ([401, 403, 404].includes(response.status)) {
86-
// The token minted a moment ago no longer opens the stream —
87-
// the gateway is the arbiter, and it said no.
111+
// 403 is the gateway having read the token and answered about the
112+
// person. 401 and 404 are answers about the token and the route,
113+
// which a new token settles, so they are worth exactly one more.
114+
if (response.status === 403) {
88115
setStatus("access_lost");
89116
return;
90117
}
91-
if (!response.ok || !response.body) {
92-
throw new Error(`stream answered ${response.status}`);
93-
}
118+
if (response.status === 401 || response.status === 404) {
119+
if (refusedOnce) {
120+
setStatus("access_lost");
121+
return;
122+
}
123+
refusedOnce = true;
124+
setStatus("reconnecting");
125+
} else {
126+
if (!response.ok || !response.body) {
127+
throw new Error(`stream answered ${response.status}`);
128+
}
94129

95-
attempt = 0;
96-
setStatus("live");
130+
attempt = 0;
131+
refusedOnce = false;
132+
setStatus("live");
97133

98-
for await (const record of readSseRecords(
99-
response.body,
100-
abort.signal
101-
)) {
102-
if (record.retry !== undefined) retryHintMs = record.retry;
103-
const frame = toFrame(record, "private");
104-
if (!frame) continue;
105-
if (frame.type === "retry") continue;
106-
if (frame.type === "snapshot") {
107-
if (frame.id) lastId = frame.id;
108-
setSnapshot(frame.data as RealtimeSnapshot);
109-
setStatus("live");
110-
continue;
134+
for await (const record of readSseRecords(
135+
response.body,
136+
abort.signal
137+
)) {
138+
if (record.retry !== undefined) retryHintMs = record.retry;
139+
const frame = toFrame(record, "private");
140+
if (!frame) continue;
141+
if (frame.type === "retry") continue;
142+
if (frame.type === "snapshot") {
143+
if (frame.id) lastId = frame.id;
144+
setSnapshot(frame.data as RealtimeSnapshot);
145+
setStatus("live");
146+
continue;
147+
}
148+
const next = statusAfterControl(frame.data);
149+
if (frame.data.action === "disconnect") {
150+
// A revocation is a claim about access that a fresh token can
151+
// check. Believed the second time it is made, answered the
152+
// first time by reconnecting.
153+
if (next === "access_lost") {
154+
if (refusedOnce) {
155+
setStatus("access_lost");
156+
return;
157+
}
158+
refusedOnce = true;
159+
setStatus("reconnecting");
160+
} else {
161+
setStatus(next);
162+
}
163+
break;
164+
}
165+
setStatus(next);
111166
}
112-
const next = statusAfterControl(frame.data);
113-
setStatus(next);
114-
if (frame.data.action === "disconnect") {
115-
if (next === "access_lost") return;
116-
break; // polite server disconnect — reconnect with a new token
167+
// Stream ended (network drop or server break), so fall through
168+
// to the backoff and reconnect.
169+
if (!abort.signal.aborted && !refusedOnce) {
170+
setStatus("reconnecting");
117171
}
118172
}
119-
// Stream ended (network drop or server break) — fall through to
120-
// the backoff and reconnect.
121-
if (!abort.signal.aborted) setStatus("reconnecting");
122173
} catch (raised: unknown) {
123174
if (abort.signal.aborted) return;
124175
if (isUnauthenticated(raised)) {

0 commit comments

Comments
 (0)