Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
152 changes: 147 additions & 5 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,11 +222,153 @@ export default defineNuxtConfig({
'Cloudflare-CDN-Cache-Control': 'public, max-age=31536000, immutable',
},
},
// HTML and API responses must not be cached by browsers or CDNs — stale
// HTML referencing previous-build chunk hashes is the primary cause of
// "Failed to fetch dynamically imported module" errors after deploys.
// CDN-Cache-Control is honoured by compliant CDNs and overrides any
// edge-side cache rules that may ignore the origin Cache-Control.

// Static public assets. Safe to cache aggressively now that the
// deployment pipeline retains old `_nuxt/*` chunks in S3 across
// deploys — the chunk-404 risk that originally motivated the
// blanket `no-store` no longer applies to non-HTML routes.
// Fonts are pinned to specific content via the `?v=` query string
// in assets/styles/fonts.scss, so 1y immutable is safe.
'/fonts/**': {
headers: {
'Cache-Control': 'public, max-age=31536000, immutable',
'CDN-Cache-Control': 'public, max-age=31536000, immutable',
'Cloudflare-CDN-Cache-Control': 'public, max-age=31536000, immutable',
},
},
// App metadata images are referenced by wallet/connect metadata and
// are stable between releases.
'/logo.svg': {
headers: {
'Cache-Control': 'public, max-age=86400',
'CDN-Cache-Control': 'public, max-age=604800',
'Cloudflare-CDN-Cache-Control': 'public, max-age=604800',
},
},
'/logo.png': {
headers: {
'Cache-Control': 'public, max-age=86400',
'CDN-Cache-Control': 'public, max-age=604800',
'Cloudflare-CDN-Cache-Control': 'public, max-age=604800',
},
},
'/manifest-img.png': {
headers: {
'Cache-Control': 'public, max-age=86400',
'CDN-Cache-Control': 'public, max-age=604800',
'Cloudflare-CDN-Cache-Control': 'public, max-age=604800',
},
},
// Icon assets without content-hashing: shorter browser cache so a
// forced reload re-fetches, longer edge cache because assets change
// very rarely and per-byte egress dominates.
'/entities/**': {
headers: {
'Cache-Control': 'public, max-age=86400',
'CDN-Cache-Control': 'public, max-age=604800',
'Cloudflare-CDN-Cache-Control': 'public, max-age=604800',
},
},
'/oracles/**': {
headers: {
'Cache-Control': 'public, max-age=86400',
'CDN-Cache-Control': 'public, max-age=604800',
'Cloudflare-CDN-Cache-Control': 'public, max-age=604800',
},
},
'/favicons/**': {
headers: {
'Cache-Control': 'public, max-age=86400',
'CDN-Cache-Control': 'public, max-age=604800',
'Cloudflare-CDN-Cache-Control': 'public, max-age=604800',
},
},
'/sounds/**': {
headers: {
'Cache-Control': 'public, max-age=86400',
'CDN-Cache-Control': 'public, max-age=604800',
'Cloudflare-CDN-Cache-Control': 'public, max-age=604800',
},
},

