feat(website): live community stats, drop hard-coded fallback (#23047)

## Problem

The menu's GitHub star and Discord member counts almost always render
the hard-coded snapshot (49.6K / 6.6K, frozen June 2026), not live
numbers. The render-time fetches run unauthenticated from Cloudflare
Workers, whose egress IPs are shared across tenants; GitHub's
unauthenticated quota is 60 req/hr per IP, so the call is effectively
always rate-limited. Live prod today shows the frozen 49.6K GitHub count
next to a live Discord count, confirming only GitHub is affected.

## Change

- GitHub fetch sends `Authorization: Bearer $GITHUB_STATS_TOKEN` when
the env var is set, moving it onto its own 5,000 req/hr quota. Discord
keeps the public invite endpoint, which works fine from Cloudflare's
IPs.
- The hard-coded fallback is deleted rather than refreshed.
`CommunityStats` fields are now `number | null`, resolved live ->
last-good -> null, and the menu renders an icon-only chip when a count
is genuinely unavailable. A fake number can never ship.
- Last-good values persist in the worker's existing OpenNext R2 bucket
(`NEXT_INC_CACHE_R2_BUCKET`, key `community-stats/latest.json` outside
the `incremental-cache/` prefix), so a third-party outage shows the
numbers from the previous refresh. No new infrastructure.
- Revalidation tightens from 1h to 15min (~4 GitHub calls/hour, shared
cache entry across pages).
- `MenuSocial`/`MenuDrawer` import `formatCompactCount` from its module
directly: the community barrel now re-exports server-only code, and a
client value-import would pull `getCloudflareContext` into the client
bundle.

## Rollout

- twentyhq/twenty-infra#795 passes the built-in Actions token at build
time so prerendered pages ship with a real star count from the first
request after deploy.
- One manual step: set the `GITHUB_STATS_TOKEN` secret (fine-grained
PAT, public read-only, no permissions) on the twenty-website-dev and
twenty-website-prod workers. Until it exists, behavior degrades to
today's minus the fake numbers.

## Tests

5 unit tests cover the resolution ladder: live wins, cache fills a
failed fetch, null on cold-cache failure, both-fail serves cache without
overwriting, nothing written when nothing succeeded. Verified against
the dev server: menu renders live 53.3K / 6.9K.
This commit is contained in:
Abdullah.
2026-07-24 12:44:05 +05:00
committed by GitHub
parent d1b556f4a8
commit cada1ef6d7
8 changed files with 366 additions and 43 deletions
@@ -0,0 +1,95 @@
import { getCloudflareContext } from '@opennextjs/cloudflare';
import { communityStatsStore } from './community-stats-store';
jest.mock('server-only', () => ({}));
jest.mock('@opennextjs/cloudflare', () => ({
getCloudflareContext: jest.fn(),
}));
const mockedGetCloudflareContext = getCloudflareContext as jest.Mock;
const buildBucket = () => ({ get: jest.fn(), put: jest.fn() });
const bindBucket = (bucket: ReturnType<typeof buildBucket>) => {
mockedGetCloudflareContext.mockResolvedValue({
env: { NEXT_INC_CACHE_R2_BUCKET: bucket },
});
};
const storedObject = (value: unknown) => ({
text: async () => JSON.stringify(value),
});
describe('communityStatsStore', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should read stored stats', async () => {
const bucket = buildBucket();
bucket.get.mockResolvedValue(
storedObject({ githubStars: 50000, discordMembers: 6900 }),
);
bindBucket(bucket);
await expect(communityStatsStore.read()).resolves.toEqual({
githubStars: 50000,
discordMembers: 6900,
});
});
it('should return null when no bucket is bound', async () => {
mockedGetCloudflareContext.mockResolvedValue({ env: {} });
await expect(communityStatsStore.read()).resolves.toBeNull();
});
it('should keep the stored value for a field the caller has no value for', async () => {
const bucket = buildBucket();
bucket.get.mockResolvedValue(
storedObject({ githubStars: 50000, discordMembers: 6900 }),
);
bindBucket(bucket);
await communityStatsStore.write({
githubStars: 50123,
discordMembers: null,
});
expect(bucket.put).toHaveBeenCalledWith(
'community-stats/latest.json',
JSON.stringify({ githubStars: 50123, discordMembers: 6900 }),
);
});
it('should skip the write when the stored entry cannot be read', async () => {
const bucket = buildBucket();
bucket.get.mockRejectedValue(new Error('bucket unavailable'));
bindBucket(bucket);
await communityStatsStore.write({
githubStars: 50123,
discordMembers: null,
});
expect(bucket.put).not.toHaveBeenCalled();
});
it('should overwrite a corrupt stored entry', async () => {
const bucket = buildBucket();
bucket.get.mockResolvedValue({ text: async () => 'not json' });
bindBucket(bucket);
await communityStatsStore.write({
githubStars: 50123,
discordMembers: null,
});
expect(bucket.put).toHaveBeenCalledWith(
'community-stats/latest.json',
JSON.stringify({ githubStars: 50123, discordMembers: null }),
);
});
});
@@ -0,0 +1,82 @@
import 'server-only';
import { getCloudflareContext } from '@opennextjs/cloudflare';
import { isDefined } from 'twenty-shared/utils';
import { type CommunityStats } from './get-community-stats';
import { readFiniteNumber } from './read-finite-number';
const STORE_KEY = 'community-stats/latest.json';
type CommunityStatsBucket = {
get: (key: string) => Promise<{ text: () => Promise<string> } | null>;
put: (key: string, value: string) => Promise<unknown>;
};
const isCommunityStatsBucket = (
value: unknown,
): value is CommunityStatsBucket =>
isDefined(value) &&
typeof (value as CommunityStatsBucket).get === 'function' &&
typeof (value as CommunityStatsBucket).put === 'function';
const getBucket = async (): Promise<CommunityStatsBucket | null> => {
try {
const { env } = await getCloudflareContext({ async: true });
const binding = (env as { NEXT_INC_CACHE_R2_BUCKET?: unknown })
.NEXT_INC_CACHE_R2_BUCKET;
return isCommunityStatsBucket(binding) ? binding : null;
} catch {
return null;
}
};
const readStoredStats = async (
bucket: CommunityStatsBucket,
): Promise<CommunityStats | null> => {
const storedObject = await bucket.get(STORE_KEY);
if (!isDefined(storedObject)) return null;
const raw = await storedObject.text();
try {
const parsed = JSON.parse(raw) as {
githubStars?: unknown;
discordMembers?: unknown;
};
return {
githubStars: readFiniteNumber(parsed.githubStars),
discordMembers: readFiniteNumber(parsed.discordMembers),
};
} catch {
return null;
}
};
export const communityStatsStore = {
read: async (): Promise<CommunityStats | null> => {
const bucket = await getBucket();
if (!isDefined(bucket)) return null;
try {
return await readStoredStats(bucket);
} catch {
return null;
}
},
write: async (stats: CommunityStats): Promise<void> => {
const bucket = await getBucket();
if (!isDefined(bucket)) return;
try {
const stored = await readStoredStats(bucket);
const entry: CommunityStats = {
githubStars: stats.githubStars ?? stored?.githubStars ?? null,
discordMembers: stats.discordMembers ?? stored?.discordMembers ?? null,
};
await bucket.put(STORE_KEY, JSON.stringify(entry));
} catch {
return;
}
},
};
@@ -1,6 +1,10 @@
// 49600 -> "49.6K" (en) / "49,6 k" (fr) — compact notation in the visitor's
// locale, like every other number on the site.
export function formatCompactCount(value: number, locale: string): string {
import { isDefined } from 'twenty-shared/utils';
export function formatCompactCount(
value: number | null,
locale: string,
): string | null {
if (!isDefined(value)) return null;
return new Intl.NumberFormat(locale, {
notation: 'compact',
maximumFractionDigits: 1,
@@ -0,0 +1,106 @@
import { communityStatsStore } from './community-stats-store';
import { getCommunityStats } from './get-community-stats';
jest.mock('server-only', () => ({}));
jest.mock('./community-stats-store', () => ({
communityStatsStore: { read: jest.fn(), write: jest.fn() },
}));
const mockedReadCache = communityStatsStore.read as jest.Mock;
const mockedWriteCache = communityStatsStore.write as jest.Mock;
const ORIGINAL_FETCH = global.fetch;
const githubResponse = (stars: number) =>
new Response(JSON.stringify({ stargazers_count: stars }), { status: 200 });
const discordResponse = (members: number) =>
new Response(JSON.stringify({ approximate_member_count: members }), {
status: 200,
});
const failedResponse = () => new Response('rate limited', { status: 403 });
describe('getCommunityStats', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(console, 'error').mockImplementation(() => {});
mockedReadCache.mockResolvedValue(null);
mockedWriteCache.mockResolvedValue(undefined);
});
afterEach(() => {
global.fetch = ORIGINAL_FETCH;
});
it('should return live numbers when both APIs succeed', async () => {
global.fetch = jest
.fn()
.mockResolvedValueOnce(githubResponse(50123))
.mockResolvedValueOnce(discordResponse(6712));
const stats = await getCommunityStats();
expect(stats).toEqual({ githubStars: 50123, discordMembers: 6712 });
expect(mockedReadCache).not.toHaveBeenCalled();
expect(mockedWriteCache).toHaveBeenCalledWith({
githubStars: 50123,
discordMembers: 6712,
});
});
it('should fall back to the cached value when a fetch fails', async () => {
mockedReadCache.mockResolvedValue({
githubStars: 49999,
discordMembers: 6666,
});
global.fetch = jest
.fn()
.mockResolvedValueOnce(failedResponse())
.mockResolvedValueOnce(discordResponse(6800));
const stats = await getCommunityStats();
expect(stats).toEqual({ githubStars: 49999, discordMembers: 6800 });
});
it('should return null when a fetch fails and the cache is empty', async () => {
global.fetch = jest
.fn()
.mockResolvedValueOnce(failedResponse())
.mockResolvedValueOnce(discordResponse(6800));
const stats = await getCommunityStats();
expect(stats).toEqual({ githubStars: null, discordMembers: 6800 });
});
it('should return nulls and not write the cache when everything fails', async () => {
global.fetch = jest
.fn()
.mockResolvedValueOnce(failedResponse())
.mockResolvedValueOnce(failedResponse());
const stats = await getCommunityStats();
expect(stats).toEqual({ githubStars: null, discordMembers: null });
expect(mockedWriteCache).not.toHaveBeenCalled();
});
it('should serve cached values without writing when both fetches fail', async () => {
mockedReadCache.mockResolvedValue({
githubStars: 49999,
discordMembers: 6666,
});
global.fetch = jest
.fn()
.mockResolvedValueOnce(failedResponse())
.mockResolvedValueOnce(failedResponse());
const stats = await getCommunityStats();
expect(stats).toEqual({ githubStars: 49999, discordMembers: 6666 });
expect(mockedWriteCache).not.toHaveBeenCalled();
});
});
@@ -1,51 +1,83 @@
import 'server-only';
import { isDefined } from 'twenty-shared/utils';
import { communityStatsStore } from './community-stats-store';
import { readFiniteNumber } from './read-finite-number';
export type CommunityStats = {
discordMembers: number;
githubStars: number;
discordMembers: number | null;
githubStars: number | null;
};
// Live numbers when reachable, recent-real fallbacks when not — so the menu
// never renders empty chips and never blocks on a third party.
// Snapshot of 2026-06-10; refresh when the live numbers drift visibly.
const FALLBACK_STATS: CommunityStats = {
discordMembers: 6600,
githubStars: 49600,
};
const REVALIDATE_SECONDS = 900;
const REVALIDATE_SECONDS = 3600;
const GITHUB_REPO_URL = 'https://api.github.com/repos/twentyhq/twenty';
const DISCORD_INVITE_URL =
'https://discord.com/api/v9/invites/cx5n4Jzs57?with_counts=true';
const fetchJson = async (url: string): Promise<unknown> => {
const response = await fetch(url, {
next: { revalidate: REVALIDATE_SECONDS },
});
if (!response.ok) throw new Error(`${url} -> ${response.status}`);
return response.json();
};
async function fetchGithubStars(): Promise<number | null> {
const token = process.env.GITHUB_STATS_TOKEN;
try {
const response = await fetch(GITHUB_REPO_URL, {
headers: {
Accept: 'application/vnd.github+json',
'User-Agent': 'twenty-website',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
next: { revalidate: REVALIDATE_SECONDS },
});
if (!response.ok) {
console.error(
`community stats: ${GITHUB_REPO_URL} -> ${response.status}`,
);
return null;
}
const body = (await response.json()) as { stargazers_count?: unknown };
return readFiniteNumber(body.stargazers_count);
} catch {
return null;
}
}
const readNumber = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null;
async function fetchDiscordMembers(): Promise<number | null> {
try {
const response = await fetch(DISCORD_INVITE_URL, {
next: { revalidate: REVALIDATE_SECONDS },
});
if (!response.ok) {
console.error(
`community stats: ${DISCORD_INVITE_URL} -> ${response.status}`,
);
return null;
}
const body = (await response.json()) as {
approximate_member_count?: unknown;
};
return readFiniteNumber(body.approximate_member_count);
} catch {
return null;
}
}
export async function getCommunityStats(): Promise<CommunityStats> {
const [github, discord] = await Promise.allSettled([
fetchJson('https://api.github.com/repos/twentyhq/twenty'),
fetchJson('https://discord.com/api/v9/invites/cx5n4Jzs57?with_counts=true'),
const [githubStars, discordMembers] = await Promise.all([
fetchGithubStars(),
fetchDiscordMembers(),
]);
const githubStars =
github.status === 'fulfilled'
? readNumber(
(github.value as { stargazers_count?: unknown }).stargazers_count,
)
: null;
const discordMembers =
discord.status === 'fulfilled'
? readNumber(
(discord.value as { approximate_member_count?: unknown })
.approximate_member_count,
)
: null;
const hasMissingLiveValue =
!isDefined(githubStars) || !isDefined(discordMembers);
const cached = hasMissingLiveValue ? await communityStatsStore.read() : null;
return {
discordMembers: discordMembers ?? FALLBACK_STATS.discordMembers,
githubStars: githubStars ?? FALLBACK_STATS.githubStars,
const resolved: CommunityStats = {
githubStars: githubStars ?? cached?.githubStars ?? null,
discordMembers: discordMembers ?? cached?.discordMembers ?? null,
};
if (isDefined(githubStars) || isDefined(discordMembers)) {
await communityStatsStore.write(resolved);
}
return resolved;
}
@@ -0,0 +1,2 @@
export const readFiniteNumber = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null;
@@ -7,7 +7,8 @@ import { useLingui } from '@lingui/react';
import { styled } from '@linaria/react';
import { Fragment, useState } from 'react';
import { formatCompactCount, type CommunityStats } from '@/platform/community';
import { formatCompactCount } from '@/platform/community/format-compact-count';
import { type CommunityStats } from '@/platform/community';
import { useLocale } from '@/platform/i18n';
import { LocalizedLink } from '@/platform/i18n/LocalizedLink';
import {
@@ -2,7 +2,8 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { styled } from '@linaria/react';
import { formatCompactCount, type CommunityStats } from '@/platform/community';
import { formatCompactCount } from '@/platform/community/format-compact-count';
import { type CommunityStats } from '@/platform/community';
import { useLocale } from '@/platform/i18n';
import {
color,