From eb48923de5f04cdf22996e7da60bca2ef4f16531 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 20 Aug 2026 17:22:08 +0200 Subject: [PATCH 1/2] feat: Implement cache --- apps/docs/content/docs/dev/advanced/redis.mdx | 115 ++++++ apps/docs/content/docs/dev/cache.mdx | 183 +++++++++ apps/docs/content/docs/dev/fetcher.mdx | 42 +- apps/docs/content/docs/dev/meta.json | 1 + .../login/reset-password/page.tsx | 9 + .../(vitnode-core)/settings/layout.tsx | 5 - .../(vitnode-core)/core/users/[id]/page.tsx | 17 + .../src/app/[locale]/admin/(auth)/layout.tsx | 2 - apps/docs/src/app/[locale]/layout.client.tsx | 45 +- apps/docs/src/app/[locale]/layout.tsx | 15 +- apps/docs/src/app/global.css | 8 +- .../monorepo/apps/web/.env.example | 13 +- .../src/app/[locale]/admin/(auth)/layout.tsx | 2 - packages/vitnode/config/next.config.ts | 50 +++ .../src/api/lib/check-staff-permission.ts | 44 +- .../api/lib/staff-permission-cache.test.ts | 331 +++++++++++++++ .../src/api/lib/staff-permission-cache.ts | 140 +++++++ .../admin/roles/routes/delete.route.ts | 5 + .../admin/roles/routes/update.route.ts | 5 + .../admin/staff/routes/create.route.ts | 3 + .../admin/staff/routes/delete.route.ts | 3 + .../staff/routes/update-permissions.route.ts | 3 + .../admin/users/routes/update.route.ts | 15 + .../lib/api/get-moderator-permissions-api.ts | 16 +- .../src/lib/api/get-session-admin-api.ts | 25 +- .../vitnode/src/lib/api/get-session-api.ts | 25 +- packages/vitnode/src/lib/cache-tags.ts | 23 ++ packages/vitnode/src/lib/cache/client.ts | 169 ++++++++ .../vitnode/src/lib/cache/handlers.test-d.ts | 34 ++ .../vitnode/src/lib/cache/handlers.test.ts | 383 ++++++++++++++++++ .../lib/cache/incremental-cache-handler.ts | 273 +++++++++++++ packages/vitnode/src/lib/cache/tags.ts | 156 +++++++ .../src/lib/cache/use-cache-handler.ts | 254 ++++++++++++ .../src/routes/admin/core/users/[id]/page.tsx | 17 + .../routes/main/login/reset-password/page.tsx | 9 + .../src/views/admin/layouts/admin-layout.tsx | 136 +++++-- .../layouts/breadcrumb/breadcrumb-admin.tsx | 17 +- .../layouts/sidebar/nav/nav-skeleton.tsx | 30 ++ .../views/admin/layouts/sidebar/sidebar.tsx | 17 +- .../advanced/search/mutation-api.server.ts | 21 +- .../core/dashboard/dashboard-admin-view.tsx | 20 +- .../password-reset/password-reset-view.tsx | 60 ++- .../src/views/search/discover-view.tsx | 16 +- .../vitnode/src/views/search/fetch-feed.ts | 87 ++++ .../vitnode/src/views/search/search-view.tsx | 15 +- 45 files changed, 2705 insertions(+), 154 deletions(-) create mode 100644 apps/docs/content/docs/dev/cache.mdx create mode 100644 packages/vitnode/src/api/lib/staff-permission-cache.test.ts create mode 100644 packages/vitnode/src/api/lib/staff-permission-cache.ts create mode 100644 packages/vitnode/src/lib/cache-tags.ts create mode 100644 packages/vitnode/src/lib/cache/client.ts create mode 100644 packages/vitnode/src/lib/cache/handlers.test-d.ts create mode 100644 packages/vitnode/src/lib/cache/handlers.test.ts create mode 100644 packages/vitnode/src/lib/cache/incremental-cache-handler.ts create mode 100644 packages/vitnode/src/lib/cache/tags.ts create mode 100644 packages/vitnode/src/lib/cache/use-cache-handler.ts create mode 100644 packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-skeleton.tsx create mode 100644 packages/vitnode/src/views/search/fetch-feed.ts diff --git a/apps/docs/content/docs/dev/advanced/redis.mdx b/apps/docs/content/docs/dev/advanced/redis.mdx index 181bdd053..3d1c50e9a 100644 --- a/apps/docs/content/docs/dev/advanced/redis.mdx +++ b/apps/docs/content/docs/dev/advanced/redis.mdx @@ -55,6 +55,12 @@ REDIS_URL=redis://localhost:6379 REDIS_PASSWORD=root ``` + + The API takes its connection from `vitnode.api.config.ts`, but the [Next.js cache + handlers](#caching-nextjs-output) read these variables directly. If you run the + web app and the API as separate services, set them on both. + + `root` is a convenience default for local development. Set a strong `REDIS_PASSWORD` in production. @@ -189,6 +195,12 @@ export const postsRoute = buildRoute({ ## Caching database queries + + This page covers the cache *inside* the API. The Next.js cache in front of it + stores whole responses and is expired differently. [Cache](/docs/dev/cache) + covers both and how to choose between them. + + The most useful method is `remember` - it runs your loader only on a cache miss, which is perfect for hot, rarely-changing data. Good candidates are reference data such as role-name translations or resolved staff permissions. @@ -244,6 +256,109 @@ revoking an admin takes effect immediately - not when the cache expires. it never changes what a request can see beyond the short TTL above. +## Staff permission caching + +Resolving what an admin or a moderator is allowed to do takes three queries: the +user's roles, whether any of them is `root`, and the staff entries attached to +that user or those roles. Every gated request runs it, and an AdminCP page that +renders a dozen gated elements runs it a dozen times. + +**When `redis` is configured, VitNode caches the resolved set for you** - keyed +per user and per kind, in the shared framework namespace, for at most 60 seconds. + +The TTL is a backstop rather than the mechanism: the routes that can change the +answer expire the cache themselves, so a permission change is live immediately. + +- Editing a **staff entry attached to a user** clears that user's entry. +- Editing a **role**, deleting one, or editing a **staff entry attached to a + role** moves a generation stamp that every key is built from, which expires + every user's cached set in a single write - the members of a role are not a set + a mutation can cheaply enumerate. +- Changing a user's **primary or secondary roles** clears that user's entry. + + + Without Redis the read misses, the write is a no-op, and every request resolves + permissions from the database exactly as before. + + +## Caching Next.js output + +Everything above is the cache *inside* the API. Redis also backs the **Next.js** +caches in the web process, so a cached page or `fetch` response is shared by +every instance instead of being rebuilt once per container. + +**There is nothing to add to `next.config.ts`.** `vitNodeNextConfig` wires the +handlers up when `REDIS_URL` is set - the same variable that turns Redis on for +the API - because Next takes a cache handler as a module *path* and cannot be +handed a config object: + +```ts title="src/next.config.ts" +// Already all it takes - the handlers follow REDIS_URL. +export default vitNodeNextConfig(nextConfig); +``` + + + The API gets its connection from `vitnode.api.config.ts`; the cache handlers + cannot - they are loaded by Next itself, outside your app's module graph. They + read `REDIS_URL` and `REDIS_PASSWORD` from the environment, so both must be set + wherever the **web** process runs, not only where the API runs. In a split + deployment that means adding them to the frontend service too. + + +### What each handler covers + +VitNode registers two, because Next has two caches and they are not the same one: + + + +Prerendered pages are deliberately left on disk. A custom handler *replaces* +Next's filesystem cache outright, and the pages `next build` produced only exist +there - putting them in Redis would mean serving one deploy's HTML from another +deploy's cache. Their payloads are also Next-internal structures that would need +re-checking on every upgrade. The build output is identical on every instance +anyway, so there is nothing to gain by sharing it. + +### Why it matters: shared invalidation + +The part that is genuinely broken without this is not speed, it is correctness. +`updateTag` runs on whichever instance handled the Server Action. With per-process +caches the other instances never hear about it and keep serving the old page until +their own copies age out. + +Both handlers record every revalidation in Redis and check it on read, so an +invalidation raised anywhere is seen everywhere - including for the prerendered +pages that still live on local disk, which each instance then rebuilds for itself. + +### Behaviour and limits + +- **A cold start misses.** The connection is opened in the background and commands + fail fast until it is ready, so the first requests after a boot render normally. +- **A Redis outage is a miss, never an error.** Reads fall through and writes are + dropped; pages render as if nothing were cached. +- **Entries carry a TTL** so abandoned keys cannot accumulate: `use cache` entries + expire at their `cacheLife` `expire`, stored `fetch` responses after 7 days, and + revalidation markers after 30 days. +- **Everything lives under `vitnode:next:`**, separate from the API's + `vitnode:cache:` namespace, so neither side can clear the other by accident. + + + To use Redis for the API but keep Next on its built-in caches, override the two + keys after the wrapper: `vitNodeNextConfig({ cacheHandler: undefined, cacheHandlers: undefined })`. + + ## Rate limiting with Redis When `redis` is configured, the [rate limiter](/docs/dev/advanced/rate-limiter) diff --git a/apps/docs/content/docs/dev/cache.mdx b/apps/docs/content/docs/dev/cache.mdx new file mode 100644 index 000000000..5cd23e39b --- /dev/null +++ b/apps/docs/content/docs/dev/cache.mdx @@ -0,0 +1,183 @@ +--- +title: Cache +description: Learn how caching works in VitNode across Next.js and Redis. +--- + +VitNode uses a simple two-layer caching model: + +1. **Next.js Cache (App)**: Caches rendered UI and public API responses. +2. **Redis Cache (API)**: Caches database queries and heavy computations inside Hono route handlers. + +Caching is **opt-in**. Dynamic data stays fresh by default. + +--- + +## App Caching (Next.js) + +### Public Data (`"use cache"`) + +For public data shared by all visitors (e.g. blog posts, site configuration), use Next.js `"use cache"`. + +Always use `coreFetcher` (which skips request cookies) and call `await connection()` so Next.js can prerender safely: + +```ts title="services/announcements.ts" +import { coreFetcher } from '@vitnode/core/lib/fetcher/core'; +import { cacheLife, cacheTag } from 'next/cache'; +import { connection } from 'next/server'; + +export const getAnnouncements = async (locale: string) => { + 'use cache'; + cacheLife('hours'); + cacheTag('announcements'); + await connection(); + + const res = await coreFetcher(announcementsModule, { + module: 'announcements', + path: '/', + method: 'get', + args: { query: { lang: locale } }, + }); + + return await res.json(); +}; +``` + +Render cached data inside a `` boundary in your page: + +```tsx title="page.tsx" +}> + + +``` + + + Common presets: `'seconds'`, `'minutes'`, `'hours'`, `'days'`, `'weeks'`, `'max'`, or custom `{ stale, revalidate, expire }`. + + +### Per-User Data (`React.cache`) + +Never cache user-specific data (such as sessions or permissions) with `"use cache"`. + +Instead, use React's `cache()` to deduplicate calls within a single render pass without sharing data between visitors: + +```ts title="services/profile.ts" +import { fetcher } from '@vitnode/core/lib/fetcher'; +import { cache } from 'react'; + +export const getMyProfile = cache(async () => { + const res = await fetcher(usersModule, { + module: 'users', + path: '/me', + method: 'get', + }); + + return await res.json(); +}); +``` + +--- + +## Invalidation APIs + +When data changes, expire the cached entries using the appropriate helper: + +import { TypeTable } from "fumadocs-ui/components/type-table"; + + + +### Invalidation Example + +Call `updateTag` inside a Server Action after mutating data: + +```ts title="mutation-api.server.ts" +'use server'; + +import { fetcher } from '@vitnode/core/lib/fetcher'; +import { updateTag } from 'next/cache'; + +export const publishAnnouncement = async (id: number) => { + const res = await fetcher(announcementsModule, { + module: 'announcements', + path: '/{id}/publish', + method: 'post', + args: { params: { id } }, + }); + + // Purge the cache immediately + updateTag('announcements'); + + return await res.json(); +}; +``` + + + Content Engine entries are automatically tagged and revalidated for you via `revalidateContent`. See [Content Engine Caching](/docs/dev/content-engine/public-api-and-caching). + + +--- + +## Where the App cache is stored + +By default Next.js keeps `"use cache"` entries in a per-process memory cache and everything else on local disk. That is fine for one instance and wrong for several: an `updateTag` runs on whichever instance handled the Server Action, and the others keep serving what they already had. + +**Set `REDIS_URL` and VitNode swaps both Next.js cache handlers for Redis-backed ones.** No `next.config.ts` change is required - `vitNodeNextConfig` registers them: + +```ts title="src/next.config.ts" +// Unchanged. The handlers follow REDIS_URL. +export default vitNodeNextConfig(nextConfig); +``` + +What that buys you: + +- `"use cache"` entries are stored in Redis, so they survive restarts and rolling deploys and are shared between instances. +- `fetch` Data Cache responses are shared too. +- **Every tag revalidation is shared**, so `updateTag` on one instance invalidates all of them - including for prerendered pages, which stay on each instance's own disk and are rebuilt individually. + + + The handlers are loaded by Next.js itself and read `REDIS_URL` / `REDIS_PASSWORD` straight from the environment - they cannot see `vitnode.api.config.ts`. In a split deployment, set them on the **frontend** service as well as the API. + + +See [Redis](/docs/dev/advanced/redis#caching-nextjs-output) for what each handler covers, TTLs, and how to opt out. + +## API Caching (Redis) + +Inside your Hono API route handlers, use `c.get("cache")` to store expensive query results in Redis: + +```ts title="plugins/stats/src/routes/stats.ts" +// Cache result on miss for 5 minutes +const stats = await c.get('cache').remember( + `stats:${containerId}`, + 60 * 5, // TTL in seconds + async () => await calculateHeavyStats(c, containerId), +); +``` + +### Invalidation + +Delete the key whenever the underlying record changes: + +```ts title="plugins/stats/src/routes/update.ts" +await c.get('cache').delete(`stats:${containerId}`); +``` + + + Redis is optional. If not configured, `c.get("cache")` gracefully acts as a no-op and runs the callback directly. See [Redis Setup](/docs/dev/advanced/redis). + diff --git a/apps/docs/content/docs/dev/fetcher.mdx b/apps/docs/content/docs/dev/fetcher.mdx index 17f733da2..8dbe9465e 100644 --- a/apps/docs/content/docs/dev/fetcher.mdx +++ b/apps/docs/content/docs/dev/fetcher.mdx @@ -63,20 +63,42 @@ const response = await fetcher(categoriesAdminModule, { ### Caching Responses -You can leverage Next.js caching by passing cache options: +`fetcher()` forwards the incoming request's cookies and headers, which means two +things at once: the API knows who is asking, and the call can never sit inside a +`"use cache"` function. For a response that is the same for every visitor, reach +for `coreFetcher` - the same typed interface without the request-bound headers - +and cache that: ```ts -const response = await fetcher(usersModule, { - path: '/session', - method: 'get', - module: 'users', - options: { - // [!code ++] - cache: 'force-cache', // Uses Next.js cache - }, -}); +import { coreFetcher } from '@vitnode/core/lib/fetcher/core'; +import { cacheLife, cacheTag } from 'next/cache'; + +const getAnnouncements = async () => { + // [!code ++:3] + 'use cache'; + cacheLife('minutes'); + cacheTag('announcements'); + + const response = await coreFetcher(announcementsModule, { + path: '/', + method: 'get', + module: 'announcements', + }); + + return await response.json(); +}; ``` + + Do not reach for `options: { cache: 'force-cache' }` on a `fetcher()` call. It + stores the response in the Next.js Data Cache keyed by the request - cookie + included - with no expiry, so the visitor keeps being served their own data + from whenever it was first fetched. Use React's `cache()` for those instead. + + +See [Cache](/docs/dev/cache) for how to decide which of the two a given route is, +and how to expire what you store. + ### Cookie Management When working with authentication or sessions, you might need to handle cookies. React Server Components have special considerations for cookie handling: diff --git a/apps/docs/content/docs/dev/meta.json b/apps/docs/content/docs/dev/meta.json index 47d11afc0..b2e0d3b9a 100644 --- a/apps/docs/content/docs/dev/meta.json +++ b/apps/docs/content/docs/dev/meta.json @@ -14,6 +14,7 @@ "database", "content-engine", "fetcher", + "cache", "working-with-users", "i18n", "search", diff --git a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/reset-password/page.tsx b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/reset-password/page.tsx index 02f57b3b6..e61670367 100644 --- a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/reset-password/page.tsx +++ b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/reset-password/page.tsx @@ -4,6 +4,15 @@ import { getTranslations } from "next-intl/server"; import { PasswordResetView } from "@vitnode/core/views/auth/password-reset/password-reset-view"; +// instant = false: kept on purpose. The page is only meaningful on an install +// with an email adapter, and `PasswordResetView` calls `notFound()` when there +// is none - so the response status depends on a read only the API can answer. +// That read cannot be prerendered, and behind a `` boundary it would +// land after the fallback had already committed the response to 200, leaving +// crawlers, caches and monitoring with a successful reset-password page whose +// body says not-found. The route blocks so the status is decided first. +export const instant = false; + export const generateMetadata = async (): Promise => { const t = await getTranslations("core.auth.reset_password"); diff --git a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/layout.tsx b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/layout.tsx index 0e3b8c17b..b1bf02a42 100644 --- a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/layout.tsx +++ b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/layout.tsx @@ -9,11 +9,6 @@ export const metadata: Metadata = { }, }; -// instant = false: kept on purpose. Every route under this layout is gated on a -// signed-in user in `LayoutSettings`, which calls `notFound()` when there isn't -// one. Moving that read inside `` would turn a real 404 into a 200 -// shell that later swaps to a not-found body, so the gate stays where it is and -// the segment is allowed to block. export const instant = false; export default function Layout( diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/[id]/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/[id]/page.tsx index a5f1d66ae..d6990dd0a 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/[id]/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/[id]/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next/dist/types"; import { getTranslations } from "next-intl/server"; import dynamic from "next/dynamic"; +import { connection } from "next/server"; import React from "react"; import { adminModule } from "@vitnode/core/api/modules/admin/admin.module"; @@ -46,6 +47,18 @@ export const generateMetadata = async ({ }; }; +/** + * `generateMetadata` puts the user's name in the title, which needs a fetch that + * cannot be cached - `fetcher` forwards the request's cookies and `use cache` + * cannot enclose a runtime read. This marks the route as intentionally partly + * dynamic so the metadata is allowed to be, while the body still prerenders. + */ +const DynamicMarker = async () => { + await connection(); + + return null; +}; + export default async function Page({ params, }: { @@ -56,6 +69,10 @@ export default async function Page({ return (
+ + + + }> diff --git a/apps/docs/src/app/[locale]/admin/(auth)/layout.tsx b/apps/docs/src/app/[locale]/admin/(auth)/layout.tsx index a6e0ca312..83191e110 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/layout.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/layout.tsx @@ -5,8 +5,6 @@ import { import { vitNodeConfig } from "@/vitnode.config"; -export const instant = false; - export default function Layout(props: AdminLayoutProps) { return ; } diff --git a/apps/docs/src/app/[locale]/layout.client.tsx b/apps/docs/src/app/[locale]/layout.client.tsx index 8ae133f18..e10eb48fc 100644 --- a/apps/docs/src/app/[locale]/layout.client.tsx +++ b/apps/docs/src/app/[locale]/layout.client.tsx @@ -1,16 +1,43 @@ "use client"; -import { useParams } from "next/navigation"; +import { useParams, useServerInsertedHTML } from "next/navigation"; import { useLayoutEffect } from "react"; /** - * Keeps the docs section class (`dev`, `guides`, `plugins`, `ui`) on `` in - * sync with the route. The class sets `--color-fd-primary`, so it has to live on - * `` - an element that cannot be wrapped in ``. + * Paints the docs section class (`dev`, `guides`, `plugins`, `ui`) on `` + * before first paint. * - * First paint is handled by the inline script in the root layout, which derives - * the same class from `location.pathname` before anything renders. This takes - * over from there, so client navigations between sections repaint correctly. + * The class sets `--color-fd-primary`, so it has to sit on an ancestor of the + * whole shell. `` cannot be wrapped in ``, so the class cannot + * come from a `useParams()` read during prerendering without making every route + * block - deriving it from `location.pathname` here keeps the shell static. + * + * `useServerInsertedHTML` keeps the tag out of React's render tree: it is + * emitted into the streamed `` on the server and is a no-op on the + * client, so React never renders a `