Kamil Owczarek
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 requestuurl.hostname zwracaOczekiwane
SSR (crawl Googlebota)localhost albo wewnętrzne IPdeante.pl
Request z przeglądarkideante.pldeante.pl
Preview na Verceludeante-git-xxx.vercel.appdeante.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:

  • localhost na 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 MISSData 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ę:

  1. Domena produkcyjna się nie zmieni - hardkodowanie jest po prostu prostsze
  2. Deploye preview - NUXT_PUBLIC_VERCEL_URL zwraca 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
  3. 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:

  1. Zajrzyj do logów cache'u - dorzuć logowanie, żeby zobaczyć realne cache keye:

    console.log(`[Cache] Key: ${cacheKey}`);
    
  2. Porównaj SSR z klientem - wyślij request przez curl (odpowiednik SSR) i z przeglądarki, sprawdź, czy keye się zgadzają

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