diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b09a65f22..2736388cd6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,6 +64,68 @@ pnpm test:nuxt # Nuxt component tests pnpm test:browser # Playwright E2E tests ``` +### Debugging cache + +The app has multiple caching layers that can make debugging tricky. You can bypass caching using the `__bypass_cache__` query parameter. + +**Category bypass** (bypasses all caches of that type): + +``` +?__bypass_cache__=1 # Bypass ALL cache layers +?__bypass_cache__=all # Same as above +?__bypass_cache__=fetch # Bypass external API fetch cache (npm registry, JSR, GitHub, etc.) +?__bypass_cache__=handler # Bypass all API route handlers +``` + +**Specific key bypass** (bypasses only that cache): + +``` +?__bypass_cache__=readme # Bypass only the readme handler +?__bypass_cache__=npm-package # Bypass only npm package fetching +?__bypass_cache__=readme,fetch # Combine specific keys with categories +``` + +
+All available bypass keys + +| Key | What it caches | +| ------------------- | ------------------------------------------------------------- | +| **Categories** | | +| `fetch` | External API calls (npm registry, JSR, GitHub, etc.) with SWR | +| `handler` | All API route handlers | +| **API Routes** | | +| `readme` | Package README content | +| `analysis` | Package analysis (types, create-\*, etc.) | +| `docs` | Generated TypeScript docs | +| `files` | Package file tree | +| `file` | Individual file content | +| `vulnerabilities` | Dependency vulnerability scan | +| `badge` | Version badge SVG | +| `install-size` | Install size calculation handler | +| `org-packages` | Org package list | +| `jsr` | JSR package lookup handler | +| `suggestions` | Search suggestions | +| `contributors` | GitHub contributors | +| `skills` | Package skills | +| `well-known-skills` | .well-known/skills endpoints | +| **Utilities** | | +| `npm-package` | npm packument fetching | +| `jsr-package` | JSR package info fetching | +| `install-size-calc` | Install size calculation | + +
+ +> [!TIP] +> To bypass **all** caching layers including Vercel's CDN, use a unique value like a timestamp: `?__bypass_cache__=1738438123`. The CDN caches based on the full URL, so a unique query string causes a cache miss at the edge, and then our code bypasses the internal caches too. + +When cache bypass is active, the response includes an `X-Cache-Bypass` header showing which caches were bypassed: + +``` +X-Cache-Bypass: readme,npm-package +``` + +In development mode, cache bypass also logs to the console. + ### Project structure ``` diff --git a/server/api/contributors.get.ts b/server/api/contributors.get.ts index bf9d0fcc2c..3ac93941ed 100644 --- a/server/api/contributors.get.ts +++ b/server/api/contributors.get.ts @@ -6,7 +6,7 @@ export interface GitHubContributor { contributions: number } -export default defineCachedEventHandler( +export default defineBypassableCachedEventHandler( async (): Promise => { const allContributors: GitHubContributor[] = [] let page = 1 @@ -52,6 +52,7 @@ export default defineCachedEventHandler( { maxAge: 3600, // Cache for 1 hour name: 'github-contributors', + bypassKey: 'contributors', getKey: () => 'contributors', }, ) diff --git a/server/api/jsr/[...pkg].get.ts b/server/api/jsr/[...pkg].get.ts index 33bf631e66..7ffe0e3d34 100644 --- a/server/api/jsr/[...pkg].get.ts +++ b/server/api/jsr/[...pkg].get.ts @@ -1,7 +1,6 @@ import * as v from 'valibot' import { PackageNameSchema } from '#shared/schemas/package' import { CACHE_MAX_AGE_ONE_HOUR, ERROR_JSR_FETCH_FAILED } from '#shared/utils/constants' -import type { JsrPackageInfo } from '#shared/types/jsr' /** * Check if an npm package exists on JSR. @@ -11,7 +10,7 @@ import type { JsrPackageInfo } from '#shared/types/jsr' * @example GET /api/jsr/@std/fs → { exists: true, scope: "std", name: "fs", ... } * @example GET /api/jsr/lodash → { exists: false } */ -export default defineCachedEventHandler>( +export default defineBypassableCachedEventHandler( async event => { const pkgPath = getRouterParam(event, 'pkg') @@ -30,6 +29,7 @@ export default defineCachedEventHandler>( maxAge: CACHE_MAX_AGE_ONE_HOUR, swr: true, name: 'api-jsr-package', + bypassKey: 'jsr', getKey: event => { const pkg = getRouterParam(event, 'pkg') ?? '' return `jsr:v1:${pkg.replace(/\/+$/, '').trim()}` diff --git a/server/api/opensearch/suggestions.get.ts b/server/api/opensearch/suggestions.get.ts index 4c7146e581..b36746e49c 100644 --- a/server/api/opensearch/suggestions.get.ts +++ b/server/api/opensearch/suggestions.get.ts @@ -2,7 +2,7 @@ import * as v from 'valibot' import { SearchQuerySchema } from '#shared/schemas/package' import { CACHE_MAX_AGE_ONE_MINUTE, NPM_REGISTRY } from '#shared/utils/constants' -export default defineCachedEventHandler( +export default defineBypassableCachedEventHandler( async event => { const query = getQuery(event) @@ -28,6 +28,7 @@ export default defineCachedEventHandler( { maxAge: CACHE_MAX_AGE_ONE_MINUTE, swr: true, + bypassKey: 'suggestions', getKey: event => { const query = getQuery(event) const q = String(query.q || '').trim() diff --git a/server/api/registry/analysis/[...pkg].get.ts b/server/api/registry/analysis/[...pkg].get.ts index 2fd7166db2..bc9635b8ae 100644 --- a/server/api/registry/analysis/[...pkg].get.ts +++ b/server/api/registry/analysis/[...pkg].get.ts @@ -20,7 +20,7 @@ import { import { parseRepoUrl } from '#shared/utils/git-providers' import { getLatestVersion, getLatestVersionBatch } from 'fast-npm-meta' -export default defineCachedEventHandler( +export default defineBypassableCachedEventHandler( async event => { // Parse package name and optional version from path // e.g., "vue" or "vue/v/3.4.0" or "@nuxt/kit" or "@nuxt/kit/v/1.0.0" @@ -69,6 +69,7 @@ export default defineCachedEventHandler( { maxAge: CACHE_MAX_AGE_ONE_DAY, // 24 hours - analysis rarely changes swr: true, + bypassKey: 'analysis', getKey: event => { const pkg = getRouterParam(event, 'pkg') ?? '' return `analysis:v1:${pkg.replace(/\/+$/, '').trim()}` diff --git a/server/api/registry/badge/[...pkg].get.ts b/server/api/registry/badge/[...pkg].get.ts index 9bf5573b93..42da83993b 100644 --- a/server/api/registry/badge/[...pkg].get.ts +++ b/server/api/registry/badge/[...pkg].get.ts @@ -10,7 +10,7 @@ function measureTextWidth(text: string, charWidth = 6.2, paddingX = 6): number { return Math.max(40, Math.round(text.length * charWidth) + paddingX * 2) } -export default defineCachedEventHandler( +export default defineBypassableCachedEventHandler( async event => { const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] if (pkgParamSegments.length === 0) { @@ -67,6 +67,7 @@ export default defineCachedEventHandler( { maxAge: CACHE_MAX_AGE_ONE_HOUR, swr: true, + bypassKey: 'badge', getKey: event => { const pkg = getRouterParam(event, 'pkg') ?? '' return `badge:version:${pkg}` diff --git a/server/api/registry/docs/[...pkg].get.ts b/server/api/registry/docs/[...pkg].get.ts index 95402cf860..df6a36366b 100644 --- a/server/api/registry/docs/[...pkg].get.ts +++ b/server/api/registry/docs/[...pkg].get.ts @@ -3,7 +3,7 @@ import { assertValidPackageName } from '#shared/utils/npm' import { parsePackageParam } from '#shared/utils/parse-package-param' import { generateDocsWithDeno } from '#server/utils/docs' -export default defineCachedEventHandler( +export default defineBypassableCachedEventHandler( async event => { const pkgParam = getRouterParam(event, 'pkg') if (!pkgParam) { @@ -62,6 +62,7 @@ export default defineCachedEventHandler( { maxAge: 60 * 60, // 1 hour cache swr: true, + bypassKey: 'docs', getKey: event => { const pkg = getRouterParam(event, 'pkg') ?? '' return `docs:v2:${pkg}` diff --git a/server/api/registry/file/[...pkg].get.ts b/server/api/registry/file/[...pkg].get.ts index c1dd76c1b3..438c7228b6 100644 --- a/server/api/registry/file/[...pkg].get.ts +++ b/server/api/registry/file/[...pkg].get.ts @@ -93,7 +93,7 @@ async function fetchFileContent( * - /api/registry/file/packageName/v/1.2.3/path/to/file.ts * - /api/registry/file/@scope/packageName/v/1.2.3/path/to/file.ts */ -export default defineCachedEventHandler( +export default defineBypassableCachedEventHandler( async event => { // Parse: [pkg, 'v', version, ...filePath] or [@scope, pkg, 'v', version, ...filePath] const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] @@ -200,6 +200,7 @@ export default defineCachedEventHandler( { // File content for a specific version never changes - cache permanently maxAge: CACHE_MAX_AGE_ONE_YEAR, // 1 year + bypassKey: 'file', getKey: event => { const pkg = getRouterParam(event, 'pkg') ?? '' return `file:v${CACHE_VERSION}:${pkg.replace(/\/+$/, '').trim()}` diff --git a/server/api/registry/files/[...pkg].get.ts b/server/api/registry/files/[...pkg].get.ts index 467ae9de5e..55d9044a03 100644 --- a/server/api/registry/files/[...pkg].get.ts +++ b/server/api/registry/files/[...pkg].get.ts @@ -10,7 +10,7 @@ import { CACHE_MAX_AGE_ONE_YEAR, ERROR_FILE_LIST_FETCH_FAILED } from '#shared/ut * - /api/registry/files/packageName/v/1.2.3 - required version * - /api/registry/files/@scope/packageName/v/1.2.3 - scoped package */ -export default defineCachedEventHandler( +export default defineBypassableCachedEventHandler( async event => { // Parse package name and version from URL segments // Patterns: [pkg, 'v', version] or [@scope, pkg, 'v', version] @@ -44,6 +44,7 @@ export default defineCachedEventHandler( // Files for a specific version never change - cache permanently maxAge: CACHE_MAX_AGE_ONE_YEAR, // 1 year swr: true, + bypassKey: 'files', getKey: event => { const pkg = getRouterParam(event, 'pkg') ?? '' return `files:v1:${pkg.replace(/\/+$/, '').trim()}` diff --git a/server/api/registry/install-size/[...pkg].get.ts b/server/api/registry/install-size/[...pkg].get.ts index 007ae4d680..03f2f8db38 100644 --- a/server/api/registry/install-size/[...pkg].get.ts +++ b/server/api/registry/install-size/[...pkg].get.ts @@ -8,7 +8,7 @@ import { CACHE_MAX_AGE_ONE_HOUR, ERROR_CALC_INSTALL_SIZE_FAILED } from '#shared/ * Calculate total install size for a package including all dependencies. * Handles platform-specific optional dependencies by counting only one representative per group. */ -export default defineCachedEventHandler( +export default defineBypassableCachedEventHandler( async event => { // Parse package name and optional version from path segments // Supports: /install-size/lodash, /install-size/lodash/v/4.17.21, /install-size/@scope/name, /install-size/@scope/name/v/1.0.0 @@ -46,6 +46,7 @@ export default defineCachedEventHandler( { maxAge: CACHE_MAX_AGE_ONE_HOUR, swr: true, + bypassKey: 'install-size', getKey: event => { const pkg = getRouterParam(event, 'pkg') ?? '' return `install-size:v1:${pkg.replace(/\/+$/, '').trim()}` diff --git a/server/api/registry/org/[org]/packages.get.ts b/server/api/registry/org/[org]/packages.get.ts index 113c2068fe..c5cf39e59e 100644 --- a/server/api/registry/org/[org]/packages.get.ts +++ b/server/api/registry/org/[org]/packages.get.ts @@ -15,7 +15,7 @@ function validateOrgName(name: string): void { } } -export default defineCachedEventHandler( +export default defineBypassableCachedEventHandler( async event => { const org = getRouterParam(event, 'org') @@ -48,6 +48,7 @@ export default defineCachedEventHandler( { maxAge: CACHE_MAX_AGE_ONE_HOUR, swr: true, + bypassKey: 'org-packages', getKey: event => { const org = getRouterParam(event, 'org') ?? '' return `org-packages:v1:${org}` diff --git a/server/api/registry/readme/[...pkg].get.ts b/server/api/registry/readme/[...pkg].get.ts index c523bbd01c..21bf52a5e3 100644 --- a/server/api/registry/readme/[...pkg].get.ts +++ b/server/api/registry/readme/[...pkg].get.ts @@ -55,7 +55,7 @@ async function fetchReadmeFromJsdelivr( * - /api/registry/readme/@scope/packageName - scoped package, latest * - /api/registry/readme/@scope/packageName/v/1.2.3 - scoped package, specific version */ -export default defineCachedEventHandler( +export default defineBypassableCachedEventHandler( async event => { // Parse package name and optional version from URL segments // Patterns: [pkg] or [pkg, 'v', version] or [@scope, pkg] or [@scope, pkg, 'v', version] @@ -124,6 +124,7 @@ export default defineCachedEventHandler( { maxAge: CACHE_MAX_AGE_ONE_HOUR, swr: true, + bypassKey: 'readme', getKey: event => { const pkg = getRouterParam(event, 'pkg') ?? '' return `readme:v6:${pkg.replace(/\/+$/, '').trim()}` diff --git a/server/api/registry/vulnerabilities/[...pkg].get.ts b/server/api/registry/vulnerabilities/[...pkg].get.ts index 1dac0ab364..c93d47e3b6 100644 --- a/server/api/registry/vulnerabilities/[...pkg].get.ts +++ b/server/api/registry/vulnerabilities/[...pkg].get.ts @@ -8,7 +8,7 @@ import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants' * Analyze entire dependency tree for vulnerabilities and deprecated dependencies. * I does not rename this endpoint for backward compatibility. */ -export default defineCachedEventHandler( +export default defineBypassableCachedEventHandler( async event => { const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments) @@ -43,6 +43,7 @@ export default defineCachedEventHandler( { maxAge: CACHE_MAX_AGE_ONE_HOUR, swr: true, + bypassKey: 'vulnerabilities', getKey: event => { const pkg = getRouterParam(event, 'pkg') ?? '' return `vulnerabilities:v1:${pkg.replace(/\/+$/, '').trim()}` diff --git a/server/plugins/cache-bypass.ts b/server/plugins/cache-bypass.ts new file mode 100644 index 0000000000..da0402fabf --- /dev/null +++ b/server/plugins/cache-bypass.ts @@ -0,0 +1,82 @@ +import { BYPASS_CACHE_QUERY_PARAM } from '#shared/utils/cache-bypass' + +/** + * Server plugin that initializes cache bypass context from query parameters. + * This runs early in the request lifecycle so all other caching layers can check + * whether to bypass based on the `__debug_cache__` query parameter. + * + * Also attempts to clear Nitro cache entries when bypass is requested. + */ +export default defineNitroPlugin(nitroApp => { + nitroApp.hooks.hook('request', async event => { + // Check for bypass param early + const url = getRequestURL(event) + const bypassParam = url.searchParams.get(BYPASS_CACHE_QUERY_PARAM) + + if (bypassParam) { + // Clear Nitro caches (handlers, functions) + // Storage base is 'cache' which maps to .nuxt/cache/nitro/ + const cacheStorage = useStorage('cache') + const normalizedParam = bypassParam.toLowerCase() + const clearAll = + normalizedParam === '1' || normalizedParam === 'all' || normalizedParam === 'true' + + try { + const keys = await cacheStorage.getKeys() + + let matchingKeys: string[] + + if (clearAll) { + // Clear everything + matchingKeys = keys + if (import.meta.dev) { + // eslint-disable-next-line no-console + console.log(`[cache-bypass] Clearing ALL ${keys.length} cache entries`) + } + } else { + // Extract path segment for matching (e.g., "vue-use" from "/vue-use") + const pathSegment = url.pathname.replace(/^\//, '').replace(/\//g, '') + + if (import.meta.dev && keys.length > 0) { + // eslint-disable-next-line no-console + console.log( + `[cache-bypass] Found ${keys.length} cache keys, looking for: ${pathSegment}`, + ) + } + + matchingKeys = keys.filter(key => { + const keyLower = key.toLowerCase() + const segmentLower = pathSegment.toLowerCase() + return keyLower.includes(segmentLower) + }) + } + + for (const key of matchingKeys) { + await cacheStorage.removeItem(key) + if (import.meta.dev) { + // eslint-disable-next-line no-console + console.log(`[cache-bypass] Cleared cache: ${key}`) + } + } + } catch (error) { + if (import.meta.dev) { + // eslint-disable-next-line no-console + console.warn(`[cache-bypass] Failed to clear cache:`, error) + } + } + } + + // Initialize bypass context for other layers + initCacheBypassContext(event) + }) + + // After response is ready, disable caching if bypass was requested + nitroApp.hooks.hook('beforeResponse', event => { + if (event.context.cacheBypass) { + // Disable any response caching (ISR, CDN, browser) + setHeader(event, 'Cache-Control', 'private, no-cache, no-store, must-revalidate') + setHeader(event, 'CDN-Cache-Control', 'no-store') + setHeader(event, 'Vercel-CDN-Cache-Control', 'no-store') + } + }) +}) diff --git a/server/plugins/fetch-cache.ts b/server/plugins/fetch-cache.ts index bf466adef5..b009bd4cec 100644 --- a/server/plugins/fetch-cache.ts +++ b/server/plugins/fetch-cache.ts @@ -8,6 +8,7 @@ import { isAllowedDomain, isCacheEntryStale, } from '#shared/utils/fetch-cache-config' +import { shouldBypassCacheFor } from '../utils/cache-bypass' /** * Simple hash function for cache keys. @@ -64,8 +65,15 @@ export default defineNitroPlugin(nitroApp => { options: Parameters[1] = {}, ttl: number = FETCH_CACHE_DEFAULT_TTL, ): Promise> => { - // Check if this URL should be cached - if (!isAllowedDomain(url)) { + // Check if cache bypass is requested for the fetch layer + const bypassCache = shouldBypassCacheFor(event, 'fetch') + + // Check if this URL should be cached (or if bypass is requested) + if (bypassCache || !isAllowedDomain(url)) { + if (bypassCache && import.meta.dev) { + // eslint-disable-next-line no-console + console.log(`[fetch-cache] BYPASS: ${url}`) + } const data = (await $fetch(url, options)) as T return { data, isStale: false, cachedAt: null } } diff --git a/server/routes/[pkg]/.well-known/skills/[...skills].ts b/server/routes/[pkg]/.well-known/skills/[...skills].ts index 93bed3e108..db0b7dcdbc 100644 --- a/server/routes/[pkg]/.well-known/skills/[...skills].ts +++ b/server/routes/[pkg]/.well-known/skills/[...skills].ts @@ -7,7 +7,7 @@ import { CACHE_MAX_AGE_ONE_HOUR, CACHE_MAX_AGE_ONE_YEAR } from '#shared/utils/co /** * Serves /.well-known/skills endpoints for `npx skills add` CLI. */ -export default defineCachedEventHandler( +export default defineBypassableCachedEventHandler( async (event: H3Event) => { const url = getRequestURL(event) const match = url.pathname.match(/^\/(.+?)\/\.well-known\/skills\/(.*)$/)! @@ -54,6 +54,7 @@ export default defineCachedEventHandler( { maxAge: CACHE_MAX_AGE_ONE_HOUR, swr: true, + bypassKey: 'well-known-skills', getKey: (event: H3Event) => `well-known-skills:v1:${getRequestURL(event).pathname.replace(/\/+$/, '')}`, }, diff --git a/server/routes/skills/[...pkg].get.ts b/server/routes/skills/[...pkg].get.ts index 345020b582..b7ca251c0b 100644 --- a/server/routes/skills/[...pkg].get.ts +++ b/server/routes/skills/[...pkg].get.ts @@ -23,7 +23,7 @@ const CACHE_VERSION = 1 * - /skills/vue/v/3.4.0/my-skill/refs/guide.md → supporting file (raw) * - /skills/@scope/pkg/v/1.0.0 → scoped package */ -export default defineCachedEventHandler( +export default defineBypassableCachedEventHandler( async event => { const pkgParam = getRouterParam(event, 'pkg') if (!pkgParam) { @@ -69,6 +69,7 @@ export default defineCachedEventHandler( { maxAge: CACHE_MAX_AGE_ONE_HOUR, swr: true, + bypassKey: 'skills', getKey: event => { const pkg = getRouterParam(event, 'pkg') ?? '' return `skills:v${CACHE_VERSION}:${pkg.replace(/\/+$/, '').trim()}` diff --git a/server/utils/cache-bypass.ts b/server/utils/cache-bypass.ts new file mode 100644 index 0000000000..c1fe66c5f3 --- /dev/null +++ b/server/utils/cache-bypass.ts @@ -0,0 +1,66 @@ +import type { H3Event } from 'h3' +import { + type CacheBypassConfig, + type CacheCategory, + BYPASS_CACHE_QUERY_PARAM, + BYPASS_CACHE_HEADER, + parseBypassCacheParam, + shouldBypassCache, + bypassConfigToString, +} from '#shared/utils/cache-bypass' + +/** + * Get the cache bypass configuration from the event context. + * This is set by the cache-bypass plugin early in the request lifecycle. + */ +export function getBypassConfig(event: H3Event): CacheBypassConfig | undefined { + return event.context.cacheBypass as CacheBypassConfig | undefined +} + +/** + * Check if a specific cache should be bypassed for this request. + * @param event - The H3 event + * @param category - The cache category (e.g., 'handler', 'fetch') + * @param key - Optional specific cache key (e.g., 'readme', 'npm-package') + */ +export function shouldBypassCacheFor( + event: H3Event, + category: CacheCategory, + key?: string, +): boolean { + return shouldBypassCache(getBypassConfig(event), category, key) +} + +/** + * Initialize the cache bypass context from query parameters. + * Called by the cache-bypass plugin. + */ +export function initCacheBypassContext(event: H3Event): void { + const query = getQuery(event) + const bypassParam = query[BYPASS_CACHE_QUERY_PARAM] + const paramValue = Array.isArray(bypassParam) ? bypassParam[0] : bypassParam + + if (paramValue) { + const config = parseBypassCacheParam(String(paramValue)) + const hasConfig = config.all || config.categories.size > 0 || config.keys.size > 0 + + if (hasConfig) { + event.context.cacheBypass = config + + // Set response header indicating which caches are being bypassed + setHeader(event, BYPASS_CACHE_HEADER, bypassConfigToString(config)) + + if (import.meta.dev) { + // eslint-disable-next-line no-console + console.log(`[cache-bypass] Bypassing: ${bypassConfigToString(config)}`) + } + } + } +} + +// Extend the H3EventContext type +declare module 'h3' { + interface H3EventContext { + cacheBypass?: CacheBypassConfig + } +} diff --git a/server/utils/cached-function.ts b/server/utils/cached-function.ts new file mode 100644 index 0000000000..2ed0a4a618 --- /dev/null +++ b/server/utils/cached-function.ts @@ -0,0 +1,75 @@ +import { shouldBypassCacheFor } from './cache-bypass' + +// Get the options type from the defineCachedFunction function +type CachedFunctionOptions = NonNullable< + Parameters>[1] +> + +/** + * Extended options that include a bypass key for fine-grained cache control. + */ +type BypassableCachedFunctionOptions = CachedFunctionOptions< + T, + ArgsT +> & { + /** + * Unique key for this function's cache bypass. + * When `?__bypass_cache__=` is present, only this function's cache is bypassed. + * When `?__bypass_cache__=handler` is present, all cached functions are bypassed + * (they fall under the 'handler' category since they're called from handlers). + */ + bypassKey?: string +} + +/** + * Wrapper around `defineCachedFunction` that automatically respects the + * `__bypass_cache__` query parameter for cache bypass. + * + * Supports both category-level and specific key bypass: + * - `?__bypass_cache__=handler` - Bypass all (handlers include their cached functions) + * - `?__bypass_cache__=npm-package` - Bypass only functions with bypassKey='npm-package' + * + * Note: This uses H3's `useEvent()` to access the request context, which + * requires the function to be called within a request handler context. + */ +export function defineBypassableCachedFunction( + func: (...args: ArgsT) => T | Promise, + options: BypassableCachedFunctionOptions = {} as BypassableCachedFunctionOptions< + T, + ArgsT + >, +) { + const { bypassKey, ...cachedOptions } = options + const originalShouldBypassCache = cachedOptions.shouldBypassCache + + return defineCachedFunction(func, { + ...cachedOptions, + shouldBypassCache: (...args: ArgsT) => { + // Try to get the current event from async context + // This may return null if called outside a request context + try { + const event = useEvent() + // Check using 'handler' category since cached functions are called from handlers + if (event && shouldBypassCacheFor(event, 'handler', bypassKey)) { + if (import.meta.dev) { + // eslint-disable-next-line no-console + console.log( + `[cached-function] BYPASS (${bypassKey || 'handler'}): ${cachedOptions.name || 'anonymous'}`, + ) + } + return true + } + } catch { + // useEvent() throws if called outside request context + // In that case, don't bypass + } + + // Fall back to original shouldBypassCache if provided + if (originalShouldBypassCache) { + return originalShouldBypassCache(...args) + } + + return false + }, + }) +} diff --git a/server/utils/cached-handler.ts b/server/utils/cached-handler.ts new file mode 100644 index 0000000000..b1f92f3c55 --- /dev/null +++ b/server/utils/cached-handler.ts @@ -0,0 +1,54 @@ +import type { H3Event, EventHandler } from 'h3' +import { shouldBypassCacheFor } from './cache-bypass' + +// Get the options type from the defineCachedEventHandler function +type CachedEventHandlerOptions = NonNullable[1]> + +/** + * Extended options that include a bypass key for fine-grained cache control. + */ +type BypassableCachedEventHandlerOptions = CachedEventHandlerOptions & { + /** + * Unique key for this handler's cache bypass. + * When `?__bypass_cache__=` is present, only this handler's cache is bypassed. + * When `?__bypass_cache__=handler` is present, all handlers are bypassed. + */ + bypassKey?: string +} + +/** + * Wrapper around `defineCachedEventHandler` that automatically respects the + * `__bypass_cache__` query parameter for cache bypass. + * + * Supports both category-level and specific key bypass: + * - `?__bypass_cache__=handler` - Bypass all handlers + * - `?__bypass_cache__=readme` - Bypass only handlers with bypassKey='readme' + */ +export function defineBypassableCachedEventHandler( + handler: T, + options: BypassableCachedEventHandlerOptions = {}, +) { + const { bypassKey, ...cachedOptions } = options + const originalShouldBypassCache = cachedOptions.shouldBypassCache + + return defineCachedEventHandler(handler, { + ...cachedOptions, + shouldBypassCache: (event: H3Event) => { + // Check cache bypass (category or specific key) + if (shouldBypassCacheFor(event, 'handler', bypassKey)) { + if (import.meta.dev) { + // eslint-disable-next-line no-console + console.log(`[cached-handler] BYPASS (${bypassKey || 'handler'}): ${event.path}`) + } + return true + } + + // Fall back to original shouldBypassCache if provided + if (originalShouldBypassCache) { + return originalShouldBypassCache(event) + } + + return false + }, + }) +} diff --git a/server/utils/install-size.ts b/server/utils/install-size.ts index 7705af450c..d22491c3e6 100644 --- a/server/utils/install-size.ts +++ b/server/utils/install-size.ts @@ -32,7 +32,7 @@ export interface DependencySize { * * Dependencies are resolved for linux-x64-glibc as a representative platform. */ -export const calculateInstallSize = defineCachedFunction( +export const calculateInstallSize = defineBypassableCachedFunction( async (name: string, version: string): Promise => { const resolved = await resolveDependencyTree(name, version) @@ -76,6 +76,7 @@ export const calculateInstallSize = defineCachedFunction( maxAge: 60 * 60, swr: true, name: 'install-size', + bypassKey: 'install-size-calc', getKey: (name: string, version: string) => `${name}@${version}`, }, ) diff --git a/server/utils/jsr.ts b/server/utils/jsr.ts index bc35dcd9d8..6ca1875c9e 100644 --- a/server/utils/jsr.ts +++ b/server/utils/jsr.ts @@ -15,7 +15,7 @@ const JSR_REGISTRY = 'https://jsr.io' * @param npmPackageName - The npm package name (e.g., "@hono/hono") * @returns JsrPackageInfo with existence status and metadata */ -export const fetchJsrPackageInfo = defineCachedFunction( +export const fetchJsrPackageInfo = defineBypassableCachedFunction( async (npmPackageName: string): Promise => { // Only check scoped packages - we can't authoritatively map unscoped names if (!npmPackageName.startsWith('@')) { @@ -61,6 +61,7 @@ export const fetchJsrPackageInfo = defineCachedFunction( maxAge: 60 * 60 * 24, // 1 day swr: true, name: 'jsr-package-info', + bypassKey: 'jsr-package', getKey: (name: string) => name, }, ) diff --git a/server/utils/npm.ts b/server/utils/npm.ts index 59851981de..a40bd0d046 100644 --- a/server/utils/npm.ts +++ b/server/utils/npm.ts @@ -1,22 +1,23 @@ -import type { Packument } from '#shared/types' -import { encodePackageName, fetchLatestVersion } from '#shared/utils/npm' -import { maxSatisfying, prerelease } from 'semver' -import { CACHE_MAX_AGE_FIVE_MINUTES } from '#shared/utils/constants' +import type { Packument } from "#shared/types"; +import { encodePackageName, fetchLatestVersion } from "#shared/utils/npm"; +import { maxSatisfying, prerelease } from "semver"; +import { CACHE_MAX_AGE_FIVE_MINUTES } from "#shared/utils/constants"; -const NPM_REGISTRY = 'https://registry.npmjs.org' +const NPM_REGISTRY = "https://registry.npmjs.org"; -export const fetchNpmPackage = defineCachedFunction( +export const fetchNpmPackage = defineBypassableCachedFunction( async (name: string): Promise => { - const encodedName = encodePackageName(name) - return await $fetch(`${NPM_REGISTRY}/${encodedName}`) + const encodedName = encodePackageName(name); + return await $fetch(`${NPM_REGISTRY}/${encodedName}`); }, { maxAge: CACHE_MAX_AGE_FIVE_MINUTES, swr: true, - name: 'npm-package', + name: "npm-package", + bypassKey: "npm-package", getKey: (name: string) => name, }, -) +); /** * Get the latest version of a package using fast-npm-meta API. @@ -25,16 +26,18 @@ export const fetchNpmPackage = defineCachedFunction( * @param name Package name * @returns Latest version string or null if not found */ -export async function fetchLatestVersionWithFallback(name: string): Promise { - const version = await fetchLatestVersion(name) - if (version) return version +export async function fetchLatestVersionWithFallback( + name: string, +): Promise { + const version = await fetchLatestVersion(name); + if (version) return version; // Fallback to full packument (also cached) try { - const packument = await fetchNpmPackage(name) - return packument['dist-tags']?.latest ?? null + const packument = await fetchNpmPackage(name); + return packument["dist-tags"]?.latest ?? null; } catch { - return null + return null; } } @@ -45,9 +48,10 @@ export async function fetchLatestVersionWithFallback(name: string): Promise { try { - const packument = await fetchNpmPackage(packageName) - let versions = Object.keys(packument.versions) + const packument = await fetchNpmPackage(packageName); + let versions = Object.keys(packument.versions); // Filter out prerelease versions unless constraint explicitly includes one if (!constraintIncludesPrerelease(constraint)) { - versions = versions.filter(v => !prerelease(v)) + versions = versions.filter((v) => !prerelease(v)); } - return maxSatisfying(versions, constraint) + return maxSatisfying(versions, constraint); } catch { - return null + return null; } } @@ -83,19 +87,19 @@ export async function resolveVersionConstraint( export async function resolveDependencyVersions( dependencies: Record, ): Promise> { - const entries = Object.entries(dependencies) + const entries = Object.entries(dependencies); const results = await Promise.all( entries.map(async ([name, constraint]) => { - const resolved = await resolveVersionConstraint(name, constraint) - return [name, resolved] as const + const resolved = await resolveVersionConstraint(name, constraint); + return [name, resolved] as const; }), - ) + ); - const resolved: Record = {} + const resolved: Record = {}; for (const [name, version] of results) { if (version) { - resolved[name] = version + resolved[name] = version; } } - return resolved + return resolved; } diff --git a/shared/utils/cache-bypass.ts b/shared/utils/cache-bypass.ts new file mode 100644 index 0000000000..ecf8654fb8 --- /dev/null +++ b/shared/utils/cache-bypass.ts @@ -0,0 +1,100 @@ +/** + * Cache bypass configuration. + * + * Use the query parameter `__bypass_cache__` to bypass caching layers: + * - `?__bypass_cache__=1` or `?__bypass_cache__=all` - Bypass all caching layers + * - `?__bypass_cache__=fetch` - Bypass only the custom fetch cache (external API calls) + * - `?__bypass_cache__=handler` - Bypass all API route handlers + * - `?__bypass_cache__=readme` - Bypass only the readme handler (specific key) + * + * Multiple layers can be specified with commas: `?__bypass_cache__=fetch,readme` + * + * Note: ISR/edge caching via Vercel cannot be bypassed with this mechanism as it + * happens at the CDN level before the request reaches our code. + */ + +export const BYPASS_CACHE_QUERY_PARAM = '__bypass_cache__' +export const BYPASS_CACHE_HEADER = 'X-Cache-Bypass' + +/** + * Cache categories that bypass all caches of that type. + */ +export type CacheCategory = 'fetch' | 'handler' + +export const ALL_CACHE_CATEGORIES: readonly CacheCategory[] = ['fetch', 'handler'] as const + +/** + * Parsed bypass configuration containing both categories and specific keys. + */ +export interface CacheBypassConfig { + /** Category-level bypasses (e.g., 'fetch', 'handler') */ + categories: Set + /** Specific cache keys to bypass (e.g., 'readme', 'npm-package') */ + keys: Set + /** Whether to bypass all caching */ + all: boolean +} + +/** + * Parse the bypass cache query parameter value into a bypass configuration. + */ +export function parseBypassCacheParam(value: string | undefined | null): CacheBypassConfig { + const config: CacheBypassConfig = { + categories: new Set(), + keys: new Set(), + all: false, + } + + if (!value) { + return config + } + + const normalizedValue = value.toLowerCase().trim() + + // Handle "all" or "1" as bypass everything + if (normalizedValue === 'all' || normalizedValue === '1' || normalizedValue === 'true') { + config.all = true + return config + } + + // Parse comma-separated list + for (const part of normalizedValue.split(',')) { + const trimmed = part.trim() + if (!trimmed) continue + + if (ALL_CACHE_CATEGORIES.includes(trimmed as CacheCategory)) { + config.categories.add(trimmed as CacheCategory) + } else { + config.keys.add(trimmed) + } + } + + return config +} + +/** + * Check if a specific cache should be bypassed. + * @param config - The parsed bypass configuration + * @param category - The cache category (e.g., 'handler') + * @param key - The specific cache key (e.g., 'readme') + */ +export function shouldBypassCache( + config: CacheBypassConfig | undefined, + category: CacheCategory, + key?: string, +): boolean { + if (!config) return false + if (config.all) return true + if (config.categories.has(category)) return true + if (key && config.keys.has(key)) return true + return false +} + +/** + * Get a display string for the bypass config (for headers/logging). + */ +export function bypassConfigToString(config: CacheBypassConfig): string { + if (config.all) return 'all' + const parts = [...config.categories, ...config.keys] + return parts.join(',') +}