diff --git a/app/composables/useCachedFetch.ts b/app/composables/useCachedFetch.ts new file mode 100644 index 0000000000..d7f363ce24 --- /dev/null +++ b/app/composables/useCachedFetch.ts @@ -0,0 +1,99 @@ +import type { H3Event } from 'h3' + +/** + * Type for the cachedFetch function attached to event context. + */ +export type CachedFetchFunction = ( + url: string, + options?: { + method?: string + body?: unknown + headers?: Record + }, + ttl?: number, +) => Promise + +/** + * Get the cachedFetch function from the current request context. + * + * IMPORTANT: This must be called in the composable setup context (outside of + * useAsyncData handlers). The returned function can then be used inside handlers. + * + * @example + * ```ts + * export function usePackage(name: MaybeRefOrGetter) { + * // Get cachedFetch in setup context + * const cachedFetch = useCachedFetch() + * + * return useLazyAsyncData( + * () => `package:${toValue(name)}`, + * // Use it inside the handler + * () => cachedFetch(`https://registry.npmjs.org/${toValue(name)}`) + * ) + * } + * ``` + */ +export function useCachedFetch(): CachedFetchFunction { + // On client, return a function that just uses $fetch + if (import.meta.client) { + return async ( + url: string, + options: { + method?: string + body?: unknown + headers?: Record + } = {}, + _ttl?: number, + ): Promise => { + return (await $fetch(url, options as Parameters[1])) as T + } + } + + // On server, get the cachedFetch from request context + const event = useRequestEvent() + const serverCachedFetch = event?.context?.cachedFetch + + // If cachedFetch is available from middleware, return it + if (serverCachedFetch) { + return serverCachedFetch as CachedFetchFunction + } + + // Fallback: return a function that uses regular $fetch + // (shouldn't happen in normal operation) + return async ( + url: string, + options: { + method?: string + body?: unknown + headers?: Record + } = {}, + _ttl?: number, + ): Promise => { + return (await $fetch(url, options as Parameters[1])) as T + } +} + +/** + * Create a cachedFetch function from an H3Event. + * Useful when you have direct access to the event. + */ +export function getCachedFetchFromEvent(event: H3Event | undefined): CachedFetchFunction { + const serverCachedFetch = event?.context?.cachedFetch + + if (serverCachedFetch) { + return serverCachedFetch as CachedFetchFunction + } + + // Fallback to regular $fetch + return async ( + url: string, + options: { + method?: string + body?: unknown + headers?: Record + } = {}, + _ttl?: number, + ): Promise => { + return (await $fetch(url, options as Parameters[1])) as T + } +} diff --git a/app/composables/useNpmRegistry.ts b/app/composables/useNpmRegistry.ts index 740be46104..fa8e639bd2 100644 --- a/app/composables/useNpmRegistry.ts +++ b/app/composables/useNpmRegistry.ts @@ -12,6 +12,7 @@ import type { ReleaseType } from 'semver' import { maxSatisfying, prerelease, major, minor, diff, gt, compare } from 'semver' import { isExactVersion } from '~/utils/versions' import { extractInstallScriptsInfo } from '~/utils/install-scripts' +import type { CachedFetchFunction } from '~/composables/useCachedFetch' const NPM_REGISTRY = 'https://registry.npmjs.org' const NPM_API = 'https://api.npmjs.org' @@ -19,57 +20,6 @@ const NPM_API = 'https://api.npmjs.org' // Cache for packument fetches to avoid duplicate requests across components const packumentCache = new Map>() -/** - * Fetch a package's full packument data. - * Uses caching to avoid duplicate requests. - */ -async function fetchNpmPackage(name: string): Promise { - const encodedName = encodePackageName(name) - return await $fetch(`${NPM_REGISTRY}/${encodedName}`) -} - -/** - * Fetch a package's packument with caching (returns null on error). - * This is useful for batch operations where some packages might not exist. - */ -async function fetchCachedPackument(name: string): Promise { - const cached = packumentCache.get(name) - if (cached) return cached - - const promise = fetchNpmPackage(name).catch(() => null) - packumentCache.set(name, promise) - return promise -} - -async function searchNpmPackages( - query: string, - options: { - size?: number - from?: number - quality?: number - popularity?: number - maintenance?: number - } = {}, -): Promise { - const params = new URLSearchParams() - params.set('text', query) - if (options.size) params.set('size', String(options.size)) - if (options.from) params.set('from', String(options.from)) - if (options.quality !== undefined) params.set('quality', String(options.quality)) - if (options.popularity !== undefined) params.set('popularity', String(options.popularity)) - if (options.maintenance !== undefined) params.set('maintenance', String(options.maintenance)) - - return await $fetch(`${NPM_REGISTRY}/-/v1/search?${params.toString()}`) -} - -async function fetchNpmDownloads( - packageName: string, - period: 'last-day' | 'last-week' | 'last-month' | 'last-year' = 'last-week', -): Promise { - const encodedName = encodePackageName(packageName) - return await $fetch(`${NPM_API}/downloads/point/${period}/${encodedName}`) -} - /** * Encode a package name for use in npm registry URLs. * Handles scoped packages (e.g., @scope/name -> @scope%2Fname). @@ -162,10 +112,15 @@ export function usePackage( name: MaybeRefOrGetter, requestedVersion?: MaybeRefOrGetter, ) { + const cachedFetch = useCachedFetch() + const asyncData = useLazyAsyncData( () => `package:${toValue(name)}:${toValue(requestedVersion) ?? ''}`, - () => - fetchNpmPackage(toValue(name)).then(r => transformPackument(r, toValue(requestedVersion))), + async () => { + const encodedName = encodePackageName(toValue(name)) + const pkg = await cachedFetch(`${NPM_REGISTRY}/${encodedName}`) + return transformPackument(pkg, toValue(requestedVersion)) + }, ) // Resolve requestedVersion to an exact version @@ -202,9 +157,16 @@ export function usePackageDownloads( name: MaybeRefOrGetter, period: MaybeRefOrGetter<'last-day' | 'last-week' | 'last-month' | 'last-year'> = 'last-week', ) { + const cachedFetch = useCachedFetch() + return useLazyAsyncData( () => `downloads:${toValue(name)}:${toValue(period)}`, - () => fetchNpmDownloads(toValue(name), toValue(period)), + async () => { + const encodedName = encodePackageName(toValue(name)) + return await cachedFetch( + `${NPM_API}/downloads/point/${toValue(period)}/${encodedName}`, + ) + }, ) } @@ -215,6 +177,10 @@ type NpmDownloadsRangeResponse = { downloads: Array<{ day: string; downloads: number }> } +/** + * Fetch download range data from npm API. + * Exported for external use (e.g., in components). + */ export async function fetchNpmDownloadsRange( packageName: string, start: string, @@ -226,6 +192,42 @@ export async function fetchNpmDownloadsRange( ) } +export function usePackageWeeklyDownloadEvolution( + name: MaybeRefOrGetter, + options: MaybeRefOrGetter<{ + weeks?: number + endDate?: string + }> = {}, +) { + const cachedFetch = useCachedFetch() + + return useLazyAsyncData( + () => `downloads-weekly-evolution:${toValue(name)}:${JSON.stringify(toValue(options))}`, + async () => { + const packageName = toValue(name) + const { weeks = 12, endDate } = toValue(options) ?? {} + + const today = new Date() + const yesterday = new Date( + Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 1), + ) + + const end = endDate ? new Date(`${endDate}T00:00:00.000Z`) : yesterday + + const start = addDays(end, -(weeks * 7) + 1) + const startIso = toIsoDateString(start) + const endIso = toIsoDateString(end) + + const encodedName = encodePackageName(packageName) + const range = await cachedFetch( + `${NPM_API}/downloads/range/${startIso}:${endIso}/${encodedName}`, + ) + const sortedDaily = [...range.downloads].sort((a, b) => a.day.localeCompare(b.day)) + return buildWeeklyEvolutionFromDaily(sortedDaily) + }, + ) +} + const emptySearchResponse = { objects: [], total: 0, @@ -239,6 +241,7 @@ export function useNpmSearch( from?: number }> = {}, ) { + const cachedFetch = useCachedFetch() let lastSearch: NpmSearchResponse | undefined = undefined return useLazyAsyncData( @@ -248,38 +251,24 @@ export function useNpmSearch( if (!q.trim()) { return Promise.resolve(emptySearchResponse) } - return (lastSearch = await searchNpmPackages(q, toValue(options))) + + const params = new URLSearchParams() + params.set('text', q) + const opts = toValue(options) + if (opts.size) params.set('size', String(opts.size)) + if (opts.from) params.set('from', String(opts.from)) + + // Note: Search results have a short TTL (1 minute) since they change frequently + return (lastSearch = await cachedFetch( + `${NPM_REGISTRY}/-/v1/search?${params.toString()}`, + {}, + 60, // 1 minute TTL for search results + )) }, { default: () => lastSearch || emptySearchResponse }, ) } -/** - * Fetch all package names in an npm organization - * Uses the /-/org/{org}/package endpoint - * Throws error with statusCode 404 if org doesn't exist - * Returns empty array if org exists but has no packages - */ -async function fetchOrgPackageNames(orgName: string): Promise { - try { - const data = await $fetch>( - `${NPM_REGISTRY}/-/org/${encodeURIComponent(orgName)}/package`, - ) - return Object.keys(data) - } catch (err) { - // Check if this is a 404 (org not found) - if (err && typeof err === 'object' && 'statusCode' in err && err.statusCode === 404) { - throw createError({ - statusCode: 404, - statusMessage: 'Organization not found', - message: `The organization "@${orgName}" does not exist on npm`, - }) - } - // For other errors (network, etc.), return empty array to be safe - return [] - } -} - /** * Minimal packument data needed for package cards */ @@ -292,23 +281,6 @@ interface MinimalPackument { 'maintainers'?: NpmPerson[] } -/** - * Fetch minimal packument data for a single package - */ -async function fetchMinimalPackument(name: string): Promise { - try { - const encoded = encodePackageName(name) - return await $fetch(`${NPM_REGISTRY}/${encoded}`, { - // Only fetch the fields we need using Accept header - // Note: npm registry doesn't support field filtering, so we get full packument - // but we only use what we need - }) - } catch { - // Package might not exist or be private - return null - } -} - /** * Convert packument to search result format for display */ @@ -341,6 +313,8 @@ function packumentToSearchResult(pkg: MinimalPackument): NpmSearchResult { * Returns search-result-like objects for compatibility with PackageList */ export function useOrgPackages(orgName: MaybeRefOrGetter) { + const cachedFetch = useCachedFetch() + return useLazyAsyncData( () => `org-packages:${toValue(orgName)}`, async () => { @@ -350,7 +324,24 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) { } // Get all package names in the org - const packageNames = await fetchOrgPackageNames(org) + let packageNames: string[] + try { + const data = await cachedFetch>( + `${NPM_REGISTRY}/-/org/${encodeURIComponent(org)}/package`, + ) + packageNames = Object.keys(data) + } catch (err) { + // Check if this is a 404 (org not found) + if (err && typeof err === 'object' && 'statusCode' in err && err.statusCode === 404) { + throw createError({ + statusCode: 404, + statusMessage: 'Organization not found', + message: `The organization "@${org}" does not exist on npm`, + }) + } + // For other errors (network, etc.), return empty array to be safe + packageNames = [] + } if (packageNames.length === 0) { return emptySearchResponse @@ -362,7 +353,16 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) { for (let i = 0; i < packageNames.length; i += concurrency) { const batch = packageNames.slice(i, i + concurrency) - const packuments = await Promise.all(batch.map(name => fetchMinimalPackument(name))) + const packuments = await Promise.all( + batch.map(async name => { + try { + const encoded = encodePackageName(name) + return await cachedFetch(`${NPM_REGISTRY}/${encoded}`) + } catch { + return null + } + }), + ) for (const pkg of packuments) { // Filter out any unpublished packages (missing dist-tags) @@ -386,13 +386,16 @@ export function useOrgPackages(orgName: MaybeRefOrGetter) { // Package Versions // ============================================================================ -// Cache for full version lists +// Cache for full version lists (client-side only, for non-composable usage) const allVersionsCache = new Map>() /** * Fetch all versions of a package from the npm registry. * Returns version info sorted by version (newest first). * Results are cached to avoid duplicate requests. + * + * Note: This is a standalone async function for use in event handlers. + * For composable usage, use useAllPackageVersions instead. */ export async function fetchAllPackageVersions(packageName: string): Promise { const cached = allVersionsCache.get(packageName) @@ -400,6 +403,7 @@ export async function fetchAllPackageVersions(packageName: string): Promise { const encodedName = encodePackageName(packageName) + // Use regular $fetch for client-side calls (this is called on user interaction) const data = await $fetch<{ versions: Record time: Record @@ -420,6 +424,35 @@ export async function fetchAllPackageVersions(packageName: string): Promise) { + const cachedFetch = useCachedFetch() + + return useLazyAsyncData( + () => `all-versions:${toValue(packageName)}`, + async () => { + const encodedName = encodePackageName(toValue(packageName)) + const data = await cachedFetch<{ + versions: Record + time: Record + }>(`${NPM_REGISTRY}/${encodedName}`) + + return Object.entries(data.versions) + .filter(([v]) => data.time[v]) + .map(([version, versionData]) => ({ + version, + time: data.time[version], + hasProvenance: false, // Would need to check dist.attestations for each version + deprecated: versionData.deprecated, + })) + .sort((a, b) => compare(b.version, a.version)) as PackageVersionInfo[] + }, + ) +} + // ============================================================================ // Outdated Dependencies // ============================================================================ @@ -467,12 +500,9 @@ function isNonSemverConstraint(constraint: string): boolean { /** * Check if a dependency is outdated. * Returns null if up-to-date or if we can't determine. - * - * A dependency is only considered "outdated" if the resolved version - * is older than the latest version. If the resolved version is newer - * (e.g., using ^2.0.0-rc when latest is 1.x), it's not outdated. */ async function checkDependencyOutdated( + cachedFetch: CachedFetchFunction, packageName: string, constraint: string, ): Promise { @@ -480,7 +510,19 @@ async function checkDependencyOutdated( return null } - const packument = await fetchCachedPackument(packageName) + // Check in-memory cache first + let packument: Packument | null + const cached = packumentCache.get(packageName) + if (cached) { + packument = await cached + } else { + const promise = cachedFetch( + `${NPM_REGISTRY}/${encodePackageName(packageName)}`, + ).catch(() => null) + packumentCache.set(packageName, promise) + packument = await promise + } + if (!packument) return null const latestTag = packument['dist-tags']?.latest @@ -535,6 +577,7 @@ async function checkDependencyOutdated( export function useOutdatedDependencies( dependencies: MaybeRefOrGetter | undefined>, ) { + const cachedFetch = useCachedFetch() const outdated = shallowRef>({}) async function fetchOutdatedInfo(deps: Record | undefined) { @@ -551,7 +594,7 @@ export function useOutdatedDependencies( const batch = entries.slice(i, i + batchSize) const batchResults = await Promise.all( batch.map(async ([name, constraint]) => { - const info = await checkDependencyOutdated(name, constraint) + const info = await checkDependencyOutdated(cachedFetch, name, constraint) return [name, info] as const }), ) diff --git a/app/composables/useRepoMeta.ts b/app/composables/useRepoMeta.ts index 5a2bdefbd5..311636fc04 100644 --- a/app/composables/useRepoMeta.ts +++ b/app/composables/useRepoMeta.ts @@ -1,5 +1,9 @@ import type { ProviderId, RepoRef } from '#shared/utils/git-providers' import { parseRepoUrl, GITLAB_HOSTS } from '#shared/utils/git-providers' +import type { CachedFetchFunction } from '~/composables/useCachedFetch' + +// TTL for git repo metadata (10 minutes - repo stats don't change frequently) +const REPO_META_TTL = 60 * 10 export type RepoMetaLinks = { repo: string @@ -73,7 +77,11 @@ type ProviderAdapter = { id: ProviderId parse(url: URL): RepoRef | null links(ref: RepoRef): RepoMetaLinks - fetchMeta(ref: RepoRef, links: RepoMetaLinks): Promise + fetchMeta( + cachedFetch: CachedFetchFunction, + ref: RepoRef, + links: RepoMetaLinks, + ): Promise } const githubAdapter: ProviderAdapter = { @@ -106,11 +114,18 @@ const githubAdapter: ProviderAdapter = { } }, - async fetchMeta(ref, links) { + async fetchMeta(cachedFetch, ref, links) { // Using UNGH to avoid API limitations of the Github API - const res = await $fetch(`https://ungh.cc/repos/${ref.owner}/${ref.repo}`, { - headers: { 'User-Agent': 'npmx' }, - }).catch(() => null) + let res: UnghRepoResponse | null = null + try { + res = await cachedFetch( + `https://ungh.cc/repos/${ref.owner}/${ref.repo}`, + { headers: { 'User-Agent': 'npmx' } }, + REPO_META_TTL, + ) + } catch { + return null + } const repo = res?.repo if (!repo) return null @@ -163,13 +178,19 @@ const gitlabAdapter: ProviderAdapter = { } }, - async fetchMeta(ref, links) { + async fetchMeta(cachedFetch, ref, links) { const baseHost = ref.host ?? 'gitlab.com' const projectPath = encodeURIComponent(`${ref.owner}/${ref.repo}`) - const res = await $fetch( - `https://${baseHost}/api/v4/projects/${projectPath}`, - { headers: { 'User-Agent': 'npmx' } }, - ).catch(() => null) + let res: GitLabProjectResponse | null = null + try { + res = await cachedFetch( + `https://${baseHost}/api/v4/projects/${projectPath}`, + { headers: { 'User-Agent': 'npmx' } }, + REPO_META_TTL, + ) + } catch { + return null + } if (!res) return null @@ -214,11 +235,17 @@ const bitbucketAdapter: ProviderAdapter = { } }, - async fetchMeta(ref, links) { - const res = await $fetch( - `https://api.bitbucket.org/2.0/repositories/${ref.owner}/${ref.repo}`, - { headers: { 'User-Agent': 'npmx' } }, - ).catch(() => null) + async fetchMeta(cachedFetch, ref, links) { + let res: BitbucketRepoResponse | null = null + try { + res = await cachedFetch( + `https://api.bitbucket.org/2.0/repositories/${ref.owner}/${ref.repo}`, + { headers: { 'User-Agent': 'npmx' } }, + REPO_META_TTL, + ) + } catch { + return null + } if (!res) return null @@ -265,11 +292,17 @@ const codebergAdapter: ProviderAdapter = { } }, - async fetchMeta(ref, links) { - const res = await $fetch( - `https://codeberg.org/api/v1/repos/${ref.owner}/${ref.repo}`, - { headers: { 'User-Agent': 'npmx' } }, - ).catch(() => null) + async fetchMeta(cachedFetch, ref, links) { + let res: GiteaRepoResponse | null = null + try { + res = await cachedFetch( + `https://codeberg.org/api/v1/repos/${ref.owner}/${ref.repo}`, + { headers: { 'User-Agent': 'npmx' } }, + REPO_META_TTL, + ) + } catch { + return null + } if (!res) return null @@ -316,11 +349,17 @@ const giteeAdapter: ProviderAdapter = { } }, - async fetchMeta(ref, links) { - const res = await $fetch( - `https://gitee.com/api/v5/repos/${ref.owner}/${ref.repo}`, - { headers: { 'User-Agent': 'npmx' } }, - ).catch(() => null) + async fetchMeta(cachedFetch, ref, links) { + let res: GiteeRepoResponse | null = null + try { + res = await cachedFetch( + `https://gitee.com/api/v5/repos/${ref.owner}/${ref.repo}`, + { headers: { 'User-Agent': 'npmx' } }, + REPO_META_TTL, + ) + } catch { + return null + } if (!res) return null @@ -396,13 +435,21 @@ const giteaAdapter: ProviderAdapter = { } }, - async fetchMeta(ref, links) { + async fetchMeta(cachedFetch, ref, links) { if (!ref.host) return null - const res = await $fetch( - `https://${ref.host}/api/v1/repos/${ref.owner}/${ref.repo}`, - { headers: { 'User-Agent': 'npmx' } }, - ).catch(() => null) + // Note: Generic Gitea instances may not be in the allowlist, + // so caching may not apply for self-hosted instances + let res: GiteaRepoResponse | null = null + try { + res = await cachedFetch( + `https://${ref.host}/api/v1/repos/${ref.owner}/${ref.repo}`, + { headers: { 'User-Agent': 'npmx' } }, + REPO_META_TTL, + ) + } catch { + return null + } if (!res) return null @@ -449,7 +496,7 @@ const sourcehutAdapter: ProviderAdapter = { } }, - async fetchMeta(_ref, links) { + async fetchMeta(_cachedFetch, _ref, links) { // Sourcehut doesn't have a public API for repo stats // Just return basic info without fetching return { @@ -499,7 +546,7 @@ const tangledAdapter: ProviderAdapter = { } }, - async fetchMeta(_ref, links) { + async fetchMeta(_cachedFetch, _ref, links) { // Tangled doesn't have a public API for repo stats yet // Just return basic info without fetching return { @@ -526,15 +573,10 @@ const providers: readonly ProviderAdapter[] = [ const parseRepoFromUrl = parseRepoUrl -async function fetchRepoMeta(ref: RepoRef): Promise { - const adapter = providers.find(provider => provider.id === ref.provider) - if (!adapter) return null - - const links = adapter.links(ref) - return await adapter.fetchMeta(ref, links) -} - export function useRepoMeta(repositoryUrl: MaybeRefOrGetter) { + // Get cachedFetch in setup context (outside async handler) + const cachedFetch = useCachedFetch() + const repoRef = computed(() => { const url = toValue(repositoryUrl) if (!url) return null @@ -549,7 +591,12 @@ export function useRepoMeta(repositoryUrl: MaybeRefOrGetter { const ref = repoRef.value if (!ref) return null - return await fetchRepoMeta(ref) + + const adapter = providers.find(provider => provider.id === ref.provider) + if (!adapter) return null + + const links = adapter.links(ref) + return await adapter.fetchMeta(cachedFetch, ref, links) }, ) diff --git a/modules/cache.ts b/modules/cache.ts index ba780330e7..3964ce91d7 100644 --- a/modules/cache.ts +++ b/modules/cache.ts @@ -1,6 +1,9 @@ import { defineNuxtModule } from 'nuxt/kit' import { provider } from 'std-env' +// Storage key for fetch cache - must match shared/utils/fetch-cache-config.ts +const FETCH_CACHE_STORAGE_BASE = 'fetch-cache' + export default defineNuxtModule({ meta: { name: 'vercel-cache', @@ -12,9 +15,17 @@ export default defineNuxtModule({ nuxt.hook('nitro:config', nitroConfig => { nitroConfig.storage = nitroConfig.storage || {} + + // Main cache storage (for defineCachedFunction, etc.) nitroConfig.storage.cache = { - driver: 'vercel-runtime-cache', ...nitroConfig.storage.cache, + driver: 'vercel-runtime-cache', + } + + // Fetch cache storage (for SWR fetch caching) + nitroConfig.storage[FETCH_CACHE_STORAGE_BASE] = { + ...nitroConfig.storage[FETCH_CACHE_STORAGE_BASE], + driver: 'vercel-runtime-cache', } }) }, diff --git a/nuxt.config.ts b/nuxt.config.ts index 852dad201a..67a7b0d3d9 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -95,6 +95,14 @@ export default defineNuxtConfig({ '@shikijs/core', ], }, + // Storage configuration for local development + // In production (Vercel), this is overridden by modules/cache.ts + storage: { + 'fetch-cache': { + driver: 'fsLite', + base: './.cache/fetch', + }, + }, }, fonts: { diff --git a/package.json b/package.json index a3a4f5db05..5431546395 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@vueuse/nuxt": "14.1.0", "nuxt": "^4.3.0", "nuxt-og-image": "^5.1.13", + "ohash": "^2.0.11", "perfect-debounce": "^2.1.0", "sanitize-html": "^2.17.0", "semver": "^7.7.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd1ea698b9..055d9ea659 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ importers: nuxt-og-image: specifier: ^5.1.13 version: 5.1.13(@unhead/vue@2.1.2(vue@3.5.27(typescript@5.9.3)))(magicast@0.5.1)(unstorage@1.17.4(db0@0.3.4)(ioredis@5.9.2))(vite@8.0.0-beta.10(@types/node@25.0.10)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3)) + ohash: + specifier: ^2.0.11 + version: 2.0.11 perfect-debounce: specifier: ^2.1.0 version: 2.1.0 diff --git a/server/plugins/fetch-cache.ts b/server/plugins/fetch-cache.ts new file mode 100644 index 0000000000..861f69149f --- /dev/null +++ b/server/plugins/fetch-cache.ts @@ -0,0 +1,170 @@ +import type { CachedFetchEntry } from '#shared/utils/fetch-cache-config' +import { + FETCH_CACHE_DEFAULT_TTL, + FETCH_CACHE_STORAGE_BASE, + FETCH_CACHE_VERSION, + isAllowedDomain, + isCacheEntryStale, +} from '#shared/utils/fetch-cache-config' + +/** + * Simple hash function for cache keys. + */ +function simpleHash(str: string): string { + let hash = 0 + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash + } + return Math.abs(hash).toString(36) +} + +/** + * Generate a cache key for a fetch request. + */ +function generateFetchCacheKey(url: string | URL, method: string = 'GET', body?: unknown): string { + const urlObj = typeof url === 'string' ? new URL(url) : url + const bodyHash = body ? simpleHash(JSON.stringify(body)) : '' + const searchHash = urlObj.search ? simpleHash(urlObj.search) : '' + + const parts = [ + FETCH_CACHE_VERSION, + urlObj.host, + method.toUpperCase(), + urlObj.pathname, + searchHash, + bodyHash, + ].filter(Boolean) + + return parts.join(':') +} + +export type CachedFetchFunction = ( + url: string, + options?: { + method?: string + body?: unknown + headers?: Record + }, + ttl?: number, +) => Promise + +/** + * Server middleware that attaches a cachedFetch function to the event context. + * This allows app composables to access the cached fetch via useRequestEvent(). + */ +export default defineNitroPlugin(nitroApp => { + const storage = useStorage(FETCH_CACHE_STORAGE_BASE) + + /** + * Perform a cached fetch with stale-while-revalidate semantics. + */ + const cachedFetch: CachedFetchFunction = async ( + url: string, + options: { + method?: string + body?: unknown + headers?: Record + } = {}, + ttl: number = FETCH_CACHE_DEFAULT_TTL, + ): Promise => { + // Check if this URL should be cached + if (!isAllowedDomain(url)) { + return (await $fetch(url, options as Parameters[1])) as T + } + + const method = options.method || 'GET' + const cacheKey = generateFetchCacheKey(url, method, options.body) + + // Try to get cached response (with error handling for storage failures) + let cached: CachedFetchEntry | null = null + try { + cached = await storage.getItem>(cacheKey) + } catch (error) { + // Storage read failed (e.g., ENOENT on misconfigured storage) + // Log and continue without cache + if (import.meta.dev) { + console.warn(`[fetch-cache] Storage read failed for ${url}:`, error) + } + } + + if (cached) { + if (!isCacheEntryStale(cached)) { + // Cache hit, data is fresh + if (import.meta.dev) { + console.log(`[fetch-cache] HIT (fresh): ${url}`) + } + return cached.data + } + + // Cache hit but stale - return stale data and revalidate in background + if (import.meta.dev) { + console.log(`[fetch-cache] HIT (stale, revalidating): ${url}`) + } + + // Fire-and-forget background revalidation + Promise.resolve().then(async () => { + try { + const freshData = (await $fetch(url, options as Parameters[1])) as T + const entry: CachedFetchEntry = { + data: freshData, + status: 200, + headers: {}, + cachedAt: Date.now(), + ttl, + } + await storage.setItem(cacheKey, entry) + if (import.meta.dev) { + console.log(`[fetch-cache] Revalidated: ${url}`) + } + } catch (error) { + if (import.meta.dev) { + console.warn(`[fetch-cache] Revalidation failed: ${url}`, error) + } + } + }) + + // Return stale data immediately + return cached.data + } + + // Cache miss - fetch and cache + if (import.meta.dev) { + console.log(`[fetch-cache] MISS: ${url}`) + } + + const data = (await $fetch(url, options as Parameters[1])) as T + + // Try to cache the response (non-blocking, with error handling) + try { + const entry: CachedFetchEntry = { + data, + status: 200, + headers: {}, + cachedAt: Date.now(), + ttl, + } + await storage.setItem(cacheKey, entry) + } catch (error) { + // Storage write failed - log but don't fail the request + if (import.meta.dev) { + console.warn(`[fetch-cache] Storage write failed for ${url}:`, error) + } + } + + return data + } + + // Attach to event context for access in composables via useRequestEvent() + nitroApp.hooks.hook('request', event => { + event.context.cachedFetch = cachedFetch + }) +}) + +// Extend the H3EventContext type +declare module 'h3' { + interface H3EventContext { + cachedFetch?: CachedFetchFunction + } +} diff --git a/shared/utils/fetch-cache-config.ts b/shared/utils/fetch-cache-config.ts new file mode 100644 index 0000000000..b9bb85f6e4 --- /dev/null +++ b/shared/utils/fetch-cache-config.ts @@ -0,0 +1,82 @@ +/** + * Configuration for the stale-while-revalidate fetch cache. + * + * This cache intercepts external API calls during SSR and caches responses + * using Nitro's storage layer (backed by Vercel's runtime cache in production). + */ + +/** + * Domains that should have their fetch responses cached. + * Only requests to these domains will be intercepted and cached. + */ +export const FETCH_CACHE_ALLOWED_DOMAINS = [ + // npm registry + 'registry.npmjs.org', // npm package metadata (packuments) + 'api.npmjs.org', // npm download statistics + + // JSR registry + 'jsr.io', // JSR package metadata + + // Git hosting providers (for repo metadata) + 'ungh.cc', // GitHub proxy (avoids rate limits) + 'api.github.com', // GitHub API + 'gitlab.com', // GitLab API + 'api.bitbucket.org', // Bitbucket API + 'codeberg.org', // Codeberg (Gitea-based) + 'gitee.com', // Gitee API +] as const + +/** + * Default TTL for cached fetch responses (in seconds). + * After this time, cached data is considered "stale" but will still be + * returned immediately while a background revalidation occurs. + */ +export const FETCH_CACHE_DEFAULT_TTL = 60 * 5 // 5 minutes + +/** + * Cache key version prefix. + * Increment this to invalidate all cached entries (e.g., after format changes). + */ +export const FETCH_CACHE_VERSION = 'v1' + +/** + * Storage key prefix for fetch cache entries. + */ +export const FETCH_CACHE_STORAGE_BASE = 'fetch-cache' + +/** + * Check if a URL's host is in the allowed domains list. + */ +export function isAllowedDomain(url: string | URL): boolean { + try { + const urlObj = typeof url === 'string' ? new URL(url) : url + return FETCH_CACHE_ALLOWED_DOMAINS.some(domain => urlObj.host === domain) + } catch { + return false + } +} + +/** + * Structure of a cached fetch entry stored in Nitro storage. + */ +export interface CachedFetchEntry { + /** The response body/data */ + data: T + /** HTTP status code */ + status: number + /** Response headers (subset) */ + headers: Record + /** Unix timestamp when the entry was cached */ + cachedAt: number + /** TTL in seconds */ + ttl: number +} + +/** + * Check if a cached entry is stale (past its TTL). + */ +export function isCacheEntryStale(entry: CachedFetchEntry): boolean { + const now = Date.now() + const expiresAt = entry.cachedAt + entry.ttl * 1000 + return now > expiresAt +} diff --git a/test/nuxt/composables/use-npm-registry.spec.ts b/test/nuxt/composables/use-npm-registry.spec.ts index 28ca60b23d..c193f60252 100644 --- a/test/nuxt/composables/use-npm-registry.spec.ts +++ b/test/nuxt/composables/use-npm-registry.spec.ts @@ -25,7 +25,9 @@ describe('usePackageDownloads', () => { expect(status.value).toBe('success') }) - expect(fetchSpy).toHaveBeenCalledWith('https://api.npmjs.org/downloads/point/last-week/vue') + // Check that fetch was called with the correct URL (first argument) + expect(fetchSpy).toHaveBeenCalled() + expect(fetchSpy.mock.calls[0]?.[0]).toBe('https://api.npmjs.org/downloads/point/last-week/vue') expect(data.value?.downloads).toBe(1234567) }) @@ -36,7 +38,9 @@ describe('usePackageDownloads', () => { expect(status.value).toBe('success') }) - expect(fetchSpy).toHaveBeenCalledWith('https://api.npmjs.org/downloads/point/last-month/vue') + // Check that fetch was called with the correct URL (first argument) + expect(fetchSpy).toHaveBeenCalled() + expect(fetchSpy.mock.calls[0]?.[0]).toBe('https://api.npmjs.org/downloads/point/last-month/vue') }) it('should encode scoped package names', async () => { @@ -48,7 +52,9 @@ describe('usePackageDownloads', () => { expect(status.value).toBe('success') }) - expect(fetchSpy).toHaveBeenCalledWith( + // Check that fetch was called with the correct URL (first argument) + expect(fetchSpy).toHaveBeenCalled() + expect(fetchSpy.mock.calls[0]?.[0]).toBe( 'https://api.npmjs.org/downloads/point/last-week/@vue%2Fcore', ) })