// SWR-friendly API endpoints. Each handler already sets the browser
// Cache-Control policy. The catch-all below would otherwise stamp
// CDN-Cache-Control: no-store, so these explicit rules let Cloudflare
// collapse the same public payload across users while handlers still
// drive browser caching.
'/api/vaults': {
headers: {
'CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60',
'Cloudflare-CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60',
},
},
'/api/public/**': {
headers: {
'CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60',
'Cloudflare-CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60',
},
},
'/api/labels/**': {
headers: {
'CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60',
'Cloudflare-CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60',
},
},
'/api/euler-chains': {
headers: {
'CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60',
'Cloudflare-CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60',
},
},
'/api/proxy/merkl/opportunities': {
headers: {
'CDN-Cache-Control': 'public, s-maxage=60, stale-while-revalidate=60',
'Cloudflare-CDN-Cache-Control': 'public, s-maxage=60, stale-while-revalidate=60',
},
},
'/api/proxy/fuul/incentives': {
headers: {
'CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60',
'Cloudflare-CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60',
},
},
'/api/proxy/incentra/sdk/v1/eulerCampaigns': {
headers: {
'CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60',
'Cloudflare-CDN-Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60',
},
},
'/api/proxy/intrinsic-apy-overrides': {
headers: {
'CDN-Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
'Cloudflare-CDN-Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
},
},
// Token lists change infrequently — 5 min edge cache, 10 min SWR.
'/api/token-list': {
headers: {
'CDN-Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
'Cloudflare-CDN-Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
},
},
// TOS changes very rarely — cache aggressively. Browser gets a
// 5 min window; edge can serve for an hour and SWR for a day.
'/api/tos': {
headers: {
'Cache-Control': 'public, max-age=300, stale-while-revalidate=600',
'CDN-Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
'Cloudflare-CDN-Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
},

// HTML and everything else: must not be cached by browsers or CDNs.
// S3 retention of old chunks mitigates the original stale-HTML
// failure mode for non-HTML routes, but HTML itself must always
// resolve to the latest build's chunk hashes. Sensitive/live APIs
// such as /api/pyth/updates, /api/proxy/subgraph/*,
// /api/proxy/turtle/*, user-specific reward/proof proxy paths,
// /api/rpc/*, /api/tenderly/*, and /api/screen-address also stay
// on this strict fallback.
'/**': {
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate',
Expand Down
57 changes: 57 additions & 0 deletions server/plugins/cache-error-responses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { H3Event } from 'h3'
import { getRequestURL, setResponseHeader } from 'h3'

const NO_STORE_CACHE_CONTROL = 'no-store'
const USER_REWARD_PATH_RE = /^\/api\/proxy\/merkl\/users\/0x[a-fA-F0-9]{40}\/rewards\/?$/

const alwaysNoStorePrefixes = [
'/api/proxy/subgraph/',
'/api/proxy/turtle/',
'/api/rpc/',
'/api/tenderly/',
] as const

const alwaysNoStorePaths = new Set([
'/api/pyth/updates',
'/api/proxy/fuul/claimable-rewards',
'/api/proxy/incentra/v1/getMerkleProofsBatch',
'/api/screen-address',
])

export function forceNoStoreCacheHeaders(event: H3Event): void {
setResponseHeader(event, 'Cache-Control', NO_STORE_CACHE_CONTROL)
setResponseHeader(event, 'CDN-Cache-Control', NO_STORE_CACHE_CONTROL)
setResponseHeader(event, 'Cloudflare-CDN-Cache-Control', NO_STORE_CACHE_CONTROL)
}

export function shouldForceNoStoreForPath(pathname: string): boolean {
if (alwaysNoStorePaths.has(pathname)) return true
if (USER_REWARD_PATH_RE.test(pathname)) return true
return alwaysNoStorePrefixes.some(prefix => pathname.startsWith(prefix))
}

export function forceNoStoreForErrorResponse(event: H3Event): void {
const statusCode = event.node.res.statusCode
if (statusCode < 400) return

forceNoStoreCacheHeaders(event)
}

export function forceNoStoreForSensitivePath(event: H3Event): void {
const { pathname } = getRequestURL(event)
if (!shouldForceNoStoreForPath(pathname)) return

forceNoStoreCacheHeaders(event)
}

export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('error', (_error, context) => {
if (context.event) {
forceNoStoreCacheHeaders(context.event)
}
})
nitroApp.hooks.hook('beforeResponse', (event) => {
forceNoStoreForSensitivePath(event)
forceNoStoreForErrorResponse(event)
})
})
64 changes: 64 additions & 0 deletions tests/server/cache-error-responses.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import type { H3Event } from 'h3'
import {
forceNoStoreForErrorResponse,
shouldForceNoStoreForPath,
} from '~/server/plugins/cache-error-responses'

function createMockEvent(statusCode: number) {
const headers: Record<string, string> = {}
return {
event: {
node: {
res: {
statusCode,
setHeader: (name: string, value: string) => {
headers[name] = value
},
},
},
} as unknown as H3Event,
headers,
}
}

describe('forceNoStoreForErrorResponse', () => {
it('overrides route-rule cache headers on error responses', () => {
const { event, headers } = createMockEvent(451)

forceNoStoreForErrorResponse(event)

expect(headers['Cache-Control']).toBe('no-store')
expect(headers['CDN-Cache-Control']).toBe('no-store')
expect(headers['Cloudflare-CDN-Cache-Control']).toBe('no-store')
})

it('leaves successful responses untouched', () => {
const { event, headers } = createMockEvent(200)

forceNoStoreForErrorResponse(event)

expect(headers).toEqual({})
})
})

describe('shouldForceNoStoreForPath', () => {
it.each([
'/api/proxy/fuul/claimable-rewards',
'/api/proxy/incentra/v1/getMerkleProofsBatch',
'/api/proxy/merkl/users/0x0000000000000000000000000000000000000000/rewards',
'/api/proxy/subgraph/1',
'/api/proxy/turtle/streams/merkle_proofs',
])('forces no-store for sensitive path %s', (pathname) => {
expect(shouldForceNoStoreForPath(pathname)).toBe(true)
})

it.each([
'/api/proxy/merkl/opportunities',
'/api/proxy/fuul/incentives',
'/api/proxy/incentra/sdk/v1/eulerCampaigns',
'/api/proxy/intrinsic-apy-overrides',
])('leaves public cache path %s to route rules', (pathname) => {
expect(shouldForceNoStoreForPath(pathname)).toBe(false)
})
})