- Opublikowano
Subtelny bug cache'owania w SSR, który fragmentuje cache: dlaczego url.hostname kłamie podczas renderowania na serwerze
- Autorzy
Problem, który masz cały czas przed oczami
Masz już warstwę cache'ującą dla swoich endpointów API w Nuxcie. Kod wygląda poprawnie:
const url = getRequestURL(event);
const cacheKey = encodeURIComponent(url.hostname + url.pathname + url.search);
Używasz hostname'u, żeby cache key był unikalny dla różnych domen w monorepo. Sprytne, prawda?
Nic z tego. Ta niewinnie wyglądająca linijka po cichu fragmentuje cache: Googlebot dostaje z cache'u inne dane niż użytkownicy, a cache hit rate leci na łeb.
Źródło problemu: niespójny hostname w SSR
Kiedy getRequestURL(event) z Nitro wyciąga hostname, rekonstruuje URL na podstawie nagłówków HTTP requestu. Problem? Hostname zmienia się w zależności od kontekstu requestu:
| Kontekst requestu | url.hostname zwraca | Oczekiwane |
|---|---|---|
| SSR (crawl Googlebota) | localhost albo wewnętrzne IP | deante.pl |
| Request z przeglądarki | deante.pl | deante.pl |
| Preview na Vercelu | deante-git-xxx.vercel.app | deante.pl |
Podczas SSR, gdy serwer Nuxta pobiera dane wewnętrznie (albo gdy crawler wywołuje renderowanie po stronie serwera), nagłówek Host często nie zawiera twojej właściwej domeny. Zamiast tego dostajesz:
localhostna lokalnym środowisku- wewnętrzne adresy IP albo localhost przy SSR na produkcji
- dynamiczne URL-e preview z Vercela na stagingu
Na czym polega fragmentacja cache'u
Oto co się realnie dzieje:
1. Googlebot requests /products
→ SSR runs, getRequestURL returns hostname = "localhost"
→ Cache key: "localhost/api/public/products?page=1"
→ Cache MISS → Data generated and cached
2. Real user visits /products
→ Browser makes request with Host: deante.pl
→ Cache key: "deante.pl/api/public/products?page=1"
→ Cache MISS (different key!) → Data generated AGAIN
3. Another user visits /products
→ Cache key: "deante.pl/api/public/products?page=1"
→ Cache HIT (finally!)
Masz teraz dwa osobne wpisy w cache'u dla dokładnie tych samych danych. Googlebot dostaje potencjalnie nieaktualną treść, a ty wykonujesz tę samą robotę dwa razy.
Rozwiązanie: wpisz domenę na sztywno
Skoro hostname dla danej aplikacji i tak jest stały (domena produkcyjna się nie zmieni), po prostu wpisz go na sztywno:
// Before: Dynamic hostname (broken)
const cacheKey = encodeURIComponent(url.hostname + url.pathname + url.search);
// After: Static hostname (correct)
const cacheKey = encodeURIComponent('deante.pl' + url.pathname + url.search);
Jeśli pracujesz w monorepo, w którym kilka aplikacji dzieli ten sam util do cache'owania, każda z nich dostaje swoją zahardkodowaną domenę:
// apps/deante.pl/src/server/utils/cache.utils.server.ts
const cacheKey = encodeURIComponent('deante.pl' + url.pathname + url.search);
// apps/deantedesign.studio/src/server/utils/cache.utils.server.ts
const cacheKey = encodeURIComponent('deantedesign.studio' + url.pathname + url.search);
Dlaczego nie runtime config?
Pewnie myślisz: „Zamiast hardkodować, użyję runtimeConfig.public.fullSitePath!”
const config = useRuntimeConfig();
const siteHostname = new URL(config.public.fullSitePath).hostname;
const cacheKey = encodeURIComponent(siteHostname + url.pathname + url.search);
To działa, ale weź pod uwagę:
- Domena produkcyjna się nie zmieni - hardkodowanie jest po prostu prostsze
- Deploye preview -
NUXT_PUBLIC_VERCEL_URLzwraca dynamiczne URL-e Vercela, które i tak potrafią pofragmentować cache, jeśli chcesz, żeby preview i produkcja korzystały z tego samego cache'u - Mniejszy narzut w runtimie - nie trzeba parsować URL-a
W większości przypadków hardkodowanie to najbardziej pragmatyczny wybór.
Szersza lekcja
Ten bug uczy ważnej zasady: nigdy nie buduj cache keya z wartości wyciągniętych z requestu, jeśli w grze jest SSR.
Inne wartości, które potrafią się różnić między SSR a klientem:
- nagłówek
Host(o którym już było) User-Agent(wpływa na wykrywanie SSR)- nagłówki
X-Forwarded-*(zależą od konfiguracji proxy) - wartości z cookies (mogą się nie przekazywać przy wewnętrznych wywołaniach SSR)
Do cache keyów lepiej używać:
- statycznych, zahardkodowanych identyfikatorów
- wyłącznie ścieżki URL i parametrów query
- wartości ze zmiennych środowiskowych ustawianych na etapie builda
Pełna implementacja
Tak wygląda nasz produkcyjny util do cache'owania po poprawce:
import { defineEventHandler, type H3Event } from 'h3';
import pako from 'pako';
const TTL = 259200; // 3 days
const SWR_INVALIDATION_KEY = 'cache:flag:swr';
type CacheEntry<T> = {
data: T;
createdAt: number;
};
export const defineCustomCacheEventHandler = <T>(
handler: (event: H3Event) => T | Promise<T>,
) => {
return defineEventHandler(async (event: H3Event) => {
if (process.env.NODE_ENV === 'development') {
return await handler(event);
}
const url = getRequestURL(event);
// Hardcoded domain - consistent across SSR and client
const cacheKey = encodeURIComponent('deante.pl' + url.pathname + url.search);
const storage = useStorage('cache');
const cached = await storage.getItem<string>(cacheKey);
if (cached) {
const entry = decompress<T>(cached);
if (entry) return entry.data;
}
const result = await handler(event);
const compressed = compress({ data: result, createdAt: Date.now() });
await storage.setItem(cacheKey, compressed, { ttl: TTL });
return result;
});
};
Wskazówki do debugowania
Jak sprawdzić, czy cache faktycznie działa:
Zajrzyj do logów cache'u - dorzuć logowanie, żeby zobaczyć realne cache keye:
console.log(`[Cache] Key: ${cacheKey}`);Porównaj SSR z klientem - wyślij request przez
curl(odpowiednik SSR) i z przeglądarki, sprawdź, czy keye się zgadzająPodejrzyj Redisa bezpośrednio - poszukaj zduplikowanych wpisów z różnymi prefiksami hostname
Podsumowanie
Niespójność url.hostname między requestami SSR a tymi z przeglądarki to subtelny, ale kosztowny bug. Po cichu fragmentuje cache, marnuje moc obliczeniową i potrafi narobić problemów z SEO, gdy crawlery dostają inną zacache'owaną treść niż użytkownicy.
Poprawka jest banalna: wpisz domenę produkcyjną na sztywno. I tak się nie zmieni, a ta prostota zwraca się spójnym zachowaniem cache'u we wszystkich kontekstach requestu.
Następnym razem, gdy będziesz wdrażał cache w SSR, pamiętaj: nagłówki requestu kłamią podczas renderowania po stronie serwera.