N
Nuxt5mo ago
Mähh

Cloudflare Pages - Cached Subrequest / SSR fetch

Hey there, im using Nuxt in a Cloudflare pages runtime. Using useFetch or useAsyncData on the initial request (SSR) will not do a "real" request, as long the target path is located in server/api routes, correct? It just calls the defineEventHandler, instead of calling the url like useFetch would do it on client-side. This could be a problem, when you start to cache api responses through e.g. Cloudflare. That's because the fetch on server-side (uncached) and client-side (cached) can receive different states. Is there any logic/option to force the fetch method to do an "external request" even on server-side?
7 Replies
manniL
manniL5mo ago
it should still give the same state given that cache of an event handler will still be considered (except when the cache misses on the client side)
Mähh
MähhOP5mo ago
But the request is never going out of the stack on SSR, right? So there is no DNS resolving, which means, the request on server-side-rendering e.g. with useFetch('/api/my-api') never reaches cloudflare. Client side request order: 1) useFetch('/api/foo') 2) GET https://example.com/api/foo 3) Cloudflare => Cache Hit? 3.1) Yes => Cloudflare response 3.2) No => Request to my Nuxt Application, which triggers defineEventHandler SSR request order: 1) useFetch('/api/foo') 2) call defineEventHandler() I played around a bit. I thought i could fix it using nitro-cloudflare-dev. In general the package works, but also shows that the useFetch on SSR does not really triggers a request. Base component
<script setup lang="ts">
const {data} = await useAsyncData('my-key', () => {
return $fetch('/api/proxy')
})
</script>

<template>
<div>
<pre>{{data}}</pre>
</div>
</template>
<script setup lang="ts">
const {data} = await useAsyncData('my-key', () => {
return $fetch('/api/proxy')
})
</script>

<template>
<div>
<pre>{{data}}</pre>
</div>
</template>
API Route: /server/api/proxy.ts
export default defineEventHandler(async (event) => {
const h3Context = event.context
return h3Context?.cloudflare ?? 'nothing'
})
export default defineEventHandler(async (event) => {
const h3Context = event.context
return h3Context?.cloudflare ?? 'nothing'
})
Results 1) When calling "GET https://example.com/api/proxy" i got the whole object of the cloudflare context as response. 2) When calling "GET https://example.com" and therefor trigger the component rendering, i got shown nothing on my page. So the event.context.cloudflare is not set on SSR request. ---------- So my only idea for fix that is by calling my api, from within my api by check (e.g. by header) from where the request is triggered. Like:
export default defineEventHandler(async (event) => {
if (event.node.req.headers['x-is-ssr'] === '1') {
// make sure request is going trough cloudflare, by calling same url with domain again
return $fetch('https://my-domain.com/api/proxy')
}

// do regular api stuff. e.g .calling third party api
return $fetch('https://example-external.com/api/blog-posts')
})
export default defineEventHandler(async (event) => {
if (event.node.req.headers['x-is-ssr'] === '1') {
// make sure request is going trough cloudflare, by calling same url with domain again
return $fetch('https://my-domain.com/api/proxy')
}

// do regular api stuff. e.g .calling third party api
return $fetch('https://example-external.com/api/blog-posts')
})
While the component would look like:
<script setup lang="ts">
const {data} = await useAsyncData('my-key', () => {
return $fetch('/api/proxy', {
headers: { 'x-is-ssr': import.meta.server ? '1' : '0' }
})
})
</script>

<template>
<div>
<pre>{{data}}</pre>
</div>
</template>
<script setup lang="ts">
const {data} = await useAsyncData('my-key', () => {
return $fetch('/api/proxy', {
headers: { 'x-is-ssr': import.meta.server ? '1' : '0' }
})
})
</script>

