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(',')
+}