Skip to content

Commit fcae8e7

Browse files
committed
persist stars as 👀, more failure logging
1 parent b0e77bd commit fcae8e7

14 files changed

Lines changed: 674 additions & 38 deletions

File tree

‎README.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,12 +100,19 @@ browsers revalidate with this service; that does not imply a GitHub request whil
100100
the relevant snapshot remains fresh.
101101

102102
The browser runtime keeps the last counter snapshot and the last confirmed viewer
103-
vote state in local storage for up to seven days. Consumers can render them
103+
vote/star state in local storage for up to seven days. Consumers can render them
104104
synchronously while the API request is in flight, avoiding a flash of zero counters
105105
or unselected vote buttons. Snapshots contain only the site/resource key, discussion
106-
node ID, counts, viewer state, and save time; authentication tokens are not part of
106+
node ID, counts, viewer state, star state, and save time; authentication tokens are not part of
107107
this cache. Storage is optional and failures fall back to the network normally.
108108

109+
Once authenticated, the runtime also resolves the current user's vote and star
110+
state for all visible discussions in one batched GitHub query. That viewer-specific
111+
snapshot is reused for five minutes, including across reloads, and then refreshed
112+
on demand. This makes reactions created on GitHub or another device visible without
113+
turning every counter request into an authenticated GitHub request. Stars map to
114+
GitHub's `EYES` reaction because Discussions does not provide a star reaction.
115+
109116
Operational logs are emitted at GitHub boundaries rather than for every HTTP
110117
request. Reaction-refresh lines include the site, trigger (`requested` or
111118
`sweep`), batch size, updated-row count, and duration. Failures include safe

‎runtime/src/api/client.ts‎

Lines changed: 160 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,16 @@ interface CounterState {
1010
stale: boolean;
1111
}
1212

13+
type StoredReactionState = CounterState & {
14+
viewer: ViewerVote;
15+
viewerKnown?: boolean;
16+
starred?: boolean;
17+
};
18+
1319
export interface ReactionState extends CounterState {
1420
viewer: ViewerVote;
21+
viewerKnown: boolean;
22+
starred: boolean;
1523
}
1624

1725
interface ReactionEnvelope {
@@ -42,6 +50,7 @@ export interface AccessToken {
4250
value: string;
4351
expiresAt: number;
4452
creationGrant: string;
53+
viewerId?: string;
4554
}
4655

4756
export interface EnsuredDiscussion {
@@ -91,13 +100,57 @@ export class FeedbackClient {
91100
for (const key of normalized) {
92101
const state = payload.items[key] ?? emptyState();
93102
if (!isCounterState(state)) throw new TypeError("Invalid feedback service response");
94-
const combined = { ...state, viewer: this.#cachedReaction(key)?.viewer ?? "none" };
103+
const cached = this.#cachedReaction(key);
104+
const combined = {
105+
...state,
106+
viewer: cached?.viewer ?? "none",
107+
viewerKnown: cached?.viewerKnown ?? false,
108+
starred: cached?.starred ?? false,
109+
};
95110
result.set(key, combined);
96111
this.#storeReaction(key, combined);
97112
}
98113
return result;
99114
}
100115

116+
async syncViewerReactions(
117+
keys: Iterable<string>,
118+
token: AccessToken,
119+
signal?: AbortSignal,
120+
): Promise<ReadonlyMap<string, ReactionState>> {
121+
const normalized = [...new Set(Array.from(keys, validateResourceId))].sort();
122+
if (normalized.length === 0) throw new TypeError("At least one resource key is required");
123+
const pending = normalized.filter((key) => this.#viewerNeedsSync(key, token.viewerId));
124+
if (pending.length > 0) {
125+
const url = new URL(`/v1/sites/${encodeURIComponent(this.#site)}/viewer-reactions`, this.#apiOrigin);
126+
url.searchParams.set("keys", pending.join(","));
127+
const headers = { Accept: "application/json", Authorization: `Bearer ${token.value}` };
128+
const init: RequestInit = { headers };
129+
if (signal) init.signal = signal;
130+
const response = await this.#fetch(url, init);
131+
if (!response.ok) throw new FeedbackError(response.status, await errorCode(response));
132+
const payload: unknown = await response.json();
133+
if (!isViewerEnvelope(payload, this.#site, pending)) {
134+
throw new TypeError("Invalid feedback service response");
135+
}
136+
for (const key of pending) {
137+
const cached = this.#cachedReaction(key) ?? emptyState();
138+
this.#storeReaction(
139+
key,
140+
{
141+
...cached,
142+
viewer: payload.items[key]?.vote ?? "none",
143+
starred: payload.items[key]?.starred ?? false,
144+
viewerKnown: true,
145+
},
146+
true,
147+
token.viewerId,
148+
);
149+
}
150+
}
151+
return this.cachedReactions(normalized);
152+
}
153+
101154
async authorize(challenge: string, nonce: string, signal?: AbortSignal): Promise<Authorization> {
102155
const payload = await this.#post("oauth/authorize", { challenge, nonce }, signal);
103156
const url = payload.authorization_url;
@@ -174,10 +227,26 @@ export class FeedbackClient {
174227
age: 0,
175228
stale: true,
176229
viewer: result.viewer,
177-
});
230+
viewerKnown: true,
231+
starred: cached?.starred ?? false,
232+
}, true, token.viewerId);
178233
return result;
179234
}
180235

236+
async toggleStar(key: string, token: AccessToken, signal?: AbortSignal): Promise<boolean> {
237+
validateResourceId(key);
238+
const payload = await this.#post("stars", { key }, signal, token.value);
239+
if (typeof payload.starred !== "boolean") throw new TypeError("Invalid star response");
240+
const cached = this.#cachedReaction(key) ?? emptyState();
241+
this.#storeReaction(
242+
key,
243+
{ ...cached, starred: payload.starred, viewerKnown: true },
244+
true,
245+
token.viewerId,
246+
);
247+
return payload.starred;
248+
}
249+
181250
async #post(
182251
path: string,
183252
body: Record<string, unknown>,
@@ -214,28 +283,73 @@ export class FeedbackClient {
214283
if (
215284
!Number.isSafeInteger(value.savedAt) ||
216285
Date.now() - (value.savedAt as number) > 7 * 24 * 60 * 60 * 1000 ||
217-
!isReactionState(value.state)
286+
!isStoredReactionState(value.state)
218287
) {
219288
this.#counterStorage.removeItem(counterKey(this.#site, key));
220289
return null;
221290
}
222291
const elapsed = Math.max(0, Math.floor((Date.now() - (value.savedAt as number)) / 1000));
223-
return { ...value.state, age: value.state.age + elapsed, stale: true };
292+
return {
293+
...value.state,
294+
viewerKnown: value.state.viewerKnown ?? false,
295+
starred: value.state.starred ?? false,
296+
age: value.state.age + elapsed,
297+
stale: true,
298+
};
224299
} catch {
225300
return null;
226301
}
227302
}
228303