<template>
<div>
<pre>{{data}}</pre>
</div>
</template>
manniL
manniL5mo ago
why wouldn't you defineCachedEventHandler and e.g. the CF KV store for caching?
Mähh
MähhOP5mo ago
CF KV-Store has limits on free-plan. Even if im sure im not reaching this limits, CF cache does not have any limits. The response i want to cache, is a json which represents the structure of my nuxt page (headless cms). Such headless CMS's or other headless providers often has limits on requests / traffic, i try to prevent to call them wherever i can. Instead i use webhooks (e.g. when a page is changed in the CMS) to call the CF API to flush the cache for the specific api path. E.g. (example.com/about-us) triggers the api call example.com/api/proxy/cms/about-us => this response is cached by CF. A plus: If cloudflare responde with a cache hit, as far as i saw it correctly, your daily worker request limits does not increase as well.
manniL
manniL5mo ago
fair point. though worker subrequests shouldn't count either IIRC nevertheless, I see you point. might be worth raising an issue in the nitro repository for a way to opt out from emulating
Mähh
MähhOP5mo ago
I found out a few things while reading through various Cloudflare docs and Github issues now. First of all: Normally CF-Workers (or pages, which are basically workers) can cache responses using "fetch": https://developers.cloudflare.com/workers/examples/cache-using-fetch/ Regarding the suggestion of defineCachedEventHandler: There is currently an issue (https://github.com/unjs/nitro/issues/2124) which topic is to enable the CacheStorage in Cloudflare Worker environments (https://developers.cloudflare.com/workers/runtime-apis/cache/#accessing-cache) What would make the internal cache-fetch then unnecessary, since defineCacheEventHandler takes care of the caching by using the CF cache directly. Anyways i think even if nitro supports it, the $fetch within a nuxt application is still the problem.
export default defineEventHandler(async (event) => {
// undefined while SSR, if component using $fetch('/api/foo')
// so no access to "ctx" and "req" of cloudflare
console.log(event.context.clouflare)
})
export default defineEventHandler(async (event) => {
// undefined while SSR, if component using $fetch('/api/foo')
// so no access to "ctx" and "req" of cloudflare
console.log(event.context.clouflare)
})
Without the cloudflare context in such an handler, we are not able to make use of the global caches.default of cloudflare, since we need the request and ctx to create new cache entries:
/// example of plain worker with cache usage
export default {
async fetch(request, env, ctx) {
let res = await fetch(request);

res = new Response(res.body, res);
ctx.waitUntil(caches.default.put(request, res.clone()));
return res;
},
};
/// example of plain worker with cache usage
export default {
async fetch(request, env, ctx) {
let res = await fetch(request);

res = new Response(res.body, res);
ctx.waitUntil(caches.default.put(request, res.clone()));
return res;
},
};
From nuxt docs:
But during server-side-rendering, since the $fetch request takes place 'internally' within the server, it doesn't include the user's browser cookies, nor does it pass on cookies from the fetch response.
But during server-side-rendering, since the $fetch request takes place 'internally' within the server, it doesn't include the user's browser cookies, nor does it pass on cookies from the fetch response.
TLDR; So it is an nuxt-issue, not a nitro issue, or am i wrong? @pi0 i hope it is fine i ping you here, since you are involved nearly in every issue and discussion regarding nuxt/nitro and cf-environments i found so far 😄
Cloudflare Docs
Cache using fetch · Cloudflare Workers docs
Setting the cache level to Cache Everything will override the default cacheability of the asset. For time-to-live (TTL), Cloudflare will still rely on …
GitHub
route caching using CacheStorage · Issue #2124 · unjs/nitro
(context, pinged by @Atinux asked about this possibility also #1982 by @ManasMadrecha) For cloudflare and platforms that provide a CacheStorage instance, we might allow this caching method instead ...
Cloudflare Docs
Cache · Cloudflare Workers docs
Control reading and writing from the Cloudflare global network cache.
Mähh
MähhOP5mo ago
So, i found a workaround for me:
export default defineEventHandler(async () => {
const isCloudflareCachesAvailable = typeof caches !== 'undefined' && !!caches?.default

const fetcher = async () => { // fetch from origin }

if (isCloudflareCachesAvailable) {
return await useCloudflareCache(event, fetcher) as TTransform
}

return fetcher()
})
export default defineEventHandler(async () => {
const isCloudflareCachesAvailable = typeof caches !== 'undefined' && !!caches?.default

const fetcher = async () => { // fetch from origin }

if (isCloudflareCachesAvailable) {
return await useCloudflareCache(event, fetcher) as TTransform
}

return fetcher()
})
useCloudflareCache
export default async (event: H3Event, fetcher: () => Promise<any>) => {
const {public: publicConfig} = useRuntimeConfig()
const cacheUrl = new URL(`https://${publicConfig.appUrl}${event.node.req.url}`)

// @ts-ignore
const cache = caches.default

let hit = await cache.match(cacheUrl.toString())

if (!hit) {
const fetchResult = await fetcher()
const response = new Response(responseData)

event.context.waitUntil(cache.put(cacheUrl.toString(), response.clone()))
return fetchResult
}

return hit
}
export default async (event: H3Event, fetcher: () => Promise<any>) => {
const {public: publicConfig} = useRuntimeConfig()
const cacheUrl = new URL(`https://${publicConfig.appUrl}${event.node.req.url}`)

// @ts-ignore
const cache = caches.default

let hit = await cache.match(cacheUrl.toString())

if (!hit) {
const fetchResult = await fetcher()
const response = new Response(responseData)

event.context.waitUntil(cache.put(cacheUrl.toString(), response.clone()))
return fetchResult
}

return hit
}
Want results from more Discord servers?
Add your server