Кеширование и ревалидация
Конфигурация сегментов маршрута, unstable_cache, revalidateTag / revalidatePath и stale-while-revalidate.
Кешируйте ответы обработчиков маршрутов и данные, затем ревалидируйте их по таймеру или по требованию. Устаревшие записи возвращаются сразу, пока в фоне выполняется одно обновление.
Обработчики маршрутов
Обработчики маршрутов GET по умолчанию динамические. Включите кеширование в конфигурации сегмента маршрута:
export const revalidate = 60;export async function GET() { const posts = await db.posts.latest(); return Response.json(posts);}| Экспорт | Поведение |
|---|---|
| (нет) | Выполняется при каждом запросе |
dynamic = "force-static" | Кешируется до ревалидации |
revalidate = N | Кешируется; устаревает через N секунд |
revalidate = 0 или dynamic = "force-dynamic" | Выполняется при каждом запросе |
Другие значения dynamic вызывают ошибку TX031. В их числе "error" из Next.js, поскольку Text.js не может определить, когда обработчик читает запрос.
Для кешируемых обработчиков создаётся одна запись на каждый путь, а их request выглядит так же, как во время сборки: без строки запроса, заголовков, cookie и тела. Читайте их в динамическом обработчике. Кешируются только GET и HEAD. Ответы со статусом 5xx, заголовком Set-Cookie или Cache-Control: private / no-store возвращаются, но никогда не сохраняются.
Заголовки
Кешированные ответы содержат:
| Заголовок | Значение |
|---|---|
x-textjs-cache | MISS, HIT или STALE |
Cache-Control | s-maxage=N, stale-while-revalidate=<1 year − N> или s-maxage=31536000 для force-static |
Когда middleware устанавливает cookie, переписывает путь или переопределяет заголовки запроса, сгенерированный заголовок становится private, no-cache, no-store, max-age=0, must-revalidate, поэтому общие кеши никогда не воспроизводят решение, принятое для отдельного посетителя. Заголовок Cache-Control, заданный обработчиком, сохраняется.
В text dev конфигурация сегмента применяется, но ничего не сохраняется, поэтому каждый запрос запускает обработчик.
Кеш данных
Кешируйте любую асинхронную функцию с помощью unstable_cache из @textjs/app/cache:
import { unstable_cache } from "@textjs/app/cache";export const getPosts = unstable_cache( async (author: string) => db.posts.byAuthor(author), ["posts"], { tags: ["posts"], revalidate: 300 },);Ключ состоит из частей ключа и аргументов, закодированных по значению (аргументы undefined и null, Map, Set, Date и URL остаются различимыми). Если части ключа не заданы, функцию идентифицируют её исходный код и место создания; передавайте части ключа, когда функция захватывает значения в замыкании, например при создании для каждого запроса или в цикле. Результаты сериализуются в JSON, поэтому каждый вызов возвращает новую копию (а объекты Date возвращаются в виде строк); если результат нельзя сериализовать, выбрасывается исключение. cache(fn, { key, tags, revalidate }) принимает те же настройки в виде объекта параметров.
Внутри кешируемого обработчика маршрута записи данных передают маршруту свои теги и наименьшее значение revalidate: ревалидация "posts" обновляет и /api/posts. Вызов данных с revalidate: 0 делает маршрут динамическим.
Выдача устаревших данных при ревалидации
Когда возраст записи превышает revalidate:
- Следующий запрос немедленно получает устаревшее значение (
STALE). - В фоне выполняется одно обновление; параллельные запросы используют его совместно.
- Если обновление выбрасывает исключение, устаревшее значение продолжает возвращаться, а ошибка записывается в журнал.
На Vercel и Cloudflare фоновые обновления регистрируются через waitUntil(). Записи старше года всегда пересчитываются в основном потоке.
Ревалидация по требованию
import { revalidatePath, revalidateTag } from "@textjs/app/cache";export async function POST(request: Request) { // Anyone can POST here: require a secret shared with your CMS or webhook. if (request.headers.get("authorization") !== `Bearer ${process.env.REVALIDATE_SECRET}`) { return new Response("Unauthorized", { status: 401 }); } await revalidateTag("posts"); await revalidatePath("/api/feed"); return Response.json({ revalidated: true });}| Вызов | Что инвалидируется |
|---|---|
revalidateTag("posts") | Всё с тегом posts; следующий запрос запускает пересчёт |
revalidateTag("posts", "max") | Помечает данные как устаревшие; следующий запрос получает устаревшие данные, пока выполняется обновление |
revalidatePath("/api/feed") | Указанный путь |
revalidatePath("/api/posts/[id]") | Каждый путь динамического маршрута |
revalidatePath("/api", "layout") | Всё внутри /api |
Обе функции возвращают промис. Для хранилища в памяти инвалидация происходит немедленно; используйте await, если пользовательское хранилище асинхронное.
Пользовательские хранилища кеша
Хранилище по умолчанию — LRU в памяти (1000 записей, около 50 МБ) для каждого экземпляра сервера. Чтобы использовать общий кеш для нескольких экземпляров, укажите в cacheHandler модуль, который экспортирует CacheStore по умолчанию:
export default defineConfig({ cacheHandler: "./cache-handler.ts" });import type { CacheStore } from "@textjs/app/cache";const store: CacheStore = { async get(key) { return redis.get(key).then((v) => (v ? JSON.parse(v) : undefined)); }, async set(key, entry) { await redis.set(key, JSON.stringify(entry)); }, async delete(key) { await redis.del(key); }, async markTags(tags, mode, at) { for (const t of tags) await redis.hset(`tag:${t}`, mode, at); }, async getTagState(tags) { /* max expiredAt / staleAt across tags */ return { expiredAt: 0, staleAt: 0 }; },};export default store;Записи хранятся в обычном JSON. Вместо этого setCacheStore(store) устанавливает хранилище во время выполнения.
Примечания по платформам
- У каждого serverless-экземпляра (функции Vercel, изолята Cloudflare) собственный кеш в памяти.
Cache-Control: s-maxageпозволяет CDN платформы тоже кешировать ответы; ревалидация по требованию не очищает CDN. Используйте общийcacheHandler, еслиrevalidateTagдолжен затрагивать каждый экземпляр. - Cloudflare требует флаг
nodejs_compat(кеш используетAsyncLocalStorage). См. Развёртывание.