Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/api/cache-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,12 @@ export class CacheQuery<Data> {
return this._data;
}

await this._currentQueryPromise;
try {
await this._currentQueryPromise;
} catch {
// Rejection is handled in revalidate(), which shares this promise;
// awaiting here must not rethrow past onError.
}
return this._data;
}

Expand Down
139 changes: 92 additions & 47 deletions src/api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
NpmInfo,
} from './types';

/** For transform: blog-constructor supports only Lang.En | Lang.Ru (uikit) */

Check warning on line 30 in src/api/server.ts

View workflow job for this annotation

GitHub Actions / Verify Files

Missing JSDoc for parameter 'locale'

Check warning on line 30 in src/api/server.ts

View workflow job for this annotation

GitHub Actions / Verify Files

Missing JSDoc @returns for function
function getTransformLang(locale: string): Lang {
return locale === 'ru' ? Lang.Ru : Lang.En;
}
Expand Down Expand Up @@ -75,11 +75,11 @@

this.libsCache = libsConfigs.reduce<Record<LibConfig['id'], CacheQuery<LibWithFullData>>>(
(acc, lib) => {
acc[lib.id] = new CacheQuery<LibWithFullData>({

Check warning on line 78 in src/api/server.ts

View workflow job for this annotation

GitHub Actions / Verify Files

Assignment to property of function parameter 'acc'
ttl: {hours: 1},
queryFn: () => this.fetchLibById(lib.id),
onError: (error) =>
console.error(`Error updating lib cache for ${lib.id}:`, error),

Check warning on line 82 in src/api/server.ts

View workflow job for this annotation

GitHub Actions / Verify Files

Unexpected console statement
});

return acc;
Expand All @@ -90,7 +90,7 @@
this.contributorsCache = new CacheQuery<Contributor[]>({
ttl: {hours: 24},
queryFn: () => this.fetchAllContributors(),
onError: (error) => console.error('Error updating contributors cache:', error),

Check warning on line 93 in src/api/server.ts

View workflow job for this annotation

GitHub Actions / Verify Files

Unexpected console statement
});
}

Expand All @@ -104,8 +104,8 @@
const contributors = items
.filter(({login}) => login && !this.CONTRIBUTOR_IGNORE_LIST.includes(login))
.map(({login, avatar_url: avatarUrl, html_url: url, contributions}) => ({
login: login!,

Check warning on line 107 in src/api/server.ts

View workflow job for this annotation

GitHub Actions / Verify Files

Forbidden non-null assertion
avatarUrl: avatarUrl!,

Check warning on line 108 in src/api/server.ts

View workflow job for this annotation

GitHub Actions / Verify Files

Forbidden non-null assertion
url: url!,
contributions,
}));
Expand Down Expand Up @@ -136,13 +136,18 @@

async fetchRepositoryCodeOwners(repoOwner: string, repo: string): Promise<CodeOwners[]> {
const url = `https://raw.githubusercontent.com/${repoOwner}/${repo}/main/CODEOWNERS`;
const res = await fetch(url);

if (!res.ok) {
let codeOwnersText: string | null = null;
try {
codeOwnersText = await this.fetchGithubRawFile(url);
} catch (err) {
console.error(err);
}

if (!codeOwnersText) {
return [];
}

const codeOwnersText = await res.text();
const lines = codeOwnersText.split('\n');
const codeOwners: CodeOwners[] = [];

Expand Down Expand Up @@ -301,15 +306,8 @@
async fetchChangelogInfo(changelogUrl: string): Promise<string> {
if (!changelogUrl) return '';

const headers: Record<string, string> = {'User-Agent': 'request'};

try {
const response = await fetch(changelogUrl, {
headers,
});
if (response.ok) {
return await response.text();
}
return (await this.fetchGithubRawFile(changelogUrl)) ?? '';
} catch (err) {
console.error(err);
}
Expand All @@ -328,16 +326,9 @@
pt: string;
ja: string;
}> {
const headers: Record<string, string> = {'User-Agent': 'request'};

const fetchReadmeContent = async (url: string) => {
try {
const response = await fetch(url, {
headers,
});
if (response.ok) {
return await response.text();
}
return (await this.fetchGithubRawFile(url)) ?? '';
} catch (err) {
console.error(err);
}
Expand Down Expand Up @@ -507,36 +498,33 @@
componentId: string;
locale: string;
}): Promise<string> {
let readmeContent = '';
let readmeContent: string | null = null;

const headers: Record<string, string> = {'User-Agent': 'request'};

try {
if (locale !== 'en' && locale !== 'ru') {
try {
readmeContent = await import(
`../content/local-docs/components/${libId}/${componentId}/README-${locale}.md`
).then((module) => module.default);
} catch (err) {
console.warn(
`Can't find local docs for "${componentId}", library "${libId}", lang "${locale}"`,
);
}
} else {
const res = await fetch(readmeUrl[locale], {headers});
if (res.status >= 200 && res.status < 300) {
readmeContent = await res.text();
}
if (locale !== 'en' && locale !== 'ru') {
try {
readmeContent = await import(
`../content/local-docs/components/${libId}/${componentId}/README-${locale}.md`
).then((module) => module.default);
} catch (err) {
console.warn(
`Can't find local docs for "${componentId}", library "${libId}", lang "${locale}"`,
);
}
} else {
readmeContent = await this.fetchGithubRawFile(readmeUrl[locale]);
}

if (!readmeContent && locale !== i18n.defaultLocale) {
const fallbackRes = await fetch(readmeUrl[i18n.defaultLocale], {headers});
if (fallbackRes.status >= 200 && fallbackRes.status < 300) {
readmeContent = await fallbackRes.text();
}
}
} catch (err) {
console.warn('Error fetching component README:', err);
if (!readmeContent && locale !== i18n.defaultLocale) {
readmeContent = await this.fetchGithubRawFile(readmeUrl[i18n.defaultLocale]);
}

// Throwing (instead of returning '') keeps CacheQuery in the error state,
// so a transient fetch failure is retried on the next request instead of
// being cached as valid empty content for the whole TTL.
if (!readmeContent) {
throw new Error(
`Got empty README for component "${componentId}" (lib "${libId}", locale "${locale}")`,
);
}

return readmeContent;
Expand Down Expand Up @@ -564,7 +552,13 @@
});
}

const content = await this.componentsReadmeCache?.[cacheKey]?.getData?.();
// Waiting for revalidation (instead of immediately returning a possibly
// empty cache) means a request hitting a stale or errored cache entry gets
// fresh content rather than a 500. Stale-but-present data is still served
// if the refetch fails.
const content = await this.componentsReadmeCache?.[cacheKey]?.getData?.({
immediateResponse: false,
});

if (!content) {
throw new Error(`Can't find README for ${cacheKey}`);
Expand Down Expand Up @@ -762,4 +756,55 @@
// TODO: Implement when connecting to real API
return [];
}

/**
* Fetches a file that would normally be requested from raw.githubusercontent.com
* through the authenticated GitHub API client. Unauthenticated raw.githubusercontent.com
* requests share a per-IP rate limit, which intermittently produced empty responses
* in production (see issue #729), while API requests count against the much higher
* app/token limit. Returns null when the file doesn't exist (404) so callers can
* fall back to another locale; throws on other errors after one retry so failures
* are not cached as valid content.
* @param url - Original raw.githubusercontent.com file URL
* @returns File content, or null if the file doesn't exist
*/
private async fetchGithubRawFile(url: string): Promise<string | null> {
const match = url.match(
/^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/,
);

if (!match) {
const response = await fetch(url, {headers: {'User-Agent': 'request'}});
return response.ok ? await response.text() : null;
}

const [, owner, repo, ref, filePath] = match;

let lastError: unknown;
for (let attempt = 0; attempt < 2; attempt++) {
if (attempt > 0) {
await new Promise((resolve) => setTimeout(resolve, 500));
}

try {
const response = await this.octokit.rest.repos.getContent({
owner,
repo,
path: filePath,
ref,
mediaType: {format: 'raw'},
});

return response.data as unknown as string;
} catch (error) {
if ((error as {status?: number}).status === 404) {
return null;
}

lastError = error;
}
}

throw lastError;
}
}
8 changes: 5 additions & 3 deletions src/pages/components/[libId]/[componentId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
};
}

// Components without a readme (e.g. isComingSoon) have no page — respond
// with 404 instead of an unhandled error that turns into a 500.
if (!component.content?.readmeUrl) {
throw new Error(
`Component "${ctx.params?.componentId}" in library "${ctx.params?.libId}" doesn't have url for readme file`,
);
return {
notFound: true,
};
}

const locale = ctx.locale ?? i18nextConfig.i18n.defaultLocale;
Expand Down
Loading