diff --git a/apps/site/components/Common/Supporters/index.tsx b/apps/site/components/Common/Supporters/index.tsx index e9cbfa387ed99..2797e94241ed9 100644 --- a/apps/site/components/Common/Supporters/index.tsx +++ b/apps/site/components/Common/Supporters/index.tsx @@ -6,18 +6,18 @@ import type { Supporter } from '#site/types'; import type { FC } from 'react'; type SupportersListProps = { - supporters: Array>; + supporters: Array>; }; const SupportersList: FC = ({ supporters }) => (
- {supporters.map(({ name, image, profile }) => ( + {supporters.map(({ name, image, source, url }) => ( ))}
diff --git a/apps/site/next-data/generators/supportersData.mjs b/apps/site/next-data/generators/supportersData.mjs index 0a3423918ea1a..371521166899a 100644 --- a/apps/site/next-data/generators/supportersData.mjs +++ b/apps/site/next-data/generators/supportersData.mjs @@ -1,5 +1,85 @@ -import { OPENCOLLECTIVE_MEMBERS_URL } from '#site/next.constants.mjs'; +import { + OPENCOLLECTIVE_MEMBERS_URL, + GITHUB_GRAPHQL_URL, + GITHUB_READ_API_KEY, +} from '#site/next.constants.mjs'; import { fetchWithRetry } from '#site/next.fetch.mjs'; +import { shuffle } from '#site/util/array'; + +const SPONSORSHIPS_QUERY = ` + query ($cursor: String) { + organization(login: "nodejs") { + sponsorshipsAsMaintainer( + first: 100 + includePrivate: false + after: $cursor + activeOnly: false + ) { + nodes { + sponsor: sponsorEntity { + ...on User { + id: databaseId + name + login + avatarUrl + url + websiteUrl + } + ...on Organization { + id: databaseId + name + login + avatarUrl + url + websiteUrl + } + } + } + pageInfo { + endCursor + startCursor + hasNextPage + hasPreviousPage + } + } + } + } +`; + +const DONATIONS_QUERY = ` + query { + organization(login: "nodejs") { + sponsorsActivities(first: 100, includePrivate: false) { + nodes { + id + sponsor { + ...on User { + id: databaseId + name + login + avatarUrl + url + websiteUrl + } + ...on Organization { + id: databaseId + name + login + avatarUrl + url + websiteUrl + } + } + timestamp + tier: sponsorsTier { + monthlyPriceInDollars + isOneTime + } + } + } + } + } +`; /** * Fetches supporters data from Open Collective API, filters active backers, @@ -15,12 +95,11 @@ async function fetchOpenCollectiveData() { const members = payload .filter(({ role, isActive }) => role === 'BACKER' && isActive) .sort((a, b) => b.totalAmountDonated - a.totalAmountDonated) - .map(({ name, website, image, profile }) => ({ + .map(({ name, image, profile }) => ({ name, image, - url: website, // If profile starts with the guest- prefix, it's a non-existing account - profile: profile.startsWith('https://opencollective.com/guest-') + url: profile.startsWith('https://opencollective.com/guest-') ? undefined : profile, source: 'opencollective', @@ -29,4 +108,136 @@ async function fetchOpenCollectiveData() { return members; } -export default fetchOpenCollectiveData; +/** + * Fetches supporters data from Github API, filters active backers, + * and maps it to the Supporters type. + * + * @returns {Promise>} Array of supporters + */ +async function fetchGithubSponsorsData() { + if (!GITHUB_READ_API_KEY) { + return []; + } + + const [sponsorships, donations] = await Promise.all([ + fetchSponsorshipsQuery(), + fetchDonationsQuery(), + ]); + + return [...sponsorships, ...donations]; +} + +async function fetchSponsorshipsQuery() { + const sponsors = []; + let cursor = null; + + while (true) { + const data = await graphql( + SPONSORSHIPS_QUERY, + cursor ? { cursor } : undefined + ); + + if (data.errors) { + throw new Error(JSON.stringify(data.errors)); + } + + const nodeRes = data.data.organization?.sponsorshipsAsMaintainer; + if (!nodeRes) { + break; + } + + const { nodes, pageInfo } = nodeRes; + const mapped = nodes.map(n => { + const s = n.sponsor || n.sponsorEntity; // support different field names + return { + name: s?.name || s?.login || null, + image: s?.avatarUrl || null, + url: s?.url || null, + source: 'github', + }; + }); + + sponsors.push(...mapped); + + if (!pageInfo.hasNextPage) { + break; + } + + cursor = pageInfo.endCursor; + } + + return sponsors; +} + +async function fetchDonationsQuery() { + const data = await graphql(DONATIONS_QUERY); + + if (data.errors) { + throw new Error(JSON.stringify(data.errors)); + } + + const nodeRes = data.data.organization?.sponsorsActivities; + if (!nodeRes) { + return []; + } + + const { nodes } = nodeRes; + return nodes.map(n => { + const s = n.sponsor || n.sponsorEntity; // support different field names + return { + name: s?.name || s?.login || null, + image: s?.avatarUrl || null, + url: s?.url || null, + source: 'github', + }; + }); +} + +const graphql = async (query, variables = {}) => { + const res = await fetchWithRetry(GITHUB_GRAPHQL_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${GITHUB_READ_API_KEY}`, + }, + body: JSON.stringify({ query, variables }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`GitHub API error: ${res.status} ${text}`); + } + + return res.json(); +}; + +/** + * Fetches supporters data from Open Collective API and GitHub Sponsors, filters active backers, + * and maps it to the Supporters type. + * + * @returns {Promise>} Array of supporters + */ +async function sponsorsData() { + const seconds = 300; // Change every 5 minutes + const seed = Math.floor(Date.now() / (seconds * 1000)); + + const sponsorsResults = await Promise.allSettled([ + fetchGithubSponsorsData(), + fetchOpenCollectiveData(), + ]); + + const sponsors = sponsorsResults.flatMap(result => { + if (result.status === 'fulfilled') { + return result.value; + } + + console.error('Supporters data source failed:', result.reason); + return []; + }); + + const shuffled = await shuffle(sponsors, seed); + + return shuffled; +} + +export default sponsorsData; diff --git a/apps/site/next.constants.mjs b/apps/site/next.constants.mjs index 1a4ca677ba8bc..7bb3fdc29bcde 100644 --- a/apps/site/next.constants.mjs +++ b/apps/site/next.constants.mjs @@ -180,7 +180,7 @@ export const ORAMA_CLOUD_PROJECT_ID = * * Note: This has no NEXT_PUBLIC prefix as it should not be exposed to the Browser. */ -export const GITHUB_API_KEY = process.env.NEXT_GITHUB_API_KEY || ''; +export const GITHUB_READ_API_KEY = process.env.NEXT_GITHUB_READ_API_KEY || ''; /** * The resource we point people to when discussing internationalization efforts. @@ -220,3 +220,8 @@ export const VULNERABILITIES_URL = */ export const OPENCOLLECTIVE_MEMBERS_URL = 'https://opencollective.com/nodejs/members/all.json'; + +/** + * The location of the GitHub GraphQL API + */ +export const GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql'; diff --git a/apps/site/pages/en/about/partners.mdx b/apps/site/pages/en/about/partners.mdx index 7fd69d685fdfc..5498ebbdab72b 100644 --- a/apps/site/pages/en/about/partners.mdx +++ b/apps/site/pages/en/about/partners.mdx @@ -22,8 +22,8 @@ without we can't test and release new versions of Node.js. ## Supporters -Supporters are individuals and organizations that provide financial support through -[OpenCollective](https://opencollective.com/nodejs) of the Node.js project. +Supporters are individuals and organizations who financially support the Node.js project +through [OpenCollective](https://opencollective.com/nodejs) and [GitHub Sponsors](https://github.com/sponsors/nodejs). diff --git a/apps/site/types/supporters.ts b/apps/site/types/supporters.ts index ea7b06008cfbd..b97dddf5089d1 100644 --- a/apps/site/types/supporters.ts +++ b/apps/site/types/supporters.ts @@ -7,3 +7,4 @@ export type Supporter = { }; export type OpenCollectiveSupporter = Supporter<'opencollective'>; +export type GitHubSponsorSupporter = Supporter<'github'>;