229-
#storeReaction(key: string, state: ReactionState): void {
304+
#storeReaction(
305+
key: string,
306+
state: ReactionState,
307+
viewerConfirmed = false,
308+
viewerId?: string,
309+
): void {
230310
try {
311+
let viewerCheckedAt: number | null = null;
312+
let existingViewerId: string | undefined;
313+
const existing = this.#counterStorage?.getItem(counterKey(this.#site, key));
314+
if (existing !== null && existing !== undefined) {
315+
const parsed = JSON.parse(existing) as { viewerCheckedAt?: unknown; viewerId?: unknown };
316+
if (Number.isSafeInteger(parsed.viewerCheckedAt)) viewerCheckedAt = parsed.viewerCheckedAt as number;
317+
if (typeof parsed.viewerId === "string") existingViewerId = parsed.viewerId;
318+
}
231319
this.#counterStorage?.setItem(
232320
counterKey(this.#site, key),
233-
JSON.stringify({ savedAt: Date.now(), state }),
321+
JSON.stringify({
322+
savedAt: Date.now(),
323+
viewerCheckedAt: viewerConfirmed ? Date.now() : viewerCheckedAt,
324+
viewerId: viewerConfirmed ? viewerId : existingViewerId,
325+
state,
326+
}),
234327
);
235328
} catch {
236329
// Storage may be disabled or full; counters still work from the network.
237330
}
238331
}
332+
333+
334+
#viewerNeedsSync(key: string, viewerId?: string): boolean {
335+
if (this.#counterStorage === undefined) return true;
336+
try {
337+
const raw = this.#counterStorage.getItem(counterKey(this.#site, key));
338+
if (raw === null) return true;
339+
const value = JSON.parse(raw) as {
340+
viewerCheckedAt?: unknown;
341+
viewerId?: unknown;
342+
state?: unknown;
343+
};
344+
return !isStoredReactionState(value.state) ||
345+
!value.state.viewerKnown ||
346+
(viewerId !== undefined && value.viewerId !== viewerId) ||
347+
!Number.isSafeInteger(value.viewerCheckedAt) ||
348+
Date.now() - (value.viewerCheckedAt as number) >= 5 * 60 * 1000;
349+
} catch {
350+
return true;
351+
}
352+
}
239353
}
240354

