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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

<details>
<summary>All available bypass keys</summary>

| 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 |

</details>

> [!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

```
Expand Down
3 changes: 2 additions & 1 deletion server/api/contributors.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export interface GitHubContributor {
contributions: number
}

export default defineCachedEventHandler(
export default defineBypassableCachedEventHandler(
async (): Promise<GitHubContributor[]> => {
const allContributors: GitHubContributor[] = []
let page = 1
Expand Down Expand Up @@ -52,6 +52,7 @@ export default defineCachedEventHandler(
{
maxAge: 3600, // Cache for 1 hour
name: 'github-contributors',
bypassKey: 'contributors',
getKey: () => 'contributors',
},
)
4 changes: 2 additions & 2 deletions server/api/jsr/[...pkg].get.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<Promise<JsrPackageInfo>>(
export default defineBypassableCachedEventHandler(
async event => {
const pkgPath = getRouterParam(event, 'pkg')

Expand All @@ -30,6 +29,7 @@ export default defineCachedEventHandler<Promise<JsrPackageInfo>>(
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()}`
Expand Down
3 changes: 2 additions & 1 deletion server/api/opensearch/suggestions.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion server/api/registry/analysis/[...pkg].get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()}`
Expand Down
3 changes: 2 additions & 1 deletion server/api/registry/badge/[...pkg].get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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}`
Expand Down
3 changes: 2 additions & 1 deletion server/api/registry/docs/[...pkg].get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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}`
Expand Down
3 changes: 2 additions & 1 deletion server/api/registry/file/[...pkg].get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('/') ?? []
Expand Down Expand Up @@ -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()}`
Expand Down
3 changes: 2 additions & 1 deletion server/api/registry/files/[...pkg].get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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()}`
Expand Down
3 changes: 2 additions & 1 deletion server/api/registry/install-size/[...pkg].get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()}`
Expand Down
3 changes: 2 additions & 1 deletion server/api/registry/org/[org]/packages.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function validateOrgName(name: string): void {
}
}

export default defineCachedEventHandler(
export default defineBypassableCachedEventHandler(
async event => {
const org = getRouterParam(event, 'org')

Expand Down Expand Up @@ -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}`
Expand Down
3 changes: 2 additions & 1 deletion server/api/registry/readme/[...pkg].get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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()}`
Expand Down
3 changes: 2 additions & 1 deletion server/api/registry/vulnerabilities/[...pkg].get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()}`
Expand Down
82 changes: 82 additions & 0 deletions server/plugins/cache-bypass.ts
Original file line number Diff line number Diff line change
@@ -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')
}
})
})
12 changes: 10 additions & 2 deletions server/plugins/fetch-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -64,8 +65,15 @@ export default defineNitroPlugin(nitroApp => {
options: Parameters<typeof $fetch>[1] = {},
ttl: number = FETCH_CACHE_DEFAULT_TTL,
): Promise<CachedFetchResult<T>> => {
// 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 }
}
Expand Down
Loading
Loading