perf(slug): point-lookup instead of full discovery for single-repo pages and 404 #27
Labels
No labels
bug
performance
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
slaid098/slaid098-dev#27
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Контекст
Зачем: страницы одиночного репо (
/[locale]/[slug]) и 404 на неизвестном slug медленные (cold cache ~1.5-2с) из-за того, что для разрешения одного slug вызывается list-функцияgetShowcaseRepos(), делающая 7 последовательных запросов к Forgejo API. Для 404 (несуществующий slug) страница сначала делает все 7 запросов, и только потом вызываетnotFound().Контекст (текущая архитектура, подтверждено чтением файлов):
src/lib/readme-discovery.ts—discoverShowcaseRepos(owner, token, fetchOptions)(строка 188):GET /users/{owner}/repos?limit=50→ список всех репо (1 запрос, строка 196)!r.fork && !r.private && r.name !== "slaid098-dev"(строка 202)for (const repo of candidates)(строка 205,for...of+await), для КАЖДОГО кандидата:GET /repos/{owner}/{repo.name}— detail (строки 207-217)fetchReadme(...)— readme (строка 220)fetchCoverMeta(...)— cover meta (строка 221)DiscoveredRepo[]. Вспомогательные функции:fetchReadme(строка 155),fetchCoverMeta(строка 135),parseReadme(строка 104),makeErrorRepo(строка 169).Реальные числа (Forgejo, аккаунт slaid098): 22 репо всего, 2 public non-fork кандидата (
opencode-config,opencode-voice-dictation), 19 private (отфильтрованы). Cold call: 1 (list) + 2×3 (detail+readme+cover) = 7 последовательных запросов, ~1.5-2с (7 RTT × ~80-100ms). Warm cache: 0 запросов (revalidate=3600,cache: "force-cache", tagshowcase). Инвалидация: webhook (revalidateTag("showcase")on push) и cron (/api/cron-refresh).src/lib/showcase.ts(43 строки):getShowcaseRepos=cache(_getShowcaseRepos)(строка 19) — обёртка Reactcache()поверхdiscoverShowcaseRepos. Дедуплит per-request (generateMetadata + Page шарят один вызов), НЕ персистит между запросами.findRepo(repos, slug)(строка 25) — линейный поиск по slug в массиве.src/app/[locale]/[slug]/page.tsx(194 строки):generateStaticParams(строки 20-27) — возвращает известные slug'и build-time черезgetShowcaseRepos()(try/catch fallback[]).generateMetadata(строки 29-59):getShowcaseRepos()(строка 35) +findRepo(repos, slug)(строка 36).Page(строки 61-163):src/app/[locale]/apps/page.tsx(119 строк):readyRepos(await getShowcaseRepos())(строка 39) — КОРРЕКТНОЕ использование list-функции (нужны все репо). БЕЗ изменений.Задача
В
src/lib/readme-discovery.ts— вынести тело цикла (строки 206-261) в отдельную экспортируемую функцию точечного поиска одного репо, напримерdiscoverSingleRepo(owner, repoName, token, fetchOptions): Promise<DiscoveredRepo | null>. Логика:GET /repos/{owner}/{repoName}— один запрос detail. 404 →return null(slug не существует). 200 → репо есть.name === "slaid098-dev"→return null(не подходит для showcase, трактуется как 404).fetchReadme+fetchCoverMeta+parseReadme(те же 3-4 запроса: detail + readme + cover, как в цикле).cache: "force-cache"+next: { revalidate: 3600, tags: ["showcase"] }опции кеширования.fetchReadme(строка 155),fetchCoverMeta(строка 135),parseReadme(строка 104),makeErrorRepo(строка 169).discoverShowcaseRepos(строки 206-261) должно вызывать новуюdiscoverSingleRepo(или общую логику) — чтобы list и single разделяли per-repo логику, без дублирования.В
src/lib/showcase.ts— добавитьgetShowcaseRepo(slug): Promise<ShowcaseRepo | null>:cache()(дедуп per-request междуPageиgenerateMetadata).discoverSingleRepo(SHOWCASE_OWNER, slug, FORGEJO_TOKEN, { next: { tags: ["showcase"], revalidate: 3600 } }).nullесли репо не найдено/не eligible.В
src/app/[locale]/[slug]/page.tsx:Page(строка 69-73): заменитьgetShowcaseRepos()+findRepo()наgetShowcaseRepo(slug). Еслиnull→notFound(). Иначе рендер с одним репо.generateMetadata(строки 35-37): заменитьgetShowcaseRepos()+findRepo()наgetShowcaseRepo(slug). Еслиnull→return {}.generateStaticParams(строки 20-27): БЕЗ изменений — оставляетgetShowcaseRepos()(нужны все slug'и build-time, это корректно).findRepo,getShowcaseRepos(если не используются после замены в Page/generateMetadata;generateStaticParamsвсё ещё используетgetShowcaseRepos— оставить импорт), добавитьgetShowcaseRepo.src/app/[locale]/apps/page.tsx— БЕЗ изменений (продолжает использоватьgetShowcaseRepos()для списка).Контракты
getShowcaseRepo(slug: string): Promise<ShowcaseRepo | null>— новая публичная функция вsrc/lib/showcase.ts.discoverSingleRepo(owner: string, repoName: string, token: string, fetchOptions): Promise<DiscoveredRepo | null>— новая экспортируемая функция вsrc/lib/readme-discovery.ts.getShowcaseRepos(): Promise<ShowcaseRepo[]>— БЕЗ изменений сигнатуры и поведения.findRepo(repos, slug)— может остаться (используется тестами/совместимость) или быть удалён, если больше не используется; реализатор решает./[locale]/[slug]и/[locale]/apps— без изменений в URL/API.getShowcaseRepoиспользует те же опции (cache: "force-cache",next: { revalidate: 3600, tags: ["showcase"] }), инвалидируется тем же tagshowcase(webhook/cron).Ожидаемое количество запросов (cold cache):
/nonexistentGET /repos/{owner}/{slug}→ 404 → notFound)/ru/nonexistentGET /repos/{owner}/{slug}→ 404 → notFound)/ru/opencode-config/appsсписок/ru/appsИнварианты
cache()обёртка дляgetShowcaseRepo— дедуп per-request междуPageиgenerateMetadata.getShowcaseRepos:cache: "force-cache",next: { revalidate: 3600, tags: ["showcase"] }.null(не бросает) для: несуществующий slug (404), fork, private,slaid098-dev.generateStaticParamsпродолжать использоватьgetShowcaseRepos()— нужны все slug'и build-time.apps/page.tsxпродолжать использоватьgetShowcaseRepos()— нужен полный список.Граничные случаи
GET /repos/{owner}/{slug}→getShowcaseRepoвозвращаетnull→notFound().discoverSingleRepoвозвращаетnull(или error-репо? — согласовать:nullпроще для 404-логики; ошибка сети —null, т.к. не можем отличить от 404 без проверки статуса).slaid098-dev) →null→notFound()(трактуется как 404, не как error).DiscoveredRepoсreadmeValid: false(как в текущем цикле, строки 225-243).hasCover: false,coverSha: null(какfetchCoverMetaстроки 146-149).findRepo— точное совпадение, сохранить.cache()дедуплит в пределах запроса; data-cache Next.js дедуплит между запросами (warm).Влияние на связанные компоненты
src/lib/showcase.ts— добавляетсяgetShowcaseRepo;getShowcaseRepos/findRepoбез изменений (илиfindRepoудаляется если неиспользуем).src/lib/readme-discovery.ts— добавляетсяdiscoverSingleRepo;discoverShowcaseReposрефакторится вызывать общую per-repo логику. Существующие экспорты (parseReadme,extractBetween,parseFeaturesTable, типы) без изменений.src/app/[locale]/[slug]/page.tsx—PageиgenerateMetadataпереключаются наgetShowcaseRepo;generateStaticParamsбез изменений.src/app/[locale]/apps/page.tsx— БЕЗ изменений.src/app/[locale]/%5F%5F404%5F%5F/page.tsx— БЕЗ изменений (статичный 404 для/ru/__404__, не покрывает unknown-slug — это отдельный concern, см. "Вне scope").src/middleware.ts— БЕЗ изменений.revalidateTag("showcase")) и cron (/api/cron-refresh) — БЕЗ изменений (тот же tagshowcaseинвалидирует и list, и single).src/__tests__/slug-static-params.test.ts— БЕЗ изменений (покрываетgenerateStaticParams, который не меняется).not-found.test.tsx,middleware.test.ts,loading.test.tsx— БЕЗ изменений.Вне scope
/ru/__404__) — отдельная задача; здесь только ускоряем discovery.discoverShowcaseRepos(Promise.all вместо for...of) — list-функция для/apps, вне scope.limit=50) — сейчас 22 репо, за пределами.findRepo— опционально, на усмотрение реализатора, не отдельная задача.Критерии приемки
getShowcaseRepo("nonexistent-slug")возвращаетnull(mock Forgejo:GET /repos/{owner}/nonexistent-slug→ 404).getShowcaseRepo("opencode-config")возвращает валидныйDiscoveredRepo(mock: detail 200 + readme 200 + cover 200).src/app/[locale]/[slug]/page.tsxPageдля unknown slug вызываетnotFound()БЕЗ вызоваgetShowcaseRepos()(тест-регрессия: assert list-функция НЕ вызывается на single-slug path).generateStaticParamsвsrc/app/[locale]/[slug]/page.tsxвсё ещё используетgetShowcaseRepos()(не сломан).src/app/[locale]/apps/page.tsxрендерит список черезgetShowcaseRepos()без изменений (существующий тест/ручная проверка)./ru/nonexistent→ 1 запрос к Forgejo (GET /repos/{owner}/nonexistent→ 404), не 7./ru/opencode-config→ 3-4 запроса (detail+readme+cover), не 7.npm run lint(ruff/eslint аналог) иnpm run typecheck(tsc --noEmit) проходят без ошибок.npm test(vitest) проходит, включая новые тесты дляgetShowcaseRepo.curl -sI "$FORGEJO_URL/api/v1/repos/slaid098/nonexistent" -H "Authorization: token $FORGEJO_TOKEN"→ 404 подтверждает, что точечный запрос детектит несуществующий slug одним запросом.