Skip to content

Commit

Permalink
fix: setting headers via routeRules on cached routes (#66)
Browse files Browse the repository at this point in the history
  • Loading branch information
dulnan authored Aug 25, 2024
1 parent 839a16b commit 573075b
Show file tree
Hide file tree
Showing 9 changed files with 147 additions and 4 deletions.
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"dev:build": "nuxi build playground",
"dev:serve": "node playground/.output/server/index.mjs",
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground && nuxi prepare playground-disk",
"dev:inspect": "nuxi dev playground --inspect",
"typecheck": "nuxi typecheck",
"docs:dev": "vitepress dev docs --port 5000",
"docs:build": "vitepress build docs",
Expand Down
10 changes: 10 additions & 0 deletions playground/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ export default defineNuxtConfig({
'/spaPageWithCachedComponent': { ssr: false },
'/spaDataCache': { ssr: false },
'/spaPageWithException': { ssr: false },
'/api/routeCacheWithRouteRules': {
headers: {
'x-route-rules-header': 'Set via routeRules',
},
},
'/api/testStaleIfError': {
headers: {
'x-route-rules-header': 'Set via routeRules',
},
},
},
modules: [NuxtMultiCache],
imports: {
Expand Down
11 changes: 11 additions & 0 deletions playground/server/api/routeCacheWithRouteRules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineEventHandler } from 'h3'
import { useRouteCache } from '#nuxt-multi-cache/composables'

export default defineEventHandler((event) => {
useRouteCache((helper) => {
helper.setCacheable().setMaxAge(234234)
}, event)
return {
data: 'There are route rules defined for this endpoint.',
}
})
8 changes: 7 additions & 1 deletion src/runtime/helpers/routeCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ export async function serveCachedRoute(
response.headers.set(name, value)
})

// Respond with the cached response.
// We use this to tell our "fake" event handler that runs as the very first
// one in the stack to return a fake response (which is not actually returned
// to the client). It just tells H3 to stop executing any other event
// handlers.
event.__MULTI_CACHE_SERVED_FROM_CACHE = true
event._handled = true

await event.respondWith(response)
}
4 changes: 2 additions & 2 deletions src/runtime/server/hooks/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { serveCachedRoute } from '../../helpers/routeCache'
*
* This is called after a valid response was built, but before it is sent.
*/
export function onError(_error: Error, ctx: CapturedErrorContext) {
export async function onError(_error: Error, ctx: CapturedErrorContext) {
try {
if (!ctx.event) {
return
Expand All @@ -28,6 +28,6 @@ export function onError(_error: Error, ctx: CapturedErrorContext) {
return
}

serveCachedRoute(ctx.event, decoded)
await serveCachedRoute(ctx.event, decoded)
} catch (_e) {}
}
20 changes: 20 additions & 0 deletions src/runtime/server/plugins/multiCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,26 @@ export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('beforeResponse', onBeforeResponse)
}

// We have to add a "fake" event handler as the very first handler in the
// stack. Since we serve from cache inside the "request" hook (which runs
// before any event handlers), the response has already been sent at that
// point. However, H3 will still continue to execute the event handlers
// and one of them is the "route-rules" handler from Nitro which may set
// response headers, which will throw an "Cannot set headers after they are
// sent to the client" error.
nitroApp.h3App.stack.unshift({
route: '/',
handler: function (event) {
// This is set by our serveCachedRoute method.
if (event.__MULTI_CACHE_SERVED_FROM_CACHE) {
// This is never actually sent to the client. It's just a workaround
// (or more a hack) to tell H3 to stop executing any other event
// handlers.
return 'NOOP'
}
},
})

// Only needed if route caching is enabled.
if (multiCache.config.route) {
// Hook into afterResponse to store cacheable responses in cache.
Expand Down
5 changes: 5 additions & 0 deletions src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,5 +337,10 @@ declare module 'h3' {
* The route cache key that is currently being revalidated.
*/
__MULTI_CACHE_REVALIDATION_KEY?: string

/**
* Whether the current request has already been served from cache.
*/
__MULTI_CACHE_SERVED_FROM_CACHE?: boolean
}
}
90 changes: 90 additions & 0 deletions test/routeRulesHeaders.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import path from 'path'
import { setup, fetch } from '@nuxt/test-utils/e2e'
import { describe, expect, test } from 'vitest'
import type { NuxtMultiCacheOptions } from '../src/runtime/types'
import { decodeRouteCacheItem } from '../src/runtime/helpers/cacheItem'

const multiCache: NuxtMultiCacheOptions = {
route: {
enabled: true,
},
api: {
enabled: true,
authorization: false,
cacheTagInvalidationDelay: 5000,
},
}
const nuxtConfig: any = {
multiCache,
}
await setup({
server: true,
logLevel: 0,
runner: 'vitest',
build: true,
rootDir: path.resolve(__dirname, './../playground'),
nuxtConfig,
})

/**
* This test checks that our "fake event handler" that prevents Nitro from
* applying route rules on cached routes works correctly.
*/
describe('Route cache in combination with routeRules', () => {
test('caches headers set by routeRules', async () => {
const HEADER = 'Set via routeRules'

// First request should put it in cache.
const responseA = await fetch('/api/routeCacheWithRouteRules')

// The route rules have been applied.
expect(responseA.headers.get('x-route-rules-header')).toEqual(HEADER)

// Get the cached item.
const stats = await fetch('/__nuxt_multi_cache/stats/route', {
method: 'get',
headers: {
'x-nuxt-multi-cache-token': 'hunter2',
},
}).then((v) => v.json())

const cacheItem = stats.rows[0].data
const decoded = decodeRouteCacheItem(cacheItem)

// The routeRules header should be stored in the cache.
expect(decoded?.headers['x-route-rules-header']).toEqual(HEADER)

// Second request comes from cache.
const responseB = await fetch('/api/routeCacheWithRouteRules')

// The headers are not applied from route rules anymore.
// Instead, because headers were cached originally, they are applied
// to the response.
expect(responseB.headers.get('x-route-rules-header')).toEqual(HEADER)
})

// During "stale if error", the event handlers are executed as normal,
// including the "route rules" handler by Nitro. In this scenario, the route
// cache serves the cached route from the "error" hook, which runs *after*
// the route rules handler. This is why we don't have the problem anymore
// with "Cannot set headers after they are sent to the client".
// This test makes sure that route rules are also applied in this scenario.
test('caches headers set by routeRules on "stale if error"', async () => {
const HEADER = 'Set via routeRules'

// First request should put it in cache.
const responseA = await fetch('/api/testStaleIfError')

// The route rules have been applied.
expect(responseA.headers.get('x-route-rules-header')).toEqual(HEADER)

// Second request will throw an error and then serve it from cache.
const responseB = await fetch('/api/testStaleIfError', {
headers: {
'x-nuxt-throw-error': 'true',
},
})

expect(responseB.headers.get('x-route-rules-header')).toEqual(HEADER)
})
})

0 comments on commit 573075b

Please sign in to comment.