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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user