241355
export class FeedbackError extends Error {
@@ -245,7 +359,16 @@ export class FeedbackError extends Error {
245359
}
246360

247361
function emptyState(): ReactionState {
248-
return { id: null, up: 0, down: 0, age: 0, stale: false, viewer: "none" };
362+
return {
363+
id: null,
364+
up: 0,
365+
down: 0,
366+
age: 0,
367+
stale: false,
368+
viewer: "none",
369+
viewerKnown: false,
370+
starred: false,
371+
};
249372
}
250373

251374
function isEnvelope(value: unknown, site: string): value is ReactionEnvelope {
@@ -266,14 +389,42 @@ function isCounterState(value: unknown): value is CounterState {
266389
);
267390
}
268391

269-
function isReactionState(value: unknown): value is ReactionState {
270-
return isCounterState(value) && isViewerVote((value as Partial<ReactionState>).viewer);
392+
function isStoredReactionState(value: unknown): value is StoredReactionState {
393+
return isCounterState(value) &&
394+
isViewerVote((value as Partial<ReactionState>).viewer) &&
395+
((value as Partial<ReactionState>).viewerKnown === undefined ||
396+
typeof (value as Partial<ReactionState>).viewerKnown === "boolean") &&
397+
((value as Partial<ReactionState>).starred === undefined ||
398+
typeof (value as Partial<ReactionState>).starred === "boolean");
271399
}
272400

273401
function isViewerVote(value: unknown): value is ViewerVote {
274402
return value === "up" || value === "down" || value === "both" || value === "none";
275403
}
276404

405+
function isViewerEnvelope(
406+
value: unknown,
407+
site: string,
408+
keys: readonly string[],
409+
): value is {
410+
v: 1;
411+
site: string;
412+
items: Record<string, { vote: ViewerVote; starred: boolean }>;
413+
} {
414+
if (!value || typeof value !== "object") return false;
415+
const candidate = value as { v?: unknown; site?: unknown; items?: unknown };
416+
if (candidate.v !== 1 || candidate.site !== site || !candidate.items || typeof candidate.items !== "object") {
417+
return false;
418+
}
419+
const items = candidate.items as Record<string, unknown>;
420+
return Object.keys(items).length === keys.length && keys.every((key) => {
421+
const item = items[key];
422+
return !!item && typeof item === "object" &&
423+
isViewerVote((item as { vote?: unknown }).vote) &&
424+
typeof (item as { starred?: unknown }).starred === "boolean";
425+
});
426+
}
427+
277428
function counterKey(site: string, resource: string): string {
278429
return `cppsocial.feedback.v1.${site}.reaction.${resource}`;
279430
}

‎runtime/src/auth/controller.ts‎

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,10 @@ export class Authentication {
9696
signal,
9797
);
9898
const token = await this.#service.exchange(code, authorization.state, verifier, signal);
99-
await validateViewer(this.#fetch, token.value, signal);
100-
this.#tokens.set(token);
101-
return token;
99+
const viewerId = await validateViewer(this.#fetch, token.value, signal);
100+
const authenticated = { ...token, viewerId };
101+
this.#tokens.set(authenticated);
102+
return authenticated;
102103
} catch (error) {
103104
this.#tokens.clear();
104105
throw error;
@@ -171,7 +172,7 @@ async function validateViewer(
171172
fetch: typeof globalThis.fetch,
172173
token: string,
173174
signal?: AbortSignal,
174-
): Promise<void> {
175+
): Promise<string> {
175176
const init: RequestInit = {
176177
method: "POST",
177178
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
@@ -181,17 +182,21 @@ async function validateViewer(
181182
const response = await fetch("https://api.github.com/graphql", init);
182183
if (!response.ok) throw new AuthenticationError("token_invalid");
183184
const body: unknown = await response.json();
184-
if (!hasViewer(body)) throw new AuthenticationError("token_invalid");
185+
const viewerId = viewer(body);
186+
if (viewerId === null) throw new AuthenticationError("token_invalid");
187+
return viewerId;
185188
}
186189

187-
function hasViewer(value: unknown): boolean {
188-
if (!value || typeof value !== "object") return false;
190+
function viewer(value: unknown): string | null {
191+
if (!value || typeof value !== "object") return null;
189192
const data = (value as { data?: unknown }).data;
190-
if (!data || typeof data !== "object") return false;
193+
if (!data || typeof data !== "object") return null;
191194
const viewer = (data as { viewer?: unknown }).viewer;
192-
if (!viewer || typeof viewer !== "object") return false;
195+
if (!viewer || typeof viewer !== "object") return null;
193196
const candidate = viewer as { id?: unknown; login?: unknown };
194-
return typeof candidate.id === "string" && typeof candidate.login === "string";
197+
return typeof candidate.id === "string" && typeof candidate.login === "string"
198+
? candidate.id
199+
: null;
195200
}
196201

197202
function randomBase64Url(crypto: Crypto, size: number): string {

‎runtime/src/auth/session.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ interface StoredToken {
66
value: string;
77
expiresAt: number;
88
creationGrant: string;
9+
viewerId: string;
910
}
1011

1112
export class SessionTokenStore {
@@ -29,6 +30,8 @@ export class SessionTokenStore {
2930
!parsed.value.startsWith("ghu_") ||
3031
!Number.isSafeInteger(parsed.expiresAt) ||
3132
typeof parsed.creationGrant !== "string" ||
33+
typeof parsed.viewerId !== "string" ||
34+
parsed.viewerId.length === 0 ||
3235
(parsed.expiresAt ?? 0) * 1000 <= this.#clock()
3336
) {
3437
this.clear();
@@ -38,6 +41,7 @@ export class SessionTokenStore {
3841
value: parsed.value,
3942
expiresAt: parsed.expiresAt,
4043
creationGrant: parsed.creationGrant,
44+
viewerId: parsed.viewerId,
4145
} as AccessToken;
4246
} catch {
4347
this.clear();

0 commit comments

Comments
 (0)