diff --git a/apps/web/content/docs/content-engine/live-editing-api.mdx b/apps/web/content/docs/content-engine/live-editing-api.mdx index 0b1998a04..75a1cd921 100644 --- a/apps/web/content/docs/content-engine/live-editing-api.mdx +++ b/apps/web/content/docs/content-engine/live-editing-api.mdx @@ -15,7 +15,7 @@ Live editing runs on two channels. Field locks and the shared draft are plain HT | Long-lived Node API server with the WebSocket | Live, pushed over the socket | Yes | Co-edited | | No WebSocket, such as [Vercel](/docs/dev/deployments/cloud/vercel) | Polled over HTTP every 10 s | No | One editor at a time, locked like a plain field | -A form without a socket polls `GET /{id}/locks` and `GET /{id}/draft` every 10 seconds, on top of lock renewals. Every request counts against the [rate limiter](/docs/dev/advanced/rate-limiter), which is keyed by IP address. A newsroom behind one office IP can reach the default of 80 requests a minute. A refused lock request leaves the field without autosave, so raise `rateLimiter.points` if that is you. +A form without a socket polls `GET /{id}/locks` and `GET /{id}/draft` every 10 seconds, on top of lock renewals. Every request counts against the [rate limiter](/docs/dev/advanced/rate-limiter), which is keyed by IP address: the polls against the read budget (300 a minute), lock renewals and draft saves against the write budget (60 a minute). A newsroom behind one office IP can reach the write budget. A refused lock request leaves the field without autosave, so raise `rateLimiter.write.points` if that is you. With more than one API instance, set `REDIS_URL`. Instances share presence and document updates over Redis pub/sub. Without it, editors connected to different instances do not see each other. diff --git a/apps/web/content/docs/content-engine/public-write-api.mdx b/apps/web/content/docs/content-engine/public-write-api.mdx index e4e12f906..a8feee0e8 100644 --- a/apps/web/content/docs/content-engine/public-write-api.mdx +++ b/apps/web/content/docs/content-engine/public-write-api.mdx @@ -263,7 +263,7 @@ The same member editing somebody else's draft gets `404` with `"code": "CONTENT_ ### Rate limit -`POST` and `PATCH` requests to a path containing `/content/`, outside `/admin/`, share a budget of 20 requests per 60 seconds per IP, on top of the global limit. Change it with `rateLimiter.contentWrite` in `apps/api/src/vitnode.api.config.ts`. It is off when `NODE_ENV=development`. See [Rate limiter](/docs/dev/advanced/rate-limiter) for the `429` body and Redis-backed counters. +`POST` and `PATCH` requests to a path containing `/content/`, outside `/admin/`, share a budget of 20 requests per 60 seconds per IP, on top of the write budget every mutation spends. Change it with `rateLimiter.contentWrite` in `apps/api/src/vitnode.api.config.ts`. It is off when `NODE_ENV=development`. See [Rate limiter](/docs/dev/advanced/rate-limiter) for the `429` body and Redis-backed counters. ### Codes @@ -410,7 +410,7 @@ export const askRoute = buildRoute({ }); ``` -A route outside `/content/` only gets the global rate limit, which is why this one asks for a captcha. +A route outside `/content/` only gets the general write budget, which is why this one asks for a captcha. ### Helpers diff --git a/apps/web/content/docs/content-engine/public-write.mdx b/apps/web/content/docs/content-engine/public-write.mdx index 14241e4d6..ea90b3fe5 100644 --- a/apps/web/content/docs/content-engine/public-write.mdx +++ b/apps/web/content/docs/content-engine/public-write.mdx @@ -278,7 +278,7 @@ Each layer below holds even if the one before it has a bug: - **Authorization under a row lock.** An edit reads the record with `SELECT ... FOR UPDATE`, checks the owner or moderator permission, and writes in the same transaction, so the owner that was checked is the owner that is written. - **Relation targets.** A relation field is writable only when its target content type has a `publicApi`. On every write, each referenced record must be published and visible, otherwise the request is a `400` with the `unavailable` issue code. Missing, draft and hidden targets get the same answer. - **No existence leaks.** `403` is only for records the public can already read. A draft or a hidden record answers `404`, like a record that does not exist. -- **Rate limit and captcha.** `POST` and `PATCH` requests under `/content/` share a budget of 20 per minute per IP, on top of the global limit. Set `create.captcha: true` to require a [captcha](/docs/dev/captcha) token on every create. +- **Rate limit and captcha.** `POST` and `PATCH` requests under `/content/` share a budget of 20 per minute per IP, on top of the write budget every mutation spends. Set `create.captcha: true` to require a [captcha](/docs/dev/captcha) token on every create. - **Session and CSRF.** The routes use the same session cookie, CORS and CSRF origin checks as every other VitNode API route, and they accept JSON bodies only. Tune the write budget with `rateLimiter.contentWrite`. Like every rate limit, it is off when `NODE_ENV=development`: diff --git a/apps/web/content/docs/dev/advanced/meta.json b/apps/web/content/docs/dev/advanced/meta.json index 77eef557a..e0cb3d7c6 100644 --- a/apps/web/content/docs/dev/advanced/meta.json +++ b/apps/web/content/docs/dev/advanced/meta.json @@ -2,5 +2,11 @@ "title": "Advanced", "description": "Sessions and cookies, request rate limiting, the background task queue, and the optional Redis cache", "icon": "Wrench", - "pages": ["auth", "rate-limiter", "queue", "redis"] + "pages": [ + "auth", + "rate-limiter", + "rate-limiter-debugging", + "queue", + "redis" + ] } diff --git a/apps/web/content/docs/dev/advanced/rate-limiter-debugging.mdx b/apps/web/content/docs/dev/advanced/rate-limiter-debugging.mdx new file mode 100644 index 000000000..6940c2a27 --- /dev/null +++ b/apps/web/content/docs/dev/advanced/rate-limiter-debugging.mdx @@ -0,0 +1,60 @@ +--- +title: Debug unexpected 429 errors +description: Find which VitNode rate-limit budget a visitor ran out of, using the X-RateLimit-Category header and the API's rate-limit log line, then fix the cause. +icon: SearchCheck +--- + +When a real visitor sees "Too many requests", find out which [rate-limit budget](/docs/dev/advanced/rate-limiter) refused them before you raise anything. Raising the wrong budget hides the cause and weakens protection you still need. + + + + +### Read the refused budget + +Open the browser's Network tab, repeat what the visitor did, and select the first request with status `429`. The `X-RateLimit-Category` response header names the budget: + +| Value | What ran out | Usual cause | +| --------------- | ------------------------------------------------ | ------------------------------------------------------------- | +| `read` | `GET` / `HEAD` budget | A loader that fetches on every preload, or a polling screen | +| `write` | Mutation budget | A form that submits in a loop, or autosave on every keystroke | +| `auth` | Sign-in budget | Repeated failed sign-ins, often a password manager retrying | +| `content-write` | Public Content Engine create and edit budget | A member, or a script, posting very quickly | +| `safety` | The cap on every request | A client sending requests in parallel, or many people on one IP | + + + + +### Find the route in the API log + +The API prints one line the first time a client goes over a budget in each window: + +```txt +[VitNode] Rate limit exceeded: {"category":"read","subject":"user","method":"GET","path":"/api/@vitnode/forum/content/topics/:param","status":429,"retryAfter":24,"store":"redis"} +``` + +`subject` says whether a signed-in member (`user`) or an IP address (`ip`) ran out, `path` is the route template with IDs and slugs replaced, and `store` says whether the counters live in `redis` or in each instance's `memory`. The line never includes the member's ID, the IP address, cookies, headers or the query string, and later refusals in the same window are not logged, so a flood stays one line per client. + + + + +### Fix the cause + +- **`read` from navigation**: check that the page's loader reads through the [query cache](/docs/dev/cache/app) with a `staleTime`, so hovering a link twice or hovering and then clicking costs one request. For a page that should never load early, set [`preload: false`](/docs/dev/routing/preloading#turn-off-preloading-for-a-route). +- **`write` or `content-write`**: debounce autosave, and disable the submit button while a request is pending. +- **Many people behind one IP**: an office or school shares one public address. Signed-in members already have their own read and write budgets, but guests share them, and everyone shares the safety cap. If `subject` is `ip` and the category is `safety`, raise `rateLimiter.safety.points`; if guests run out of reads, raise `rateLimiter.read.points`. +- **Every server-rendered request in one bucket**: the API sees your web server instead of the visitor. Set up [real visitor IPs](/docs/dev/advanced/rate-limiter#real-visitor-ips-for-server-rendered-pages). +- **Limits differ between instances**: with `store` set to `memory`, each API instance counts on its own. Set `REDIS_URL` to share counters. + + + + +## Check the result + +Repeat the visitor's steps with the Network tab open. You see no `429`, and the API prints no new `Rate limit exceeded` line. + + + A request with a session cookie spends its read and write budgets only after + the API has checked the session, so the budget belongs to a verified member. + Guests are counted before that check, and the per-IP safety cap limits how + many session checks one address can cause. + diff --git a/apps/web/content/docs/dev/advanced/rate-limiter.mdx b/apps/web/content/docs/dev/advanced/rate-limiter.mdx index 9640c9f5d..2ab10d2fb 100644 --- a/apps/web/content/docs/dev/advanced/rate-limiter.mdx +++ b/apps/web/content/docs/dev/advanced/rate-limiter.mdx @@ -1,87 +1,105 @@ --- title: Rate Limiter -description: Restrict API request rates per client IP, return 429 responses with Retry-After headers, and share counters across clusters with Redis. +description: How VitNode splits API rate limits into read, write, sign-in and content-write budgets under a global safety cap, with Redis-shared counters and 429 responses that carry Retry-After. icon: ShieldAlert --- import { TypeTable } from "fumadocs-ui/components/type-table" -VitNode includes automated IP-based rate limiting middleware. Exceeding the request budget immediately returns `429 Too Many Requests` with a `Retry-After` header, protecting authentication and public endpoints from brute-force attacks. +VitNode limits API requests per client IP, and per member once they sign in. Reads, writes, sign-in attempts and public content writes each get their own budget, and one global safety cap per IP sits above them all. A request over any budget gets `429 Too Many Requests` with a `Retry-After` header. -## Quick start +## How the budgets work -Customize the rate limiter budget in `apps/api/src/vitnode.api.config.ts`: +Every request spends points from the budgets that apply to it, in this order, and stops at the first one that is used up: -```ts title="apps/api/src/vitnode.api.config.ts" -import { buildApiConfig } from "@vitnode/core/vitnode.config" - -export const vitNodeApiConfig = buildApiConfig({ - // [!code ++:4] - rateLimiter: { - points: 60, // Max requests allowed - duration: 60, // Window in seconds - }, -}) -``` +| Budget | Spent by | Default | Config key | +| -------------- | ------------------------------------------------------------------------ | -------------- | -------------------------- | +| Safety cap | Every request, including `OPTIONS` preflights | 1000 / 60 s | `rateLimiter.safety` | +| Read | `GET` and `HEAD` | 300 / 60 s | `rateLimiter.read` | +| Write | `POST`, `PUT`, `PATCH`, `DELETE` and any other method | 60 / 60 s | `rateLimiter.write` | +| Sign-in | `POST` to the sign-in, sign-up, password and passkey routes, plus write | 10 / 60 s | `rateLimiter.auth` | +| Content writes | `POST` and `PATCH` under a plugin's `/content/`, outside `/admin/`, plus write | 20 / 60 s | `rateLimiter.contentWrite` | -The default configuration allows **80 requests per 60 seconds** per IP address. +So a sign-in spends the safety cap, the write budget and the sign-in budget. A `GET` spends only the safety cap and the read budget. Each budget is spent at most once per request. -IPv6 visitors are counted per `/64` network, not per address - an ISP usually hands one person a whole `/64`, and we'd rather not give them 18 quintillion fresh budgets. - -## Stricter budget for sign-in - -Routes that check a password or send an email to whoever is named in the request get a second, much smaller budget on top of the global one: **10 requests per 60 seconds** per IP address. That covers: +The sign-in budget covers: - `POST /users/sign_in` and `POST /users/sign_up` - `POST /users/reset-password` and `POST /users/change-password` - `POST /users/verify-email` and `POST /users/verify-email/resend` - `POST /users/passkeys/sign-in` and `POST /users/passkeys/admin-sign-in` -Tune it with `rateLimiter.auth`: +## Who a budget belongs to + +| Budget | Guest | Signed-in member | +| ----------------------------- | --------------- | ---------------- | +| Safety cap, sign-in | IP address | IP address | +| Read, write, content writes | IP address | The member | + +A member's read and write budgets follow them, not their network. Fifty colleagues behind one office IP each get their own 300 reads once signed in, and a member who switches from Wi-Fi to mobile data keeps the budget they had. The safety cap stays per IP, so many accounts on one address still share its 1000 requests. The sign-in budget stays per IP because a sign-in request has no member yet. + +VitNode only trusts a session the API has verified. A request with a forged or expired session cookie is counted against its IP address. Set `rateLimiter.perUser: false` to count every budget per IP address, as older versions did. + +IPv6 visitors are counted per `/64` network, not per address. An ISP usually hands one person a whole `/64`, and we'd rather not give them 18 quintillion fresh budgets. + + + Every budget is skipped when `NODE_ENV=development`, so local clicking never + hits a `429`. + + +## Why reads and writes are separate + +The router [preloads a page](/docs/dev/routing/preloading) when a visitor rests the pointer on a link. A preload runs the page's loader, and the loader calls the API. Those are real `GET` requests that the visitor never asked for. + +With one shared budget, a visitor who hovered across a long forum list could use up the minute before pressing **Post comment**. With separate budgets, preloads can only use up the read budget. The comment still has its own 60 writes, and the sign-in and content-write budgets still apply to it. + +The safety cap is the wall behind both. It is set well above read plus write (300 + 60), so normal use never reaches it. It stops a client that would otherwise spend every budget in parallel, and it also counts `OPTIONS` preflights, which no other budget sees. A preflight is counted but never refused: once the cap is used up, the request that follows the preflight gets the `429`, so the browser can read it instead of reporting a CORS error. + +The defaults leave room for a busy reader: a server-rendered page with a few queries costs 3 to 6 reads, and the [query cache](/docs/dev/cache/app) answers repeat visits without a request. Writes stay low because a person rarely submits more than one form a second, and a script that does is what the budget is for. + +## Change a budget + +Set only the budgets you want to change in `apps/api/src/vitnode.api.config.ts`. Each one falls back to its own default for any option you leave out: ```ts title="apps/api/src/vitnode.api.config.ts" +import { buildApiConfig } from "@vitnode/core/vitnode.config" + export const vitNodeApiConfig = buildApiConfig({ + // [!code ++:5] rateLimiter: { - // [!code ++:4] - auth: { - points: 5, - duration: 60, - }, + read: { points: 600 }, // a busy reading site + write: { points: 30, duration: 60 }, + auth: { points: 5, duration: 60, blockDuration: 300 }, }, }) ``` -## Budget for public content writes +`blockDuration` keeps a client blocked for that many seconds after it goes over, instead of until the window ends. -Public [Content Engine writes](/docs/content-engine/public-write) - `POST` and `PATCH` requests under a plugin's `/content/` routes, outside `/admin/` - get their own budget on top of the global one: **20 requests per 60 seconds** per IP address. It keeps one member from flooding a forum with new discussions. +## Migrate from the single global limit -Tune it with `rateLimiter.contentWrite`: +Older configs set `points` and `duration` at the top of `rateLimiter`. Those keys configured a cap on every request, which is what `safety` is now, so VitNode keeps them working as the safety cap with the old defaults (80 requests per 60 seconds). The read and write budgets apply underneath. ```ts title="apps/api/src/vitnode.api.config.ts" -export const vitNodeApiConfig = buildApiConfig({ - rateLimiter: { - // [!code ++:4] - contentWrite: { - points: 10, - duration: 60, - }, - }, -}) +rateLimiter: { + points: 120, // [!code --] + duration: 60, // [!code --] + safety: { points: 120, duration: 60 }, // [!code ++] +}, ``` - - Rate limiting is automatically bypassed when `NODE_ENV=development` to prevent interruptions during local coding. - +Nothing gets looser on its own: an old config keeps exactly the cap it had and logs a deprecation warning at boot. To get the new defaults, delete the top-level keys. With them gone, the safety cap is 1000 and reads get 300 of their own. ---- +Setting both the old top-level keys and `safety` stops the API at boot with an error, because both name the same limiter. `auth` and `contentWrite` work as before, and their Redis counters carry over a deploy. -## 429 Error Response Format +## 429 response format -When a client exhausts their request allowance, the API returns a structured JSON error: +When a client goes over any budget, the API returns: ```http HTTP/1.1 429 Too Many Requests Retry-After: 24 +X-RateLimit-Category: read Content-Type: application/json { @@ -90,16 +108,18 @@ Content-Type: application/json } ``` ---- +`X-RateLimit-Category` names the budget that refused the request: `safety`, `read`, `write`, `auth` or `content-write`. Both headers are listed in the CORS `exposeHeaders`, so a web app on another origin can read them; VitNode adds them to your own `cors` options if you pass any. In the browser, VitNode shows one toast that uses `Retry-After` for its countdown, even when several requests fail at once. + +[Debug unexpected 429 errors](/docs/dev/advanced/rate-limiter-debugging) explains how to find which budget a real visitor ran out of. -## Distributed Rate Limiting (Redis) +## Distributed rate limiting (Redis) -- **Without Redis**: Counters are tracked in local memory per server instance. -- **With Redis**: Counters are synchronized across all API containers using a distributed sliding window. +- **Without Redis**: each API instance counts in its own memory. +- **With Redis**: all API instances share the counters, so a visitor gets the same budget whichever instance answers. Each budget has its own key prefix, so the counters never mix. -Configure Redis in `vitnode.api.config.ts`: +If Redis stops answering, each instance falls back to counting in memory until it returns, so requests keep flowing and limits keep holding. -```ts +```ts title="apps/api/src/vitnode.api.config.ts" export const vitNodeApiConfig = buildApiConfig({ redis: process.env.REDIS_URL ? { url: process.env.REDIS_URL, password: process.env.REDIS_PASSWORD } @@ -107,11 +127,9 @@ export const vitNodeApiConfig = buildApiConfig({ }) ``` ---- - ## Real visitor IPs for server-rendered pages -When your web app renders a page on the server, it calls the API itself - so the API sees the **web server's** address, not your visitor's. Every server-rendered request then lands in the same bucket, and 80 requests a minute disappears fast on a busy site. +When your web app renders a page on the server, it calls the API itself - so the API sees the **web server's** address, not your visitor's. Every server-rendered request then lands in the same bucket, and one busy page fills it fast. The API never believes a plain `X-Forwarded-For` header (anyone can type one). Instead, the web server works out the visitor's address, signs it with a key derived from your `CRON_SECRET` - the secret the web app and the API already share - and the API checks that signature before trusting it. @@ -134,46 +152,73 @@ The API never believes a plain `X-Forwarded-For` header (anyone can type one). I Anyone holding `CRON_SECRET` can trigger cron jobs and pick any IP address the rate limiter will believe. Use a long random value and rotate it in both apps together. ---- +No request header can skip or refill a budget. A header such as `X-Prefetch: true` is ignored, because any client can send it. -## `rateLimiter` Options +## `rateLimiter` options + +Every budget takes the same options: `points` (requests per window), `duration` (window length in seconds), `blockDuration` (seconds to stay blocked after going over, `0` by default) and `execEvenly` / `execEvenlyMinDelayMs` (delay allowed requests so they spread across the window). -## Learn More +## Learn more + + - diff --git a/apps/web/content/docs/dev/cache/app.mdx b/apps/web/content/docs/dev/cache/app.mdx index 6f1b42709..2add96c38 100644 --- a/apps/web/content/docs/dev/cache/app.mdx +++ b/apps/web/content/docs/dev/cache/app.mdx @@ -79,6 +79,8 @@ export default NotesPage `staleTime: 'static'` makes the loader reuse an entry that is already cached instead of fetching again on every navigation. The page reads the entry the loader just filled, so it renders without suspending. +The router also runs this loader when a visitor rests on a link to the page, and again on the click. Because the loader reads through the cache, both runs share one request. See [Preloading](/docs/dev/routing/preloading). + diff --git a/apps/web/content/docs/dev/routing/index.mdx b/apps/web/content/docs/dev/routing/index.mdx index 4aa5d95dc..1fe7f32b0 100644 --- a/apps/web/content/docs/dev/routing/index.mdx +++ b/apps/web/content/docs/dev/routing/index.mdx @@ -474,6 +474,19 @@ page('/blog/:slug', { Routes are uncached by default, and a route with `requires` cannot be cached. [Cache server-rendered pages](/docs/dev/cache/pages) covers the setup and how the host app overrides a policy. +## Control preloading + +Hovering a link runs the destination's loader early. Set `preload: false` on a page or layout whose loader is too costly to run on a hover: + +```ts title="plugins/site-notes/src/routes.ts" +page('/notes/:slug/history', { + component: lazy(() => import('./pages/note-history-page')), + preload: false, // [!code ++] +}) +``` + +Children of a `layout()` inherit its setting. [Preloading](/docs/dev/routing/preloading) explains what a preload runs and how to keep it to one request. + ## Nested routes and layouts A `layout()` wraps child routes in a shared UI frame without claiming a URL segment of its own. Use `index()` to render a page at the layout's root path. diff --git a/apps/web/content/docs/dev/routing/meta.json b/apps/web/content/docs/dev/routing/meta.json index b61a08cf1..f17f02937 100644 --- a/apps/web/content/docs/dev/routing/meta.json +++ b/apps/web/content/docs/dev/routing/meta.json @@ -2,5 +2,12 @@ "title": "Routing", "description": "Claim a URL, name the page, and let the router handle locales", "icon": "Route", - "pages": ["index", "navigation", "breadcrumbs", "loading", "not-found"] + "pages": [ + "index", + "navigation", + "preloading", + "breadcrumbs", + "loading", + "not-found" + ] } diff --git a/apps/web/content/docs/dev/routing/navigation.mdx b/apps/web/content/docs/dev/routing/navigation.mdx index e505a8658..1b4325e07 100644 --- a/apps/web/content/docs/dev/routing/navigation.mdx +++ b/apps/web/content/docs/dev/routing/navigation.mdx @@ -5,8 +5,10 @@ icon: Signpost --- Inside the app, use TanStack Router's `Link`. It builds the href, applies the -locale prefix, and preloads the destination on hover. You write the logical -path; the router writes the language. +locale prefix, and preloads the destination once the pointer rests on it for +250 ms. You write the logical path; the router writes the language. +[Preloading](/docs/dev/routing/preloading) covers what a preload costs and how +to turn it off. ## Example @@ -100,16 +102,47 @@ const CatalogPage = ({ export default CatalogPage ``` -## Plugin route modules +## Link from a plugin page -A plugin route should not import the host router. Use its `navigate` prop for -same-page filters, sort order, and pagination. For links to another internal -screen, let the host render a link component; a plain `` is for another -origin only. +A plugin page cannot use the router's typed `to`, because the host's route tree +does not know plugin URLs at compile time. Use `HrefLink` from +`@vitnode/core/tanstack/link` instead. It takes the same href string you would +put on an ``, query and hash included, and renders a router link: + +```tsx title="plugins/catalog/src/pages/catalog-list.tsx" +import { HrefLink } from '@vitnode/core/tanstack/link' + +export const CatalogItemLink = ({ slug }: { slug: string }) => ( + + Reviews + +) +``` + +The link navigates without a full page load, [preloads](/docs/dev/routing/preloading) +after a 250 ms hover, and gets the visitor's locale prefix from the router. +Write the href without a locale prefix. An absolute URL such as +`https://example.com` renders a plain ``. `aria-current="page"` is set only +when the href matches the current URL exactly, query and hash included. + +To navigate from code after a form is saved, use `useHrefNavigate`: + +```tsx title="plugins/catalog/src/pages/catalog-new-page.tsx" +import { useHrefNavigate } from '@vitnode/core/tanstack/link' + +const navigate = useHrefNavigate() + +// after a successful save: +await navigate(`/catalog/${encodeURIComponent(item.slug)}`) +``` + +Invalidate the queries the save changed before you navigate, so the next page's +loader reads fresh data. For filters, sorting and pagination on the page already +being viewed, use the page's own `navigate` prop shown above. - `navigate` only changes this plugin page's query string. That small boundary - is intentional—and saves future hosts from router spaghetti. + Plugin pages never import the host's route tree. `HrefLink` and `navigate` + work with any host, which saves future hosts from router spaghetti. ## Next diff --git a/apps/web/content/docs/dev/routing/preloading.mdx b/apps/web/content/docs/dev/routing/preloading.mdx new file mode 100644 index 000000000..794f9fe5f --- /dev/null +++ b/apps/web/content/docs/dev/routing/preloading.mdx @@ -0,0 +1,139 @@ +--- +title: Preloading +description: How VitNode preloads a page when a visitor rests on a link, why that sends API requests, and how to keep it cheap with TanStack Query or turn it off for a route with preload false. +icon: MousePointerClick +--- + +VitNode starts loading a page when a visitor rests the pointer on a link to it, so the page is often ready by the time they click. This page explains what a preload costs, how to keep it to one API request, and how to turn it off for a route. + +## What a preload does + +A preload runs the destination route the same way a navigation does, without showing it: + +1. Downloads the route's JavaScript chunk. +2. Runs its guard, such as `requires: 'authenticated'`. +3. Runs its loader, including your module's `load` function. + +Step 3 is what reaches your API. A forum list with 50 links can send many `GET` requests if a visitor sweeps across it, and each one counts against the [read budget](/docs/dev/advanced/rate-limiter#how-the-budgets-work) of the rate limiter. + +## When a link preloads + +The app's router sets the defaults in `src/router.tsx`: + +```tsx title="apps/web/src/router.tsx" +const router = createTanStackRouter({ + defaultPreload: 'intent', + defaultPreloadDelay: 250, + defaultPreloadStaleTime: 0, + // ... +}) +``` + +| Visitor action | Result | +| ----------------------------------- | ----------------------------------------- | +| Hover or focus a link for 250 ms | The destination preloads once | +| Pass over a link in less than 250 ms | Nothing is loaded | +| Touch a link on a phone | The destination preloads at once | +| Click before 250 ms | The page loads normally, with no preload | + +`defaultPreloadStaleTime: 0` hands freshness to TanStack Query instead of the router. The router reruns the loader for every preload and again on the click, and the loader answers from the query cache. That is why the next section matters. + +## Make repeat preloads free + +Read your API through `context.queryClient.query` in `load`, with a `staleTime`. The first preload fills the cache. Hovering again, or clicking after the hover, reads the same entry without a request, and two identical requests in flight at once share one fetch. + +```tsx title="plugins/site-notes/src/pages/note-page.tsx" +import { queryOptions } from '@tanstack/react-query' +import { LOADER_DATA_QUERY_META } from '@vitnode/core/lib/query-client' +import { defineRoute } from '@vitnode/core/tanstack/plugin-routes' + +import { fetchNote } from '../features/notes/fetch-note' + +const noteQuery = (slug: string) => + queryOptions({ + queryKey: ['@acme/site-notes', 'note', { slug }], + queryFn: async () => await fetchNote(slug), + staleTime: 60_000, // [!code ++] + meta: LOADER_DATA_QUERY_META, // [!code ++] + }) + +export const route = defineRoute({ + load: async ({ context, params }) => + await context.queryClient.query(noteQuery(params.slug)), // [!code ++] +}) +``` + +`LOADER_DATA_QUERY_META` is for a query whose data `load` returns, as here. The router already sends that data to the browser with the server-rendered HTML, so the meta keeps the query cache from sending it a second time. Leave it out when the page reads the query with `useSuspenseQuery` instead, as in [Cache app data](/docs/dev/cache/app). + +A `load` that calls `fetcher` directly sends a new request on every hover and again on the click. Pick a `staleTime` per query: a minute is fine for a public article, a few seconds for a busy list, and data that changes after a write needs its key invalidated by that write. [Cache app data](/docs/dev/cache/app) covers keys, invalidation and per-user data. + +## Turn off preloading for a route + +Set `preload: false` on a `page()`, `index()` or `layout()` when its loader is expensive or the visitor rarely opens it: + +```ts title="plugins/site-notes/src/routes.ts" +import { definePluginRoutes, lazy, page } from '@vitnode/core/routing' + +export const routes = definePluginRoutes([ + page('/notes/:slug/history', { + component: lazy(() => import('./pages/note-history-page')), + preload: false, // [!code ++] + }), +]) +``` + +A hover then skips the route's loader: its `load` and the loading of its `messages`. The click still runs everything. + +| Value | Effect | +| --------- | ----------------------------------------------------------------------------- | +| left out | Uses the nearest parent `layout()` that sets it, otherwise the app's defaults | +| `false` | A preload skips this route's loader | +| `true` | Turns preloading back on under a `layout()` that set `false` | + + + `preload: false` skips only the loader. A preload still downloads the route's + chunk and still runs its `requires` guard, which reads the session from the + query cache. A real navigation always runs the guard and the loader, so + protected pages stay protected. + + +Any value other than `true` or `false` stops the build with an `invalid-preload` route error. + +## Turn off preloading for one link + +To change a single link instead of a route, use the `Link` props from TanStack Router: + +```tsx +import { Link } from '@tanstack/react-router' + +const ExportLink = () => ( + + Export data + +) +``` + +`preload={false}` stops this link from preloading. `preloadDelay={500}` waits longer for this link only. In a plugin page, [`HrefLink`](/docs/dev/routing/navigation#link-from-a-plugin-page) takes the same two props. A link cannot make a `preload: false` route run its loader early. + +## When to turn it off + +- The loader is slow or expensive, such as a report, a search or an export. +- The page is rarely opened from the link, such as an edit or history page. +- The loader writes something or counts a view. A loader should only read, but a preload would count a visit nobody made. + +Keep it on for ordinary reading pages. One cached preload makes the click feel instant and costs nothing on the second hover. + +## Next + + + + + diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index d7dd181c1..2070ff8e8 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -58,6 +58,7 @@ export function getRouter() { defaultPendingMs: 150, defaultPendingMinMs: 300, defaultPreload: 'intent', + defaultPreloadDelay: 250, defaultPreloadStaleTime: 0, defaultViewTransition: navigationViewTransition( vitNodeConfig.viewTransitions, diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/router.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/router.tsx index 2a879f03c..3e97daeb8 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/router.tsx +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/router.tsx @@ -54,6 +54,7 @@ export function getRouter() { defaultPendingMs: 150, defaultPendingMinMs: 300, defaultPreload: "intent", + defaultPreloadDelay: 250, defaultPreloadStaleTime: 0, defaultViewTransition: navigationViewTransition( vitNodeConfig.viewTransitions, diff --git a/packages/vitnode/src/api/config.ts b/packages/vitnode/src/api/config.ts index ff91a1ba8..cebd41abe 100644 --- a/packages/vitnode/src/api/config.ts +++ b/packages/vitnode/src/api/config.ts @@ -10,6 +10,7 @@ import { secureHeaders } from "hono/secure-headers"; import type { VitNodeApiConfig } from "@/vitnode.config"; +import { DEFAULT_SESSION_COOKIE_NAME } from "@/api/lib/auth-cookie"; import { createCacheClient } from "@/api/lib/cache-client"; import { clientIpMiddleware } from "@/api/lib/client-ip"; import { collectCronJobs } from "@/api/lib/cron"; @@ -38,8 +39,7 @@ import { globalMiddleware, } from "./middlewares/global.middleware"; import { - authRateLimiterMiddleware, - contentWriteRateLimiterMiddleware, + exposeRateLimitHeaders, rateLimiterMiddleware, } from "./middlewares/rate-limiter.middleware"; import { registerCronJobs } from "./modules/cron/helpers/register-cron-jobs"; @@ -135,6 +135,12 @@ export function VitNodeAPI({ } const webOrigin = CONFIG.web.origin; + const rateLimiter = rateLimiterMiddleware(vitNodeApiConfig.rateLimiter, { + sessionCookieName: + vitNodeApiConfig.authorization?.cookieName ?? DEFAULT_SESSION_COOKIE_NAME, + storeClient: redisClient, + }); + const corsConfig = corsOptions ?? { credentials: true, origin: webOrigin }; app.use( secureHeaders({ @@ -143,7 +149,14 @@ export function VitNodeAPI({ strictTransportSecurity: false, }), ); - app.use(cors(corsOptions ?? { credentials: true, origin: webOrigin })); + app.use("*", clientIpMiddleware); + app.use("*", rateLimiter.preflight); + app.use( + cors({ + ...corsConfig, + exposeHeaders: exposeRateLimitHeaders(corsConfig.exposeHeaders), + }), + ); app.use( csrf( csrfOptions ?? { @@ -152,7 +165,6 @@ export function VitNodeAPI({ }, ), ); - app.use("*", clientIpMiddleware); // Nothing bounded a request body before this. `POST /sign_in` reads its JSON // and then runs scrypt unconditionally, so a body the server is willing to // buffer is memory *and* CPU an unauthenticated caller gets to choose the size @@ -166,17 +178,7 @@ export function VitNodeAPI({ onError: c => c.json({ error: "Payload Too Large" }, 413), }), ); - const { - auth: authRateLimiter, - contentWrite: contentWriteRateLimiter, - ...rateLimiter - } = vitNodeApiConfig.rateLimiter ?? {}; - app.use("*", rateLimiterMiddleware(rateLimiter, redisClient)); - app.use("*", authRateLimiterMiddleware(authRateLimiter, redisClient)); - app.use( - "*", - contentWriteRateLimiterMiddleware(contentWriteRateLimiter, redisClient), - ); + app.use("*", rateLimiter.requests); if (docsEnabled) { app.get("/swagger", swaggerUI({ url: "/api/swagger/doc" })); } @@ -201,6 +203,7 @@ export function VitNodeAPI({ cacheClient: redisClient, }), ); + app.use("*", rateLimiter.identified); app.use(async (c, next) => { if (c.req.path.includes("/admin/")) { return await globalAdminMiddleware()(c, next); diff --git a/packages/vitnode/src/api/lib/auth-cookie.ts b/packages/vitnode/src/api/lib/auth-cookie.ts index 7284ac429..48c801947 100644 --- a/packages/vitnode/src/api/lib/auth-cookie.ts +++ b/packages/vitnode/src/api/lib/auth-cookie.ts @@ -3,6 +3,8 @@ import type { CookieOptions } from "hono/utils/cookie"; import { deleteCookie, setCookie } from "hono/cookie"; +export const DEFAULT_SESSION_COOKIE_NAME = "vitnode_auth"; + const authCookieOptions = (c: Context): CookieOptions => { const { cookieDomain, cookieSecure } = c.get("core").authorization; diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index 22a2f10b6..95a3c372d 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -14,6 +14,7 @@ import type { VitNodeRealtime } from "@/ws/registry"; import { LocalEventsAdapter } from "@/api/adapters/events/local"; import { PostgresSearchAdapter } from "@/api/adapters/search/postgres"; +import { DEFAULT_SESSION_COOKIE_NAME } from "@/api/lib/auth-cookie"; import { CacheModel } from "@/api/lib/cache"; import { AIModel } from "@/api/models/ai"; import { EmailModel } from "@/api/models/email"; @@ -498,7 +499,7 @@ export const globalMiddleware = ({ searchIndexers: searchIndexersMetadata, storage, authorization: { - cookieName: authorization?.cookieName ?? "vitnode_auth", + cookieName: authorization?.cookieName ?? DEFAULT_SESSION_COOKIE_NAME, cookie_expires: authorization?.cookieExpires ?? 1000 * 60 * 60 * 24 * 90, // 90 days ssoAdapters: authorization?.ssoAdapters ?? [], diff --git a/packages/vitnode/src/api/middlewares/rate-limiter-config.ts b/packages/vitnode/src/api/middlewares/rate-limiter-config.ts new file mode 100644 index 000000000..a2a132a68 --- /dev/null +++ b/packages/vitnode/src/api/middlewares/rate-limiter-config.ts @@ -0,0 +1,127 @@ +import type { IRateLimiterOptions } from "rate-limiter-flexible"; + +export type RateLimiterCategoryOptions = Partial< + Pick< + IRateLimiterOptions, + | "blockDuration" + | "duration" + | "execEvenly" + | "execEvenlyMinDelayMs" + | "points" + > +>; + +export interface RateLimiterConfig { + auth?: RateLimiterCategoryOptions; + /** @deprecated Moved to `rateLimiter.safety.blockDuration`. */ + blockDuration?: number; + contentWrite?: RateLimiterCategoryOptions; + /** @deprecated Moved to `rateLimiter.safety.duration`. */ + duration?: number; + /** @deprecated Moved to `rateLimiter.safety.execEvenly`. */ + execEvenly?: boolean; + /** @deprecated Moved to `rateLimiter.safety.execEvenlyMinDelayMs`. */ + execEvenlyMinDelayMs?: number; + perUser?: boolean; + /** @deprecated Moved to `rateLimiter.safety.points`. */ + points?: number; + read?: RateLimiterCategoryOptions; + safety?: RateLimiterCategoryOptions; + write?: RateLimiterCategoryOptions; +} + +export type RateLimiterCategory = + | "auth" + | "contentWrite" + | "read" + | "safety" + | "write"; + +export type ResolvedRateLimiterOptions = Pick< + IRateLimiterOptions, + "duration" | "points" +> & + RateLimiterCategoryOptions; + +export const RATE_LIMITER_DEFAULTS: Record< + RateLimiterCategory, + ResolvedRateLimiterOptions +> = { + safety: { points: 1000, duration: 60 }, + read: { points: 300, duration: 60 }, + write: { points: 60, duration: 60 }, + auth: { points: 10, duration: 60 }, + contentWrite: { points: 20, duration: 60 }, +}; + +export const RATE_LIMITER_KEY_PREFIXES: Record = { + safety: "vitnode-api-safety-rate-limiter", + read: "vitnode-api-read-rate-limiter", + write: "vitnode-api-write-rate-limiter", + auth: "vitnode-api-auth-rate-limiter", + contentWrite: "vitnode-api-content-write-rate-limiter", +}; + +const LEGACY_OPTION_KEYS = [ + "blockDuration", + "duration", + "execEvenly", + "execEvenlyMinDelayMs", + "points", +] as const satisfies (keyof RateLimiterCategoryOptions)[]; + +const LEGACY_DEFAULTS: ResolvedRateLimiterOptions = { + points: 80, + duration: 60, +}; + +export const usesLegacyRateLimiterOptions = ( + config: RateLimiterConfig | undefined, +): boolean => LEGACY_OPTION_KEYS.some(key => config?.[key] !== undefined); + +const resolveCategory = ( + options: RateLimiterCategoryOptions | undefined, + defaults: ResolvedRateLimiterOptions, +): ResolvedRateLimiterOptions => { + const resolved: ResolvedRateLimiterOptions = { + points: options?.points ?? defaults.points, + duration: options?.duration ?? defaults.duration, + }; + + if (options?.blockDuration !== undefined) { + resolved.blockDuration = options.blockDuration; + } + if (options?.execEvenly !== undefined) { + resolved.execEvenly = options.execEvenly; + } + if (options?.execEvenlyMinDelayMs !== undefined) { + resolved.execEvenlyMinDelayMs = options.execEvenlyMinDelayMs; + } + + return resolved; +}; + +export const resolveRateLimiterConfig = ( + config: RateLimiterConfig | undefined, +): Record => { + const legacy = usesLegacyRateLimiterOptions(config); + + if (legacy && config?.safety !== undefined) { + throw new Error( + `\`rateLimiter\` sets both the deprecated top-level options (${LEGACY_OPTION_KEYS.filter(key => config[key] !== undefined).join(", ")}) and \`rateLimiter.safety\`. They configure the same limiter - move the top-level options into \`rateLimiter.safety\`.`, + ); + } + + return { + safety: legacy + ? resolveCategory(config, LEGACY_DEFAULTS) + : resolveCategory(config?.safety, RATE_LIMITER_DEFAULTS.safety), + read: resolveCategory(config?.read, RATE_LIMITER_DEFAULTS.read), + write: resolveCategory(config?.write, RATE_LIMITER_DEFAULTS.write), + auth: resolveCategory(config?.auth, RATE_LIMITER_DEFAULTS.auth), + contentWrite: resolveCategory( + config?.contentWrite, + RATE_LIMITER_DEFAULTS.contentWrite, + ), + }; +}; diff --git a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.test.ts b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.test.ts index 382ca75dc..6d18f7e95 100644 --- a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.test.ts +++ b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.test.ts @@ -1,252 +1,851 @@ +import type { ContextVariableMap } from "hono"; + import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { RateLimiterMemory, RateLimiterRedis } from "rate-limiter-flexible"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CacheClient } from "@/api/lib/cache"; + +import { clientIpMiddleware } from "@/api/lib/client-ip"; + +import { + RATE_LIMITER_DEFAULTS, + RATE_LIMITER_KEY_PREFIXES, + type RateLimiterConfig, + resolveRateLimiterConfig, +} from "./rate-limiter-config"; import { - authRateLimiterMiddleware, - contentWriteRateLimiterMiddleware, + createRateLimiter, + exposeRateLimitHeaders, + RATE_LIMIT_RESPONSE_HEADERS, + rateLimiterCategoriesFor, rateLimiterMiddleware, rateLimitKey, + rateLimitPathTemplate, } from "./rate-limiter.middleware"; interface Env { Variables: { ipAddress: string }; } -const buildApp = (ipAddress: string) => { - const app = new Hono(); +const SESSION_COOKIE = "vitnode_auth"; +const WEB_ORIGIN = "https://web.example"; + +const METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]; + +const ROOMY = { points: 100, duration: 60 }; + +const member = (id: number): NonNullable => ({ + avatarColor: "000000", + avatarUrl: null, + birthday: null, + coverUrl: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + email: `${id}@example.com`, + emailVerified: true, + firstName: null, + headline: null, + id, + language: "en", + lastName: null, + name: `Member ${id}`, + nameCode: `member-${id}`, + newsletter: false, + phone: null, + roleId: 2, + showRealName: false, + timeZone: null, +}); + +const fakeRedis = ({ down = false } = {}) => { + const counters = new Map(); + const client = { + multi: () => ({}), + eval: async ( + _script: string, + { + arguments: [points], + keys: [key], + }: { arguments: string[]; keys: string[] }, + ): Promise<[number, number]> => { + if (down) return Promise.reject(new Error("ECONNREFUSED")); + const consumed = (counters.get(key) ?? 0) + Number(points); + counters.set(key, consumed); + + return Promise.resolve([consumed, 60_000]); + }, + }; + + return { counters, storeClient: client as unknown as CacheClient }; +}; + +const buildApp = ( + config: RateLimiterConfig, + { + ipAddress = "203.0.113.7", + storeClient, + }: { ipAddress?: string; storeClient?: CacheClient } = {}, +) => { + const app = new Hono().basePath("/api"); + const limiter = rateLimiterMiddleware(config, { + sessionCookieName: SESSION_COOKIE, + storeClient, + }); + const sessionLookups: string[] = []; + + app.use("*", async (c, next) => { + c.set("ipAddress", c.req.header("x-test-ip") ?? ipAddress); + + return next(); + }); + app.use("*", limiter.preflight); + app.use( + "*", + cors({ + credentials: true, + exposeHeaders: exposeRateLimitHeaders(), + origin: WEB_ORIGIN, + }), + ); + app.use("*", limiter.requests); app.use("*", async (c, next) => { - c.set("ipAddress", ipAddress); + const cookie = c.req.header("cookie") ?? ""; + const id = new RegExp(`^${SESSION_COOKIE}=member-(\\d+)$`).exec( + cookie, + )?.[1]; + sessionLookups.push(cookie); + c.set("user", id ? member(Number(id)) : null); return next(); }); - app.use("*", rateLimiterMiddleware({ points: 1, duration: 60 })); - app.get("/", c => c.json({ ok: true })); + app.use("*", limiter.identified); + app.all("*", c => c.json({ ok: true })); - return app; + return Object.assign(app, { sessionLookups }); }; +type Visit = [ + method: string, + path: string, + as?: { cookie?: string; ip?: string }, +]; + +type RequestableApp = Pick, "request">; + +const visit = async ( + app: RequestableApp, + [method, path, { cookie, ip } = {}]: Visit, +) => { + const headers = new Headers({ origin: WEB_ORIGIN }); + if (cookie) headers.set("cookie", `${SESSION_COOKIE}=${cookie}`); + if (ip) headers.set("x-test-ip", ip); + + return await app.request(path, { method, headers }); +}; + +const statusesOf = async (app: RequestableApp, requests: Visit[]) => { + const statuses: number[] = []; + for (const request of requests) { + statuses.push((await visit(app, request)).status); + } + + return statuses; +}; + +const times = (count: number, value: T): T[] => + Array.from({ length: count }, () => value); + +const silenceWarnings = () => + vi.spyOn(console, "warn").mockImplementation(() => undefined); + +beforeEach(() => { + vi.stubEnv("NODE_ENV", "production"); + silenceWarnings(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + describe("rateLimiterMiddleware", () => { - beforeEach(() => { - vi.stubEnv("NODE_ENV", "production"); - }); + it("spends one read point per GET, and only on reads", async () => { + const app = buildApp({ safety: ROOMY, read: { points: 2 }, write: ROOMY }); - afterEach(() => { - vi.unstubAllEnvs(); + await expect( + statusesOf(app, [ + ["GET", "/api/topics"], + ["HEAD", "/api/topics"], + ["GET", "/api/topics"], + ["POST", "/api/topics"], + ]), + ).resolves.toEqual([200, 200, 429, 200]); }); - it("allows requests under the limit", async () => { - const res = await buildApp("under-limit").request("/"); + it.each(["POST", "PUT", "PATCH", "DELETE", "PROPFIND"])( + "counts %s against the write budget", + async method => { + const app = buildApp({ + safety: ROOMY, + read: ROOMY, + write: { points: 1 }, + }); + + await expect( + statusesOf(app, [ + [method, "/api/topics/7"], + ["POST", "/api/topics"], + ["GET", "/api/topics"], + ]), + ).resolves.toEqual([200, 429, 200]); + }, + ); + + it("caps every method with the safety budget", async () => { + const app = buildApp({ safety: { points: 3 }, read: ROOMY, write: ROOMY }); + await statusesOf(app, times(3, ["GET", "/api/topics"])); - expect(res.status).toBe(200); + const res = await app.request("/api/topics", { method: "POST" }); + + expect(res.status).toBe(429); + expect(res.headers.get("X-RateLimit-Category")).toBe("safety"); }); - it("returns a JSON 429 with a Retry-After header when the limit is exceeded", async () => { - const app = buildApp("over-limit"); - await app.request("/"); - const res = await app.request("/"); + it("returns a JSON 429 with Retry-After and the refusing category", async () => { + const app = buildApp({ read: { points: 1 } }); + await app.request("/api/topics"); + const res = await app.request("/api/topics"); expect(res.status).toBe(429); expect(res.headers.get("content-type")).toContain("application/json"); + expect(res.headers.get("X-RateLimit-Category")).toBe("read"); - const retryAfter = res.headers.get("Retry-After"); - expect(retryAfter).toBeTruthy(); - expect(Number(retryAfter)).toBeGreaterThan(0); + const retryAfter = Number(res.headers.get("Retry-After")); + expect(retryAfter).toBeGreaterThan(0); // The regression this guards against: a plain-text body made callers throw // `Unexpected token 'T', "Too Many Requests" is not valid JSON`. - const body = (await res.json()) as { error: string; retryAfter: number }; - expect(body.error).toBe("Too Many Requests"); - expect(typeof body.retryAfter).toBe("number"); + await expect(res.json()).resolves.toEqual({ + error: "Too Many Requests", + retryAfter, + }); }); - it("disables rate limiting in development", async () => { - vi.stubEnv("NODE_ENV", "development"); - const app = buildApp("dev-ip"); - await app.request("/"); - const res = await app.request("/"); + it("counts a preflight against safety, but leaves the refusal to the request after it", async () => { + const app = buildApp({ safety: { points: 2 }, read: ROOMY, write: ROOMY }); + const preflight = { + method: "OPTIONS", + headers: { + origin: WEB_ORIGIN, + "access-control-request-method": "POST", + }, + }; + + const preflights = await Promise.all( + times(3, preflight).map(async init => app.request("/api/topics", init)), + ); + const request = await visit(app, ["POST", "/api/topics"]); - expect(res.status).toBe(200); + expect(preflights.map(res => res.status)).toEqual([204, 204, 204]); + expect(preflights[2].headers.get("Retry-After")).toBeNull(); + expect(request.status).toBe(429); + expect(request.headers.get("X-RateLimit-Category")).toBe("safety"); + expect(request.headers.get("Access-Control-Allow-Origin")).toBe(WEB_ORIGIN); }); -}); -describe("rateLimitKey", () => { - it.each([ - ["203.0.113.7", "203.0.113.7"], - ["::ffff:203.0.113.7", "203.0.113.7"], - ["2001:db8:1:2:3:4:5:6", "2001:db8:1:2::/64"], - ["2001:0db8:0001:0002:ffff:ffff:ffff:ffff", "2001:db8:1:2::/64"], - ["2001:db8::1", "2001:db8:0:0::/64"], - ["::1", "0:0:0:0::/64"], - ])("keys %s as %s", (ipAddress, key) => { - expect(rateLimitKey(ipAddress)).toBe(key); + it("keeps different visitors on separate budgets", async () => { + const app = buildApp({ read: { points: 1 } }); + const as = async (ip: string) => + app.request("/api/topics", { headers: { "x-test-ip": ip } }); + + await as("198.51.100.1"); + + expect((await as("198.51.100.1")).status).toBe(429); + expect((await as("198.51.100.2")).status).toBe(200); }); -}); -describe("rateLimiterMiddleware keying", () => { - beforeEach(() => { - vi.stubEnv("NODE_ENV", "production"); + it("shares one budget across an IPv6 /64", async () => { + const app = buildApp({ read: { points: 1 } }); + const as = async (ip: string) => + app.request("/api/topics", { headers: { "x-test-ip": ip } }); + + expect((await as("2001:db8:aa:bb::1")).status).toBe(200); + expect((await as("2001:db8:aa:bb::2")).status).toBe(429); + expect((await as("2001:db8:aa:cc::1")).status).toBe(200); }); - afterEach(() => { - vi.unstubAllEnvs(); + it("disables rate limiting in development", async () => { + vi.stubEnv("NODE_ENV", "development"); + const app = buildApp({ safety: { points: 1 } }); + + await expect( + statusesOf(app, times(3, ["GET", "/api/topics"])), + ).resolves.toEqual([200, 200, 200]); }); - it("shares one budget across an IPv6 /64", async () => { - const app = new Hono(); + it("logs the first refusal in a window with a route template and no visitor details", async () => { + const warn = silenceWarnings(); + const app = new Hono().basePath("/api"); + const limiter = rateLimiterMiddleware( + { write: { points: 1 } }, + { sessionCookieName: SESSION_COOKIE }, + ); app.use("*", async (c, next) => { - c.set("ipAddress", c.req.header("x-test-ip") ?? ""); + c.set("ipAddress", "198.51.100.77"); return next(); }); - app.use("*", rateLimiterMiddleware({ points: 1, duration: 60 })); - app.get("/", c => c.json({ ok: true })); + app.use("*", limiter.requests); + app.patch("/@acme/forum/topics/:id", c => c.json({ ok: true })); - const first = await app.request("/", { - headers: { "x-test-ip": "2001:db8:aa:bb::1" }, - }); - const rotated = await app.request("/", { - headers: { "x-test-ip": "2001:db8:aa:bb::2" }, - }); - const otherNetwork = await app.request("/", { - headers: { "x-test-ip": "2001:db8:aa:cc::1" }, - }); + await statusesOf( + app, + times(4, ["PATCH", "/api/@acme/forum/topics/8f14e45f?token=secret"]), + ); - expect(first.status).toBe(200); - expect(rotated.status).toBe(429); - expect(otherNetwork.status).toBe(200); + expect(warn).toHaveBeenCalledOnce(); + const [line] = warn.mock.calls[0] as [string]; + expect(line).toContain( + JSON.stringify({ + category: "write", + subject: "ip", + method: "PATCH", + path: "/api/@acme/forum/topics/:id", + status: 429, + retryAfter: 60, + store: "memory", + }), + ); + expect(line).not.toContain("198.51.100"); + expect(line).not.toContain("secret"); }); }); -describe("authRateLimiterMiddleware", () => { - beforeEach(() => { - vi.stubEnv("NODE_ENV", "production"); +describe("rateLimiterMiddleware for sign-in", () => { + it("counts every credential route against one strict budget", async () => { + const app = buildApp({ auth: { points: 2 } }); + + await expect( + statusesOf(app, [ + ["POST", "/api/@vitnode/core/users/sign_in"], + ["POST", "/api/@vitnode/core/users/passkeys/sign-in"], + ["POST", "/api/@vitnode/core/users/sign_in"], + ["GET", "/api/@vitnode/core/users/session"], + ["POST", "/api/@vitnode/core/users/sign_out"], + ]), + ).resolves.toEqual([200, 200, 429, 200, 200]); }); - afterEach(() => { - vi.unstubAllEnvs(); + it("spends safety and write on a sign-in too", async () => { + const app = buildApp({ write: { points: 2 }, auth: ROOMY }); + + await expect( + statusesOf(app, [ + ["POST", "/api/@vitnode/core/users/sign_in"], + ["DELETE", "/api/topics/7"], + ["POST", "/api/@vitnode/core/users/sign_in"], + ]), + ).resolves.toEqual([200, 200, 429]); }); +}); - const buildAuthApp = (ipAddress: string) => { - const app = new Hono().basePath("/api"); - app.use("*", async (c, next) => { - c.set("ipAddress", ipAddress); +describe("rateLimiterMiddleware for public content writes", () => { + it("counts public creates and edits against one budget", async () => { + const app = buildApp({ contentWrite: { points: 2 } }); - return next(); + await expect( + statusesOf(app, [ + ["POST", "/api/@acme/forum/content/topics"], + ["PATCH", "/api/@acme/forum/content/topics/7"], + ["POST", "/api/@acme/forum/content/topics"], + ]), + ).resolves.toEqual([200, 200, 429]); + }); + + it.each([ + ["GET", "/api/@acme/forum/content/topics/7/edit"], + ["PUT", "/api/@acme/forum/content/topics/7"], + ["POST", "/api/@acme/forum/admin/content/topics"], + ["PATCH", "/api/@acme/forum/admin/content/topics/7"], + ["POST", "/api/@vitnode/core/users/sign_in"], + ])("leaves %s %s to the other budgets", async (method, path) => { + const app = buildApp({ contentWrite: { points: 2 }, auth: ROOMY }); + + await expect(statusesOf(app, times(3, [method, path]))).resolves.toEqual([ + 200, 200, 200, + ]); + }); + + it("lets a reader who used up reads still post a comment", async () => { + const app = buildApp({ + safety: { points: 10 }, + read: { points: 5 }, + write: ROOMY, + contentWrite: { points: 2 }, }); - app.use("*", authRateLimiterMiddleware({ points: 2, duration: 60 })); - app.post("/@vitnode/core/users/sign_in", c => c.json({ ok: true })); - app.post("/@vitnode/core/users/passkeys/sign-in", c => - c.json({ ok: true }), + const comment = "/api/@vitnode/forum/content/comments"; + + const reads = await statusesOf( + app, + times(7, ["GET", "/api/@vitnode/forum/content/topics/42"]), ); - app.get("/@vitnode/core/users/session", c => c.json({ ok: true })); + const posts = await statusesOf(app, times(3, ["POST", comment])); + const capped = await app.request("/api/@vitnode/forum/topics/42/follow", { + method: "PUT", + }); - return app; - }; + expect(reads).toEqual([...times(5, 200), 429, 429]); + expect(posts).toEqual([200, 200, 429]); + expect(capped.status).toBe(429); + expect(capped.headers.get("X-RateLimit-Category")).toBe("safety"); + }); +}); - it("limits sign-in attempts with its own budget", async () => { - const app = buildAuthApp("auth-sign-in"); +describe("rateLimiterMiddleware identity", () => { + it("ignores client headers that try to buy a fresh budget", async () => { + vi.stubEnv("CRON_SECRET", ""); + vi.stubEnv("VITNODE_TRUSTED_PROXY_HOPS", "0"); + const app = new Hono(); + const limiter = rateLimiterMiddleware( + { read: { points: 2 } }, + { sessionCookieName: SESSION_COOKIE }, + ); + app.use("*", clientIpMiddleware); + app.use("*", limiter.requests); + app.get("/", c => c.json({ ok: true })); + + const env = { incoming: { socket: { remoteAddress: "203.0.113.50" } } }; const statuses: number[] = []; - for (let attempt = 0; attempt < 3; attempt++) { - const res = await app.request("/api/@vitnode/core/users/sign_in", { - method: "POST", - }); - statuses.push(res.status); + const attempts: Record[] = [ + { "x-prefetch": "true" }, + { "x-forwarded-for": "198.51.100.1" }, + { "x-forwarded-for": "198.51.100.2", "x-prefetch": "true" }, + ]; + for (const headers of attempts) { + statuses.push((await app.request("/", { headers }, env)).status); } expect(statuses).toEqual([200, 200, 429]); }); +}); - it("counts every credential route against the same budget", async () => { - const app = buildAuthApp("auth-shared"); - await app.request("/api/@vitnode/core/users/sign_in", { method: "POST" }); - await app.request("/api/@vitnode/core/users/passkeys/sign-in", { - method: "POST", - }); - const res = await app.request("/api/@vitnode/core/users/sign_in", { - method: "POST", - }); +describe("rateLimiterMiddleware per member", () => { + const OFFICE = "198.51.100.10"; + + it("gives each member behind one address their own read budget, under one safety cap", async () => { + const app = buildApp({ safety: { points: 3 }, read: { points: 1 } }); + + await expect( + statusesOf(app, [ + ["GET", "/api/topics", { cookie: "member-1", ip: OFFICE }], + ["GET", "/api/topics", { cookie: "member-2", ip: OFFICE }], + ["GET", "/api/topics", { cookie: "member-2", ip: OFFICE }], + ["GET", "/api/topics", { cookie: "member-3", ip: OFFICE }], + ]), + ).resolves.toEqual([200, 200, 429, 429]); + + const capped = await visit(app, [ + "GET", + "/api/topics", + { cookie: "member-4", ip: OFFICE }, + ]); + expect(capped.headers.get("X-RateLimit-Category")).toBe("safety"); + }); + + it("keeps one budget for a member who changes address", async () => { + const app = buildApp({ read: { points: 2 }, write: { points: 1 } }); + const as = (ip: string) => ({ cookie: "member-1", ip }); + + await expect( + statusesOf(app, [ + ["GET", "/api/topics", as("198.51.100.1")], + ["GET", "/api/topics", as("198.51.100.2")], + ["GET", "/api/topics", as("198.51.100.3")], + ["DELETE", "/api/topics/7", as("198.51.100.1")], + ["DELETE", "/api/topics/7", as("198.51.100.2")], + ["GET", "/api/topics", { ip: "198.51.100.2" }], + ]), + ).resolves.toEqual([200, 200, 429, 200, 429, 200]); + }); + + it("keys a cookie with no valid session by address", async () => { + const app = buildApp({ read: { points: 1 } }); + + await expect( + statusesOf(app, [ + ["GET", "/api/topics", { cookie: "expired", ip: OFFICE }], + ["GET", "/api/topics", { ip: OFFICE }], + ["GET", "/api/topics", { cookie: "forged", ip: "198.51.100.11" }], + ]), + ).resolves.toEqual([200, 429, 200]); + }); + + it("refuses a guest before the session lookup", async () => { + const app = buildApp({ read: { points: 1 } }); + + await expect( + statusesOf(app, times(3, ["GET", "/api/topics"])), + ).resolves.toEqual([200, 429, 429]); + expect(app.sessionLookups).toHaveLength(1); + }); + + it("keys every budget by address with perUser: false", async () => { + const app = buildApp({ perUser: false, read: { points: 1 } }); + + await expect( + statusesOf(app, [ + ["GET", "/api/topics", { cookie: "member-1", ip: OFFICE }], + ["GET", "/api/topics", { cookie: "member-2", ip: OFFICE }], + ]), + ).resolves.toEqual([200, 429]); + expect(app.sessionLookups).toHaveLength(1); + }); + + it("keeps sign-in per address for members too", async () => { + const app = buildApp({ auth: { points: 1 } }); + const signIn = "/api/@vitnode/core/users/sign_in"; + + await expect( + statusesOf(app, [ + ["POST", signIn, { cookie: "member-1", ip: OFFICE }], + ["POST", signIn, { cookie: "member-2", ip: OFFICE }], + ["POST", signIn, { cookie: "member-1", ip: "198.51.100.11" }], + ]), + ).resolves.toEqual([200, 429, 200]); + }); + + const ip = "203.0.113.7"; + const counted = ( + keys: [category: keyof typeof RATE_LIMITER_KEY_PREFIXES, key: string][], + ) => + Object.fromEntries( + keys.map(([category, key]) => [ + `${RATE_LIMITER_KEY_PREFIXES[category]}:${key}`, + 1, + ]), + ); + + it.each<[string, Visit, Record]>([ + [ + "a member's read", + ["GET", "/api/topics", { cookie: "member-7" }], + counted([ + ["safety", ip], + ["read", "user:7"], + ]), + ], + [ + "a member's content write", + ["POST", "/api/@acme/forum/content/topics", { cookie: "member-7" }], + counted([ + ["safety", ip], + ["write", "user:7"], + ["contentWrite", "user:7"], + ]), + ], + [ + "a member's sign-in", + ["POST", "/api/@vitnode/core/users/sign_in", { cookie: "member-7" }], + counted([ + ["safety", ip], + ["auth", ip], + ["write", "user:7"], + ]), + ], + [ + "a forged session's write", + ["POST", "/api/@acme/forum/content/topics", { cookie: "forged" }], + counted([ + ["safety", ip], + ["write", ip], + ["contentWrite", ip], + ]), + ], + [ + "a guest's write", + ["POST", "/api/@acme/forum/content/topics"], + counted([ + ["safety", ip], + ["write", ip], + ["contentWrite", ip], + ]), + ], + [ + "a guest's read", + ["GET", "/api/topics"], + counted([ + ["safety", ip], + ["read", ip], + ]), + ], + ])("spends each budget once for %s", async (_name, request, expected) => { + const redis = fakeRedis(); + const app = buildApp({}, { ipAddress: ip, storeClient: redis.storeClient }); + + expect((await visit(app, request)).status).toBe(200); + expect(Object.fromEntries(redis.counters)).toEqual(expected); + }); + + it("logs that a member ran out, but not which one", async () => { + const warn = silenceWarnings(); + const app = buildApp({ read: { points: 1 } }); + + await statusesOf( + app, + times(3, ["GET", "/api/topics", { cookie: "member-4242" }]), + ); + + expect(warn).toHaveBeenCalledOnce(); + const [line] = warn.mock.calls[0] as [string]; + expect(line).toContain('"category":"read","subject":"user"'); + expect(line).not.toContain("4242"); + expect(line).not.toContain("203.0.113"); + }); +}); + +describe("rateLimiterMiddleware across origins", () => { + it.each<[string, RateLimiterConfig, Visit]>([ + ["safety", { safety: { points: 1 } }, ["GET", "/api/topics"]], + ["read", { read: { points: 1 } }, ["GET", "/api/topics"]], + [ + "read", + { read: { points: 1 } }, + ["GET", "/api/topics", { cookie: "member-1" }], + ], + ["write", { write: { points: 1 } }, ["DELETE", "/api/topics/7"]], + [ + "auth", + { auth: { points: 1 } }, + ["POST", "/api/@vitnode/core/users/sign_in"], + ], + [ + "content-write", + { contentWrite: { points: 1 } }, + ["POST", "/api/@acme/forum/content/topics", { cookie: "member-1" }], + ], + ])("lets the page read a %s refusal", async (category, config, request) => { + const app = buildApp(config); + await visit(app, request); + + const res = await visit(app, request); expect(res.status).toBe(429); + expect(res.headers.get("X-RateLimit-Category")).toBe(category); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe(WEB_ORIGIN); + expect(res.headers.get("Access-Control-Allow-Credentials")).toBe("true"); + expect( + res.headers.get("Access-Control-Expose-Headers")?.split(","), + ).toEqual([...RATE_LIMIT_RESPONSE_HEADERS]); }); +}); - it("leaves other routes to the global budget", async () => { - const app = buildAuthApp("auth-other"); - const statuses: number[] = []; - for (let attempt = 0; attempt < 3; attempt++) { - const res = await app.request("/api/@vitnode/core/users/session"); - statuses.push(res.status); - } +describe("exposeRateLimitHeaders", () => { + it.each([ + [undefined, ["Retry-After", "X-RateLimit-Category"]], + [ + ["X-Total-Count"], + ["X-Total-Count", "Retry-After", "X-RateLimit-Category"], + ], + [["retry-after"], ["retry-after", "X-RateLimit-Category"]], + [ + ["X-RateLimit-Category", "Retry-After"], + ["X-RateLimit-Category", "Retry-After"], + ], + ])("adds the rate limit headers to %j", (exposeHeaders, expected) => { + expect(exposeRateLimitHeaders(exposeHeaders)).toEqual(expected); + }); +}); + +describe("rateLimiterMiddleware with Redis", () => { + it("uses Redis when a client is set, and memory otherwise", () => { + const limiter = createRateLimiter({ + ...ROOMY, + keyPrefix: "test", + storeClient: fakeRedis().storeClient, + }); + + expect(limiter).toBeInstanceOf(RateLimiterRedis); + expect(createRateLimiter({ ...ROOMY, keyPrefix: "test" })).toBeInstanceOf( + RateLimiterMemory, + ); + }); - expect(statuses).toEqual([200, 200, 200]); + it("shares counters between instances, under one prefix per limiter", async () => { + const redis = fakeRedis(); + const config = { read: { points: 2 } }; + const first = buildApp(config, { storeClient: redis.storeClient }); + const second = buildApp(config, { storeClient: redis.storeClient }); + + await first.request("/api/topics"); + await second.request("/api/topics"); + await first.request("/api/@vitnode/core/users/sign_in", { method: "POST" }); + + expect((await second.request("/api/topics")).status).toBe(429); + expect([...redis.counters.keys()].sort()).toEqual( + [ + `${RATE_LIMITER_KEY_PREFIXES.auth}:203.0.113.7`, + `${RATE_LIMITER_KEY_PREFIXES.read}:203.0.113.7`, + `${RATE_LIMITER_KEY_PREFIXES.safety}:203.0.113.7`, + `${RATE_LIMITER_KEY_PREFIXES.write}:203.0.113.7`, + ].sort(), + ); + }); + + it("keeps limiting in memory while Redis is down", async () => { + const app = buildApp( + { read: { points: 1 } }, + { storeClient: fakeRedis({ down: true }).storeClient }, + ); + + await expect( + statusesOf(app, times(2, ["GET", "/api/topics"])), + ).resolves.toEqual([200, 429]); }); }); -describe("contentWriteRateLimiterMiddleware", () => { - beforeEach(() => { - vi.stubEnv("NODE_ENV", "production"); +describe("legacy rateLimiter config", () => { + it.each(METHODS.filter(method => method !== "OPTIONS"))( + "keeps a top-level cap on every request (%s)", + async method => { + const legacyCap = { points: 2, duration: 60 }; + const app = buildApp(legacyCap); + + await expect( + statusesOf(app, [ + ["GET", "/api/topics"], + ["POST", "/api/topics"], + [method, "/api/topics"], + ]), + ).resolves.toEqual([200, 200, 429]); + }, + ); + + it("warns once at boot that the top-level options moved", async () => { + const warn = silenceWarnings(); + const legacyCap = { points: 1 }; + const app = buildApp(legacyCap); + await statusesOf(app, times(3, ["GET", "/api/topics"])); + + expect( + warn.mock.calls.filter(([line]) => String(line).includes("Deprecated")), + ).toHaveLength(1); }); +}); - afterEach(() => { - vi.unstubAllEnvs(); +describe("resolveRateLimiterConfig", () => { + it("fills every category with its defaults", () => { + expect(resolveRateLimiterConfig(undefined)).toEqual(RATE_LIMITER_DEFAULTS); }); - const buildContentApp = (ipAddress: string) => { - const app = new Hono().basePath("/api"); - app.use("*", async (c, next) => { - c.set("ipAddress", ipAddress); + it("overrides only the fields a category sets", () => { + const resolved = resolveRateLimiterConfig({ + auth: { points: 3, blockDuration: 300 }, + read: { duration: 10 }, + }); - return next(); + expect(resolved.auth).toEqual({ + points: 3, + duration: 60, + blockDuration: 300, }); - app.use( - "*", - contentWriteRateLimiterMiddleware({ points: 2, duration: 60 }), - ); - app.on(["GET", "POST", "PATCH", "PUT", "DELETE"], "*", c => - c.json({ ok: true }), + expect(resolved.read).toEqual({ points: 300, duration: 10 }); + expect(resolved.write).toEqual(RATE_LIMITER_DEFAULTS.write); + expect(resolved.safety).toEqual(RATE_LIMITER_DEFAULTS.safety); + }); + + it("maps the deprecated top-level options onto safety with the old defaults", () => { + const legacy = { points: 20, blockDuration: 30, auth: { points: 5 } }; + const legacyDuration = { duration: 120 }; + const resolved = resolveRateLimiterConfig(legacy); + + expect(resolved.safety).toEqual({ + points: 20, + duration: 60, + blockDuration: 30, + }); + expect(resolveRateLimiterConfig(legacyDuration).safety).toEqual({ + points: 80, + duration: 120, + }); + expect(resolved.read).toEqual(RATE_LIMITER_DEFAULTS.read); + expect(resolved.auth).toEqual({ points: 5, duration: 60 }); + }); + + it("refuses top-level options next to safety", () => { + const mixed = { points: 20, safety: { points: 500 } }; + + expect(() => resolveRateLimiterConfig(mixed)).toThrow( + /rateLimiter\.safety/, ); + }); - return app; - }; + it("passes no option a category does not own", () => { + const write = { points: 5, keyPrefix: "stolen", storeClient: {} }; + const resolved = resolveRateLimiterConfig({ write }); + const legacyCap = { perUser: false, points: 5 }; + const legacy = resolveRateLimiterConfig(legacyCap); - const statusesOf = async ( - app: ReturnType, - requests: [method: string, path: string][], - ) => { - const statuses: number[] = []; - for (const [method, path] of requests) { - statuses.push((await app.request(path, { method })).status); - } + expect(resolved.write).toEqual({ points: 5, duration: 60 }); + expect(legacy.safety).toEqual({ points: 5, duration: 60 }); + }); - return statuses; - }; + it("gives every category its own key prefix", () => { + const prefixes = Object.values(RATE_LIMITER_KEY_PREFIXES); - it("counts public creates and edits against one budget", async () => { - const app = buildContentApp("content-write"); + expect(new Set(prefixes).size).toBe(prefixes.length); + for (const prefix of prefixes) { + expect(prefixes.filter(other => other.startsWith(`${prefix}:`))).toEqual( + [], + ); + } + expect(prefixes).not.toContain("vitnode-api-rate-limiter"); + }); +}); - await expect( - statusesOf(app, [ - ["POST", "/api/@acme/forum/content/topics"], - ["PATCH", "/api/@acme/forum/content/topics/7"], - ["POST", "/api/@acme/forum/content/topics"], - ]), - ).resolves.toEqual([200, 200, 429]); +describe("rateLimiterCategoriesFor", () => { + it.each([ + ["GET", "/api/topics", ["safety", "read"]], + ["HEAD", "/api/topics", ["safety", "read"]], + ["OPTIONS", "/api/@vitnode/core/users/sign_in", ["safety"]], + ["DELETE", "/api/topics/7", ["safety", "write"]], + ["PROPFIND", "/api/topics", ["safety", "write"]], + ["POST", "/api/@vitnode/core/users/sign_in", ["safety", "write", "auth"]], + [ + "PATCH", + "/api/@acme/forum/content/topics/7", + ["safety", "write", "contentWrite"], + ], + ["PATCH", "/api/@acme/forum/admin/content/topics/7", ["safety", "write"]], + ])("%s %s spends %j", (method, path, categories) => { + expect(rateLimiterCategoriesFor(method, path)).toEqual(categories); }); +}); +describe("rateLimitPathTemplate", () => { it.each([ - ["GET", "/api/@acme/forum/content/topics/7/edit"], - ["PUT", "/api/@acme/forum/content/topics/7"], - ["POST", "/api/@acme/forum/admin/content/topics"], - ["PATCH", "/api/@acme/forum/admin/content/topics/7"], - ["POST", "/api/@vitnode/core/users/sign_in"], - ])("leaves %s %s to the other budgets", async (method, path) => { - const app = buildContentApp(`other-${method}-${path}`); + ["/api/@vitnode/core/users/sign_in", "/api/@vitnode/core/users/sign_in"], + ["/api/topics/42", "/api/topics/:param"], + ["/api/files/3f2b8c1e-9d4a-4b6f-8e2a-1c3d5e7f9a0b", "/api/files/:param"], + ["/api/reset/9f86d081884c7d659a2feaa0c55ad015", "/api/reset/:param"], + ["/api/topics/hello-world-2024", "/api/topics/:param"], + ["/api/users/%40someone", "/api/users/:param"], + ["/wp-admin/setup-config.php", "/wp-admin/setup-config.php"], + ])("templates %s as %s", (path, template) => { + expect(rateLimitPathTemplate(path)).toBe(template); + }); +}); - await expect( - statusesOf(app, [ - [method, path], - [method, path], - [method, path], - ]), - ).resolves.toEqual([200, 200, 200]); +describe("rateLimitKey", () => { + it.each([ + ["203.0.113.7", "203.0.113.7"], + ["::ffff:203.0.113.7", "203.0.113.7"], + ["2001:db8:1:2:3:4:5:6", "2001:db8:1:2::/64"], + ["2001:0db8:0001:0002:ffff:ffff:ffff:ffff", "2001:db8:1:2::/64"], + ["2001:db8::1", "2001:db8:0:0::/64"], + ["::1", "0:0:0:0::/64"], + ])("keys %s as %s, apart from member keys", (ipAddress, key) => { + expect(rateLimitKey(ipAddress)).toBe(key); + expect(key).not.toMatch(/^user:/); }); }); diff --git a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts index d2eede5c4..5f42879c6 100644 --- a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts +++ b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts @@ -1,23 +1,32 @@ -import type { Context, Next } from "hono"; +import type { Context, MiddlewareHandler } from "hono"; +import { getCookie } from "hono/cookie"; +import { routePath } from "hono/route"; import { isIP } from "node:net"; import { - type IRateLimiterOptions, type RateLimiterAbstract, RateLimiterMemory, RateLimiterRedis, - type RateLimiterRes, + RateLimiterRes, } from "rate-limiter-flexible"; import type { CacheClient } from "@/api/lib/cache"; import { CONFIG } from "../../lib/config"; +import { + RATE_LIMITER_KEY_PREFIXES, + type RateLimiterCategory, + type RateLimiterConfig, + type ResolvedRateLimiterOptions, + resolveRateLimiterConfig, + usesLegacyRateLimiterOptions, +} from "./rate-limiter-config"; -const createRateLimiter = ({ +export const createRateLimiter = ({ keyPrefix, storeClient, ...options -}: Omit & { +}: ResolvedRateLimiterOptions & { keyPrefix: string; storeClient?: CacheClient | null; }): RateLimiterAbstract => { @@ -65,98 +74,308 @@ export const rateLimitKey = (ipAddress: string): string => { .join(":")}::/64`; }; +const AUTH_RATE_LIMITED_PATHS = [ + "/@vitnode/core/users/sign_in", + "/@vitnode/core/users/sign_up", + "/@vitnode/core/users/reset-password", + "/@vitnode/core/users/verify-email", + "/@vitnode/core/users/verify-email/resend", + "/@vitnode/core/users/change-password", + "/@vitnode/core/users/passkeys/sign-in", + "/@vitnode/core/users/passkeys/admin-sign-in", +] as const; + +const READ_METHODS = new Set(["GET", "HEAD"]); + +export const rateLimiterCategoriesFor = ( + method: string, + path: string, +): RateLimiterCategory[] => { + if (method === "OPTIONS") return ["safety"]; + if (READ_METHODS.has(method)) return ["safety", "read"]; + + const categories: RateLimiterCategory[] = ["safety", "write"]; + if ( + method === "POST" && + AUTH_RATE_LIMITED_PATHS.some(authPath => path.endsWith(authPath)) + ) { + categories.push("auth"); + } + if ( + (method === "POST" || method === "PATCH") && + path.includes("/content/") && + !path.includes("/admin/") + ) { + categories.push("contentWrite"); + } + + return categories; +}; + +export const RATE_LIMIT_RESPONSE_HEADERS = [ + "Retry-After", + "X-RateLimit-Category", +] as const; + +export const exposeRateLimitHeaders = ( + exposeHeaders: readonly string[] = [], +): string[] => { + const exposed = new Set(exposeHeaders.map(header => header.toLowerCase())); + + return [ + ...exposeHeaders, + ...RATE_LIMIT_RESPONSE_HEADERS.filter( + header => !exposed.has(header.toLowerCase()), + ), + ]; +}; + +const PER_USER_CATEGORIES = new Set([ + "contentWrite", + "read", + "write", +]); + +type RateLimitSubject = "ip" | "user"; + +interface RateLimitIdentity { + key: string; + subject: RateLimitSubject; +} + +const ipIdentity = (c: Context): RateLimitIdentity => ({ + key: rateLimitKey(c.get("ipAddress")), + subject: "ip", +}); + +const userRateLimitKey = (userId: number): string => `user:${userId}`; + +const rateLimiterResOf = (rejection: unknown): RateLimiterRes | undefined => + rejection instanceof RateLimiterRes ? rejection : undefined; + +const CATEGORY_HEADER_VALUES: Record = { + safety: "safety", + read: "read", + write: "write", + auth: "auth", + contentWrite: "content-write", +}; + +const DYNAMIC_SEGMENT = + /^(?:\d+|[\da-f]{16,}|[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12})$/i; +const MAX_TEMPLATE_SEGMENTS = 12; + +const isDynamicSegment = (segment: string): boolean => + DYNAMIC_SEGMENT.test(segment) || + segment.length > 32 || + /[^\w.@-]/.test(segment) || + (segment.length >= 8 && /\d/.test(segment) && /[a-z]/i.test(segment)); + +export const rateLimitPathTemplate = (path: string): string => + path + .split("/") + .slice(0, MAX_TEMPLATE_SEGMENTS + 1) + .map(segment => (segment && isDynamicSegment(segment) ? ":param" : segment)) + .join("/"); + +const requestPathTemplate = (c: Context): string => { + const route = routePath(c, -1); + + return route && !route.includes("*") + ? route + : rateLimitPathTemplate(c.req.path); +}; + +const KNOWN_METHODS = new Set([ + "DELETE", + "GET", + "HEAD", + "OPTIONS", + "PATCH", + "POST", + "PUT", +]); + +interface CategoryLimiter { + category: RateLimiterCategory; + duration: number; + limiter: RateLimiterAbstract; + points: number; +} + +interface RateLimiterMiddlewareOptions { + sessionCookieName: string; + storeClient?: CacheClient | null; +} + export const rateLimiterMiddleware = ( - options?: Partial>, - storeClient?: CacheClient | null, - { - defaults = { duration: 60, points: 80 }, - keyPrefix = "vitnode-api-rate-limiter", - }: { - defaults?: { duration: number; points: number }; - keyPrefix?: string; - } = {}, -) => { + config: RateLimiterConfig | undefined, + { sessionCookieName, storeClient }: RateLimiterMiddlewareOptions, +): { + identified: MiddlewareHandler; + preflight: MiddlewareHandler; + requests: MiddlewareHandler; +} => { + const resolved = resolveRateLimiterConfig(config); + const perUser = config?.perUser ?? true; + + if (usesLegacyRateLimiterOptions(config)) { + // oxlint-disable-next-line no-console + console.warn( + `\x1b[34m[VitNode]\x1b[0m \x1b[33mDeprecated:\x1b[0m top-level \`rateLimiter\` options (points, duration, ...) now configure \`rateLimiter.safety\`, the cap on every request. Move them there.`, + ); + } + if (CONFIG.node_development) { // In development, we disable the rate limiter for easier testing - return async (_c: Context, next: Next) => { + const skip: MiddlewareHandler = async (_c, next) => { await next(); }; - } - const duration = options?.duration ?? defaults.duration; + return { preflight: skip, requests: skip, identified: skip }; + } - const rateLimiter = createRateLimiter({ - ...options, - keyPrefix, - duration, - points: options?.points ?? defaults.points, - storeClient, + const store = storeClient ? "redis" : "memory"; + const build = (category: RateLimiterCategory): CategoryLimiter => ({ + category, + duration: resolved[category].duration, + points: resolved[category].points, + limiter: createRateLimiter({ + ...resolved[category], + keyPrefix: RATE_LIMITER_KEY_PREFIXES[category], + storeClient, + }), }); + const limiters: Record = { + safety: build("safety"), + read: build("read"), + write: build("write"), + auth: build("auth"), + contentWrite: build("contentWrite"), + }; + + const retryAfterOf = ( + { duration }: CategoryLimiter, + res: RateLimiterRes | undefined, + ) => Math.ceil((res?.msBeforeNext ?? duration * 1000) / 1000); + + const logFirstRefusal = ( + c: Context, + limiter: CategoryLimiter, + subject: RateLimitSubject, + res: RateLimiterRes | undefined, + status: number, + ) => { + if (res?.consumedPoints !== limiter.points + 1) return; + + // oxlint-disable-next-line no-console + console.warn( + `\x1b[34m[VitNode]\x1b[0m \x1b[33mRate limit exceeded\x1b[0m: ${JSON.stringify( + { + category: CATEGORY_HEADER_VALUES[limiter.category], + subject, + method: KNOWN_METHODS.has(c.req.method) ? c.req.method : "OTHER", + path: requestPathTemplate(c), + status, + retryAfter: retryAfterOf(limiter, res), + store, + }, + )}`, + ); + }; + + const reject = ( + c: Context, + limiter: CategoryLimiter, + subject: RateLimitSubject, + rejection: unknown, + ) => { + // `rate-limiter-flexible` rejects with a `RateLimiterRes` carrying + // `msBeforeNext` when the limit is hit. Reply with JSON (not plain text) + // so clients that expect a JSON body don't choke while parsing, and + // advertise when to retry via the standard `Retry-After` header. + const res = rateLimiterResOf(rejection); + const retryAfter = retryAfterOf(limiter, res); + const [retryAfterHeader, categoryHeader] = RATE_LIMIT_RESPONSE_HEADERS; + + logFirstRefusal(c, limiter, subject, res, 429); + + c.header(retryAfterHeader, `${retryAfter}`); + c.header(categoryHeader, CATEGORY_HEADER_VALUES[limiter.category]); + + return c.json({ error: "Too Many Requests", retryAfter }, 429); + }; + + const consume = async ( + c: Context, + categories: RateLimiterCategory[], + identity: RateLimitIdentity, + ) => { + const ip = ipIdentity(c); - return async (c: Context, next: Next) => { - const key = rateLimitKey(c.get("ipAddress")); - - try { - await rateLimiter.consume(key); - } catch (rejection) { - // `rate-limiter-flexible` rejects with a `RateLimiterRes` carrying - // `msBeforeNext` when the limit is hit. Reply with JSON (not plain text) - // so clients that expect a JSON body don't choke while parsing, and - // advertise when to retry via the standard `Retry-After` header. - const msBeforeNext = (rejection as RateLimiterRes | undefined) - ?.msBeforeNext; - const retryAfter = Math.ceil((msBeforeNext ?? duration * 1000) / 1000); - c.header("Retry-After", `${retryAfter}`); - - return c.json({ error: "Too Many Requests", retryAfter }, 429); + for (const category of categories) { + const { key, subject } = PER_USER_CATEGORIES.has(category) + ? identity + : ip; + + try { + await limiters[category].limiter.consume(key); + } catch (rejection) { + return reject(c, limiters[category], subject, rejection); + } } - await next(); + return undefined; }; -}; -const AUTH_RATE_LIMITED_PATHS = [ - "/@vitnode/core/users/sign_in", - "/@vitnode/core/users/sign_up", - "/@vitnode/core/users/reset-password", - "/@vitnode/core/users/verify-email", - "/@vitnode/core/users/verify-email/resend", - "/@vitnode/core/users/change-password", - "/@vitnode/core/users/passkeys/sign-in", - "/@vitnode/core/users/passkeys/admin-sign-in", -] as const; + const carriesSession = (c: Context): boolean => + perUser && !!getCookie(c, sessionCookieName); -const isAuthRateLimitedRequest = (c: Context): boolean => - c.req.method === "POST" && - AUTH_RATE_LIMITED_PATHS.some(path => c.req.path.endsWith(path)); - -export const authRateLimiterMiddleware = ( - options?: Partial>, - storeClient?: CacheClient | null, -) => { - const limit = rateLimiterMiddleware(options, storeClient, { - defaults: { duration: 60, points: 10 }, - keyPrefix: "vitnode-api-auth-rate-limiter", - }); + return { + preflight: async (c, next) => { + if (c.req.method === "OPTIONS") { + const limiter = limiters.safety; - return async (c: Context, next: Next) => - isAuthRateLimitedRequest(c) ? limit(c, next) : next(); -}; + try { + await limiter.limiter.consume(ipIdentity(c).key); + } catch (rejection) { + logFirstRefusal(c, limiter, "ip", rateLimiterResOf(rejection), 204); + } + } -const isContentWriteRequest = (c: Context): boolean => - (c.req.method === "POST" || c.req.method === "PATCH") && - c.req.path.includes("/content/") && - !c.req.path.includes("/admin/"); - -export const contentWriteRateLimiterMiddleware = ( - options?: Partial>, - storeClient?: CacheClient | null, -) => { - const limit = rateLimiterMiddleware(options, storeClient, { - defaults: { duration: 60, points: 20 }, - keyPrefix: "vitnode-api-content-write-rate-limiter", - }); + await next(); + }, + requests: async (c, next) => { + if (c.req.method !== "OPTIONS") { + const categories = rateLimiterCategoriesFor(c.req.method, c.req.path); + const rejected = await consume( + c, + carriesSession(c) + ? categories.filter(category => !PER_USER_CATEGORIES.has(category)) + : categories, + ipIdentity(c), + ); + if (rejected) return rejected; + } + + await next(); + }, + identified: async (c, next) => { + if (c.req.method !== "OPTIONS" && carriesSession(c)) { + const userId = c.get("user")?.id; + const rejected = await consume( + c, + rateLimiterCategoriesFor(c.req.method, c.req.path).filter(category => + PER_USER_CATEGORIES.has(category), + ), + userId === undefined + ? ipIdentity(c) + : { key: userRateLimitKey(userId), subject: "user" }, + ); + if (rejected) return rejected; + } - return async (c: Context, next: Next) => - isContentWriteRequest(c) ? limit(c, next) : next(); + await next(); + }, + }; }; diff --git a/packages/vitnode/src/lib/query-client.test.ts b/packages/vitnode/src/lib/query-client.test.ts new file mode 100644 index 000000000..206db46f6 --- /dev/null +++ b/packages/vitnode/src/lib/query-client.test.ts @@ -0,0 +1,45 @@ +import { dehydrate } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; + +import { + createVitNodeQueryClient, + LOADER_DATA_QUERY_META, +} from "./query-client"; + +const dehydratedKeys = ( + queryClient: ReturnType, +) => dehydrate(queryClient).queries.map(query => query.queryKey); + +describe("createVitNodeQueryClient dehydration", () => { + it("leaves out queries a loader already ships as loaderData", async () => { + const queryClient = createVitNodeQueryClient(); + + await Promise.all([ + queryClient.query({ + meta: LOADER_DATA_QUERY_META, + queryFn: () => "article", + queryKey: ["@acme/blog", "post"], + }), + queryClient.query({ + queryFn: () => "session", + queryKey: ["session"], + }), + ]); + + expect(dehydratedKeys(queryClient)).toEqual([["session"]]); + }); + + it("lets the app replace the filter", async () => { + const queryClient = createVitNodeQueryClient({ + defaultOptions: { dehydrate: { shouldDehydrateQuery: () => true } }, + }); + + await queryClient.query({ + meta: LOADER_DATA_QUERY_META, + queryFn: () => "article", + queryKey: ["@acme/blog", "post"], + }); + + expect(dehydratedKeys(queryClient)).toEqual([["@acme/blog", "post"]]); + }); +}); diff --git a/packages/vitnode/src/lib/query-client.ts b/packages/vitnode/src/lib/query-client.ts index e8d81e0a5..a95f0c577 100644 --- a/packages/vitnode/src/lib/query-client.ts +++ b/packages/vitnode/src/lib/query-client.ts @@ -1,6 +1,15 @@ -import type { QueryClientConfig } from "@tanstack/react-query"; +import type { Query, QueryClientConfig } from "@tanstack/react-query"; -import { QueryClient } from "@tanstack/react-query"; +import { + defaultShouldDehydrateQuery, + QueryClient, +} from "@tanstack/react-query"; + +export const LOADER_DATA_QUERY_META = { dehydrate: false } as const; + +const shouldDehydrateQuery = (query: Query): boolean => + query.meta?.dehydrate !== false && + (defaultShouldDehydrateQuery(query) || query.state.status === "pending"); export const createVitNodeQueryClient = ( config?: QueryClientConfig, @@ -9,6 +18,10 @@ export const createVitNodeQueryClient = ( ...config, defaultOptions: { ...config?.defaultOptions, + dehydrate: { + shouldDehydrateQuery, + ...config?.defaultOptions?.dehydrate, + }, queries: { refetchOnMount: false, refetchOnReconnect: true, diff --git a/packages/vitnode/src/routing/authoring.test-d.ts b/packages/vitnode/src/routing/authoring.test-d.ts index c628b5ea5..392a3626a 100644 --- a/packages/vitnode/src/routing/authoring.test-d.ts +++ b/packages/vitnode/src/routing/authoring.test-d.ts @@ -8,8 +8,14 @@ import type { PluginRoutePageModule, PluginRoutePageProps, } from "./module"; +import type { + PluginRouteIndexOptions, + PluginRouteLayoutOptions, + PluginRoutePageOptions, +} from "./tree"; import { definePluginRoute } from "./authoring"; +import { index, layout, lazy, page } from "./tree"; describe("definePluginRoute", () => { it("accepts every member of the contract, and only those", () => { @@ -190,3 +196,35 @@ describe("a plugin route module's default export", () => { >().toEqualTypeOf<{ title: string }>(); }); }); + +describe("a route declaration's `preload`", () => { + const component = lazy( + async () => await Promise.resolve({ default: () => null }), + ); + + it("is an optional boolean on page(), index() and layout()", () => { + expectTypeOf["preload"]>().toEqualTypeOf< + boolean | undefined + >(); + expectTypeOf["preload"]>().toEqualTypeOf< + boolean | undefined + >(); + expectTypeOf["preload"]>().toEqualTypeOf< + boolean | undefined + >(); + }); + + it("is accepted on every shape", () => { + layout("/catalog", { + children: [index({ component, preload: true })], + component, + preload: false, + }); + page("/catalog/:productId", { component, preload: false }); + }); + + it("refuses a router's preload mode", () => { + // @ts-expect-error `preload` is `true` or `false`; *when* to preload is the router's. + page("/catalog", { component, preload: "intent" }); + }); +}); diff --git a/packages/vitnode/src/routing/errors.ts b/packages/vitnode/src/routing/errors.ts index da99e0bef..3060df2a3 100644 --- a/packages/vitnode/src/routing/errors.ts +++ b/packages/vitnode/src/routing/errors.ts @@ -14,6 +14,7 @@ export type PluginRouteErrorCode = | "invalid-path" | "invalid-pending" | "invalid-plugin-id" + | "invalid-preload" | "invalid-requires" | "invalid-search" | "invalid-tree" diff --git a/packages/vitnode/src/routing/flatten.ts b/packages/vitnode/src/routing/flatten.ts index f968006ca..cc8a19269 100644 --- a/packages/vitnode/src/routing/flatten.ts +++ b/packages/vitnode/src/routing/flatten.ts @@ -27,6 +27,7 @@ export interface FlatPluginRoute { parentId: null | string; path: string; pendingComponent: null | React.FunctionComponent; + preload: boolean | undefined; requires: null | PluginRouteRequirement; routeId: string; search: null | PluginRouteSearchValidator; @@ -175,6 +176,30 @@ const readPendingComponent = ( return pendingComponent as React.FunctionComponent; }; +const readPreload = ( + preload: unknown, + parent: FlatPluginRoute | null, + pluginId: string, + where: string, +): boolean | undefined => { + if (preload === undefined) return parent?.preload; + + if (typeof preload !== "boolean") { + const got = + typeof preload === "string" || typeof preload === "number" + ? JSON.stringify(preload) + : typeof preload; + + return fail({ + code: "invalid-preload", + message: `${where} in ${pluginId} declares a \`preload\` that is not a boolean (got ${got}). \`false\` keeps a hover from running this route's loader, \`true\` turns that back on under a layout that turned it off, and leaving it out inherits from the layout around it. When links preload ("intent", "viewport") is not the route's to say - that is the application's \`defaultPreload\`, or a link's own \`preload\`.`, + pluginId, + }); + } + + return preload; +}; + const readCache = ( cache: unknown, kind: PluginRouteKind, @@ -379,6 +404,7 @@ const readNode = ({ pluginId, where, ), + preload: readPreload(declared.preload, parent, pluginId, where), requires: readRequires(declared.requires, area, pluginId, where), routeId: pluginRouteIdFor(declared.kind, parsed.path), search: readSearch(declared.search, declared.kind, pluginId, where), diff --git a/packages/vitnode/src/routing/manifest.test.ts b/packages/vitnode/src/routing/manifest.test.ts index cf5599d38..584a4a3fe 100644 --- a/packages/vitnode/src/routing/manifest.test.ts +++ b/packages/vitnode/src/routing/manifest.test.ts @@ -234,6 +234,29 @@ describe("a page cache policy", () => { }); }); +describe("a preload setting", () => { + it("is on the routes it resolved for, and absent from the rest", () => { + const manifest = buildPluginRouteManifest([ + catalog( + layout("/catalog", { + component: lazyPage(), + children: [index({ component: lazyPage() })], + preload: false, + }), + page("/catalog/:productId", { component: lazyPage() }), + ), + ]); + + expect( + manifest.map(route => [route.id, "preload" in route, route.preload]), + ).toEqual([ + ["@acme/catalog:layout#/catalog", true, false], + ["@acme/catalog:page#/catalog", true, false], + ["@acme/catalog:page#/catalog/:productId", false, undefined], + ]); + }); +}); + describe("ordering is decided by the paths, not by the registration order", () => { const paths = (...sources: Parameters[0]) => buildPluginRouteManifest(sources).map(route => route.path); diff --git a/packages/vitnode/src/routing/manifest.ts b/packages/vitnode/src/routing/manifest.ts index b319e1fce..f752c6210 100644 --- a/packages/vitnode/src/routing/manifest.ts +++ b/packages/vitnode/src/routing/manifest.ts @@ -68,6 +68,7 @@ const builtRoute = (pluginId: string, flat: FlatPluginRoute): PluginRoute => ({ flat.parentId === null ? null : pluginRouteId(pluginId, flat.parentId), path: flat.path, pluginId, + ...(flat.preload === undefined ? {} : { preload: flat.preload }), requires: flat.requires, routeId: flat.routeId, segments: flat.segments, diff --git a/packages/vitnode/src/routing/tree.test.ts b/packages/vitnode/src/routing/tree.test.ts index 7cabcee51..e94e01545 100644 --- a/packages/vitnode/src/routing/tree.test.ts +++ b/packages/vitnode/src/routing/tree.test.ts @@ -564,3 +564,77 @@ describe("requires", () => { ).toBe("invalid-requires"); }); }); + +describe("preload", () => { + const tree = ( + outer: Record, + inner: Record, + ) => + flatten( + layout( + "/catalog", + withExtra( + { + component: lazyPage(), + children: [ + index(withExtra({ component: lazyPage() }, inner)), + page("about", { component: lazyPage() }), + ], + }, + outer, + ), + ), + page("/elsewhere", { component: lazyPage() }), + ).map(route => [route.routeId, route.preload]); + + it.each< + [string, Record, Record, unknown[]] + >([ + [ + "stays unset when nothing declares it", + {}, + {}, + [undefined, undefined, undefined, undefined], + ], + [ + "is inherited from a layout by every route inside it", + { preload: false }, + {}, + [false, false, false, undefined], + ], + [ + "lets a route inside the layout turn it back on", + { preload: false }, + { preload: true }, + [false, true, false, undefined], + ], + [ + "stays on the route that declared it", + {}, + { preload: false }, + [undefined, false, undefined, undefined], + ], + ])("%s", (_, outer, inner, expected) => { + expect(tree(outer, inner)).toEqual( + [ + "layout#/catalog", + "page#/catalog", + "page#/catalog/about", + "page#/elsewhere", + ].map((routeId, position) => [routeId, expected[position]]), + ); + }); + + it.each(["intent", 0, null])("refuses %j, which is not a boolean", value => { + expect( + thrownBy(() => + flatten( + page( + "/catalog", + withExtra({ component: lazyPage() }, { preload: value }), + ), + ), + ).code, + ).toBe("invalid-preload"); + }); +}); diff --git a/packages/vitnode/src/routing/tree.ts b/packages/vitnode/src/routing/tree.ts index 3a981434d..e344b8109 100644 --- a/packages/vitnode/src/routing/tree.ts +++ b/packages/vitnode/src/routing/tree.ts @@ -55,6 +55,7 @@ interface PluginRouteDeclarationShared { * bundle, and a heavy one is worth a second thought. */ pendingComponent?: React.FunctionComponent; + preload?: boolean; requires?: PluginRouteRequirement; } @@ -108,6 +109,7 @@ export interface PluginRouteDeclaration { readonly messages: readonly string[] | undefined; readonly path: null | string; readonly pendingComponent: unknown; + readonly preload: unknown; readonly requires: PluginRouteRequirement | undefined; readonly search: unknown; } @@ -133,6 +135,7 @@ interface PluginRouteDeclarationOptions { component: unknown; messages?: readonly string[]; pendingComponent?: unknown; + preload?: unknown; requires?: PluginRouteRequirement; search?: unknown; } @@ -154,6 +157,7 @@ const declaration = ( messages: options.messages, path: options.path, pendingComponent: options.pendingComponent, + preload: options.preload, requires: options.requires, search: options.search, }); diff --git a/packages/vitnode/src/routing/types.ts b/packages/vitnode/src/routing/types.ts index 7055b0669..468476f89 100644 --- a/packages/vitnode/src/routing/types.ts +++ b/packages/vitnode/src/routing/types.ts @@ -50,6 +50,7 @@ export interface PluginRoute { /** Canonical path, normalised (no trailing slash). */ path: string; pluginId: string; + preload?: boolean; /** As declared. `null` means the route is offered to everybody. */ requires: null | PluginRouteRequirement; /** diff --git a/packages/vitnode/src/tanstack/link/href-link.test.tsx b/packages/vitnode/src/tanstack/link/href-link.test.tsx new file mode 100644 index 000000000..58b8c7187 --- /dev/null +++ b/packages/vitnode/src/tanstack/link/href-link.test.tsx @@ -0,0 +1,125 @@ +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { HrefLink, useHrefNavigate } from "./href-link"; + +const NavigateButton = ({ href }: { href: string }) => { + const navigate = useHrefNavigate(); + + return ( + + ); +}; + +const mount = async (ui: React.ReactNode, pathname = "/") => { + const root = createRootRoute({ + component: () => ( + <> + {ui} + + + ), + }); + const router = createRouter({ + history: createMemoryHistory({ initialEntries: [pathname] }), + routeTree: root.addChildren([ + createRoute({ getParentRoute: () => root, path: "/" }), + createRoute({ getParentRoute: () => root, path: "/forum" }), + createRoute({ getParentRoute: () => root, path: "/forum/$slug" }), + createRoute({ getParentRoute: () => root, path: "/forum/create/topic" }), + ]), + }); + + render(); + await router.load(); + + return router; +}; + +describe("HrefLink", () => { + it.each([ + "/forum/news?cursor=abc#comment-7", + "/forum/create/topic?forum=123", + "/forum/news?created=true", + ])("renders the internal href %s as written", async href => { + await mount(Link); + + expect( + (await screen.findByRole("link", { name: "Link" })).getAttribute("href"), + ).toBe(href); + }); + + it.each(["https://example.com/forum?a=1", "//cdn.example.com/forum"])( + "renders the external href %s as a plain anchor", + async href => { + await mount(Link); + + expect( + (await screen.findByRole("link", { name: "Link" })).getAttribute( + "href", + ), + ).toBe(href); + }, + ); + + it("navigates client-side, with the search parsed by the router", async () => { + const router = await mount( + Next, + ); + + const link = await screen.findByRole("link", { name: "Next" }); + + expect(fireEvent.click(link)).toBe(false); + await waitFor(() => { + expect(router.state.location.pathname).toBe("/forum/news"); + }); + expect(router.state.location.search).toEqual({ cursor: "abc", page: 2 }); + expect(router.state.location.hash).toBe("top"); + }); + + it("marks only a link to exactly this URL as the current page", async () => { + await mount( + <> + Forums + News + , + "/forum/news", + ); + + expect( + (await screen.findByRole("link", { name: "News" })).getAttribute( + "aria-current", + ), + ).toBe("page"); + expect( + screen.getByRole("link", { name: "Forums" }).getAttribute("aria-current"), + ).toBeNull(); + }); +}); + +describe("useHrefNavigate", () => { + it("navigates to an internal href with its query and hash", async () => { + const router = await mount( + , + ); + + fireEvent.click(await screen.findByRole("button", { name: "Go" })); + + await waitFor(() => { + expect(router.state.location.href).toBe( + "/forum/create/topic?forum=123#form", + ); + }); + expect(router.state.location.search).toEqual({ forum: 123 }); + }); +}); diff --git a/packages/vitnode/src/tanstack/link/href-link.tsx b/packages/vitnode/src/tanstack/link/href-link.tsx new file mode 100644 index 000000000..74d209d34 --- /dev/null +++ b/packages/vitnode/src/tanstack/link/href-link.tsx @@ -0,0 +1,85 @@ +import { + type ActiveOptions, + Link, + type LinkProps, + type SearchParser, + useRouter, +} from "@tanstack/react-router"; +import { useCallback } from "react"; + +import { isExternalHref } from "@/views/admin/layouts/normalize-url"; + +const RELATIVE_BASE = "https://vitnode.invalid"; + +const hrefDestination = (href: string, parseSearch: SearchParser) => { + const url = new URL(href, RELATIVE_BASE); + + return { + hash: url.hash.slice(1) || undefined, + search: parseSearch(url.search), + to: url.pathname, + }; +}; + +export interface HrefLinkProps extends Omit, "href"> { + activeOptions?: ActiveOptions; + href: string; + preload?: LinkProps["preload"]; + preloadDelay?: number; +} + +const EXACT_URL: ActiveOptions = { exact: true, includeHash: true }; + +const InternalHrefLink = ({ + activeOptions = EXACT_URL, + href, + ...props +}: HrefLinkProps) => { + const { parseSearch } = useRouter().options; + + return ( + + ); +}; + +export const HrefLink = ({ + activeOptions, + preload, + preloadDelay, + ...props +}: HrefLinkProps) => + isExternalHref(props.href) ? ( + + ) : ( + + ); + +export const useHrefNavigate = () => { + const router = useRouter(); + + return useCallback( + async (href: string, { replace }: { replace?: boolean } = {}) => { + if (isExternalHref(href)) { + if (replace) window.location.replace(href); + else window.location.assign(href); + + return; + } + + await router.navigate({ + ...hrefDestination(href, router.options.parseSearch), + replace, + }); + }, + [router], + ); +}; diff --git a/packages/vitnode/src/tanstack/link/index.ts b/packages/vitnode/src/tanstack/link/index.ts new file mode 100644 index 000000000..02903b54b --- /dev/null +++ b/packages/vitnode/src/tanstack/link/index.ts @@ -0,0 +1,2 @@ +export type { HrefLinkProps } from "./href-link"; +export { HrefLink, useHrefNavigate } from "./href-link"; diff --git a/packages/vitnode/src/tanstack/plugin-routes/mount.tsx b/packages/vitnode/src/tanstack/plugin-routes/mount.tsx index 47427cbc4..cbf823ff8 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/mount.tsx +++ b/packages/vitnode/src/tanstack/plugin-routes/mount.tsx @@ -199,6 +199,9 @@ const pluginRouteOptions = ( ...(beforeLoad ? { beforeLoad } : {}), ...(spec.validateSearch ? { validateSearch: spec.validateSearch } : {}), + ...(spec.route.preload === undefined + ? {} + : { preload: spec.route.preload }), // Absent rather than `undefined`, so a route that declares none leaves the // router's own `defaultPendingComponent` in place instead of overriding it // with nothing. diff --git a/packages/vitnode/src/tanstack/plugin-routes/preload.test.tsx b/packages/vitnode/src/tanstack/plugin-routes/preload.test.tsx new file mode 100644 index 000000000..5a8e7d2ba --- /dev/null +++ b/packages/vitnode/src/tanstack/plugin-routes/preload.test.tsx @@ -0,0 +1,188 @@ +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Link, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PluginRouteDeclaration, PluginRouteRequirement } from "@/routing"; + +import { createVitNodeQueryClient } from "@/lib/query-client"; +import { definePluginRoutes, lazy, page } from "@/routing"; + +import type { SessionApi } from "../auth/session-api"; +import type { PluginRoutePageHead, PluginRouteRuntimeContext } from "./mount"; + +import { LOGIN_PATH } from "../auth/redirects"; +import { setSessionData } from "../auth/session-query"; +import { withVitNodeRoutes } from "./mount"; +import { pluginRouteSpecs } from "./specs"; + +const PRELOAD_DELAY = 250; + +const pageHead: PluginRoutePageHead = () => ({ meta: [] }); + +const pageWith = ( + path: string, + load: (args: { context: PluginRouteRuntimeContext }) => unknown, + options: { preload?: boolean; requires?: PluginRouteRequirement } = {}, +) => + page(path, { + ...options, + component: lazy(async () => + Promise.resolve({ + default: ({ loaderData }: { loaderData: unknown }) => ( +

{`${path}: ${String(loaderData)}`}

+ ), + route: { load }, + }), + ), + }); + +const mount = async (routes: PluginRouteDeclaration[]) => { + const queryClient = createVitNodeQueryClient(); + const root = createRootRoute({ + component: () => ( + <> + + + + ), + }); + + root.addChildren([ + createRoute({ getParentRoute: () => root, path: "/" }), + createRoute({ + component: () =>

Sign in

, + getParentRoute: () => root, + path: LOGIN_PATH, + }), + ]); + + const router = createRouter({ + context: { locale: "en", queryClient }, + defaultPreload: "intent", + defaultPreloadDelay: PRELOAD_DELAY, + defaultPreloadStaleTime: 0, + defaultStaleReloadMode: "blocking", + history: createMemoryHistory({ initialEntries: ["/"] }), + routeTree: withVitNodeRoutes( + root, + pluginRouteSpecs([ + { pluginId: "plugin", routes: definePluginRoutes(routes) }, + ]), + { pageHead }, + ), + }); + + await act(async () => { + await router.load(); + }); + render(); + + return { queryClient, router }; +}; + +const wait = async (ms: number) => { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +}; + +const linkTo = (path: string) => screen.getByRole("link", { name: path }); + +describe("preloading a plugin route on intent", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it.each([ + ["does not run a loader for a hover that leaves before the delay", 249, 0], + ["runs the loader once for a hover that rests", PRELOAD_DELAY, 1], + ])("%s", async (_, hoverFor, expectedLoads) => { + const load = vi.fn(() => "loaded"); + + await mount([pageWith("/topics", load)]); + + fireEvent.mouseEnter(linkTo("/topics")); + await wait(hoverFor); + fireEvent.mouseLeave(linkTo("/topics")); + await wait(1000); + + expect(load).toHaveBeenCalledTimes(expectedLoads); + }); + + it("skips the loader of a route declared `preload: false`, until the click", async () => { + const load = vi.fn(() => "loaded"); + + await mount([pageWith("/reports", load, { preload: false })]); + + fireEvent.mouseEnter(linkTo("/reports")); + await wait(1000); + + expect(load).not.toHaveBeenCalled(); + + fireEvent.click(linkTo("/reports")); + await wait(1000); + + expect(load).toHaveBeenCalledTimes(1); + expect(screen.getByText("/reports: loaded")).toBeTruthy(); + }); + + it("still guards a `preload: false` route on navigation", async () => { + const load = vi.fn(() => "secret"); + const { queryClient, router } = await mount([ + pageWith("/private", load, { preload: false, requires: "authenticated" }), + ]); + + setSessionData(queryClient, { user: null } as unknown as SessionApi); + + fireEvent.mouseEnter(linkTo("/private")); + await wait(1000); + fireEvent.click(linkTo("/private")); + await wait(1000); + + expect(router.state.location.pathname).toBe(LOGIN_PATH); + expect(screen.getByText("Sign in")).toBeTruthy(); + expect(load).not.toHaveBeenCalled(); + }); + + it("fetches once across the hover and the click when `load` reads through the query cache", async () => { + const queryFn = vi.fn(async () => Promise.resolve("topic")); + + await mount([ + pageWith( + "/topic", + async ({ context }) => + await context.queryClient.query({ + queryFn, + queryKey: ["topic"], + staleTime: 30_000, + }), + ), + ]); + + fireEvent.mouseEnter(linkTo("/topic")); + await wait(PRELOAD_DELAY); + fireEvent.click(linkTo("/topic")); + await wait(1000); + + expect(screen.getByText("/topic: topic")).toBeTruthy(); + expect(queryFn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/vitnode/src/views/admin/layouts/admin-link.tsx b/packages/vitnode/src/views/admin/layouts/admin-link.tsx index d049448e8..33f0d857a 100644 --- a/packages/vitnode/src/views/admin/layouts/admin-link.tsx +++ b/packages/vitnode/src/views/admin/layouts/admin-link.tsx @@ -1,17 +1,9 @@ -import { Link } from "@tanstack/react-router"; +import { HrefLink, type HrefLinkProps } from "@/tanstack/link"; -import { isExternalHref } from "./normalize-url"; +export type AdminLinkProps = Omit; -export interface AdminLinkProps extends Omit< - React.ComponentProps<"a">, - "href" -> { - href: string; -} +const PREFIX_MATCH = { exact: false }; -export const AdminLink = ({ href, ...props }: AdminLinkProps) => - isExternalHref(href) ? ( -
- ) : ( - - ); +export const AdminLink = (props: AdminLinkProps) => ( + +); diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts index 7a90fd265..6776d22d3 100644 --- a/packages/vitnode/src/vitnode.config.ts +++ b/packages/vitnode/src/vitnode.config.ts @@ -1,11 +1,11 @@ import type { drizzle } from "drizzle-orm/postgres-js"; -import type { IRateLimiterOptions } from "rate-limiter-flexible"; import type { CacheConfig } from "./api/lib/cache"; import type { CronAdapter } from "./api/lib/cron"; import type { NotificationWorkerSettings } from "./api/lib/notifications/preferences"; import type { PasskeysConfig } from "./api/lib/passkey-config"; import type { BuildPluginApiReturn } from "./api/lib/plugin"; +import type { RateLimiterConfig } from "./api/middlewares/rate-limiter-config"; import type { AIConfig } from "./api/models/ai"; import type { EmailApiPlugin } from "./api/models/email"; import type { EventsApiPlugin } from "./api/models/events"; @@ -138,10 +138,7 @@ export interface VitNodeApiConfig { storage: "redis"; }; plugins: BuildPluginApiReturn[]; - rateLimiter?: Omit & { - auth?: Partial>; - contentWrite?: Partial>; - }; + rateLimiter?: RateLimiterConfig; redis?: CacheConfig; diff --git a/plugins/blog/package.json b/plugins/blog/package.json index ae7d2af21..f6e5c9755 100644 --- a/plugins/blog/package.json +++ b/plugins/blog/package.json @@ -38,6 +38,7 @@ "dependencies": { "@hono/zod-openapi": "^1.6.3", "@tanstack/react-form": "^1.33.5", + "@tanstack/react-query": "^5.104.1", "@tanstack/react-router": "^1.170.44", "@vitnode/core": "workspace:*", "ai": "^7.0.134", diff --git a/plugins/blog/src/pages/post-page.tsx b/plugins/blog/src/pages/post-page.tsx index f6fb161e6..fd6ab643c 100644 --- a/plugins/blog/src/pages/post-page.tsx +++ b/plugins/blog/src/pages/post-page.tsx @@ -1,3 +1,6 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { PluginRoutePageProps } from "@vitnode/core/routing"; + import { DateFormat } from "@vitnode/core/components/date-format"; import { HiddenIcon } from "@vitnode/core/components/hidden-icon"; import { RichTextContent } from "@vitnode/core/components/rich-text"; @@ -6,85 +9,37 @@ import { AlertDescription, AlertTitle, } from "@vitnode/core/components/ui/alert"; -import { - zodContentFileDescriptor, - zodRichTextDocument, -} from "@vitnode/core/content"; import { resolveImageAlt } from "@vitnode/core/lib/files/resolve-alt"; -import { - definePluginRoute, - type PluginRoutePageProps, -} from "@vitnode/core/routing"; import { contentDeliveryPage, contentDeliveryPageHead, } from "@vitnode/core/tanstack/content"; -import { fetcher } from "@vitnode/core/tanstack/fetcher"; import { getIntlRuntime } from "@vitnode/core/tanstack/i18n"; import { ReportButton } from "@vitnode/core/tanstack/moderation"; +import { defineRoute } from "@vitnode/core/tanstack/plugin-routes"; import { useRecordView } from "@vitnode/core/tanstack/search"; import { EyeOffIcon } from "lucide-react"; import { useTranslations } from "use-intl"; -import { z } from "zod"; import { CONFIG_PLUGIN } from "@/const"; import { blogPostContentType } from "@/content/post"; -const zodBlogPost = z.object({ - content: zodRichTextDocument, - coverImage: zodContentFileDescriptor.nullable(), - coverImageAlt: z.string().nullable(), - publishedAt: z.string().nullable(), - title: z.string(), -}); +import { blogPostQueryOptions } from "./post-query"; -const loadBlogPost = async ({ - locale, - slug, -}: { - locale: string; - slug: string; -}) => { - const [resolution, detail] = await Promise.all([ - fetcher({ - plugin: CONFIG_PLUGIN.pluginId, - method: "get", - module: "content/blog", - path: "/delivery/resolve/{slug}", - args: { - params: { slug: encodeURIComponent(slug) }, - query: { locale }, - }, - }), - fetcher({ - plugin: CONFIG_PLUGIN.pluginId, - method: "get", - module: "content/blog", - path: "/{slug}", - args: { - params: { slug: encodeURIComponent(slug) }, - query: { locale }, - }, - }), - ]); - - if (resolution.status !== 200) { - throw new Error( - `Resolving the blog article "${slug}" answered ${resolution.status}.`, - ); - } - - return contentDeliveryPage({ - item: detail.status === 200 ? zodBlogPost.parse(await detail.json()) : null, - resolution: await resolution.json(), - }); -}; +const loadBlogPost = async ( + queryClient: QueryClient, + request: { locale: string; slug: string }, +) => + contentDeliveryPage(await queryClient.query(blogPostQueryOptions(request))); type BlogPostPageData = Awaited>; -export const route = definePluginRoute({ +export const route = defineRoute({ load: async ({ context, params }) => - await loadBlogPost({ locale: context.locale, slug: params.slug }), + await loadBlogPost(context.queryClient, { + locale: context.locale, + slug: params.slug, + }), head: ({ loaderData }) => contentDeliveryPageHead(loaderData?.metadata, { diff --git a/plugins/blog/src/pages/post-query.test.ts b/plugins/blog/src/pages/post-query.test.ts new file mode 100644 index 000000000..39ed77918 --- /dev/null +++ b/plugins/blog/src/pages/post-query.test.ts @@ -0,0 +1,60 @@ +import { createVitNodeQueryClient } from "@vitnode/core/lib/query-client"; +import { describe, expect, it, vi } from "vitest"; + +import { type BlogPostAnswer, blogPostQueryOptions } from "./post-query"; + +const ITEM: BlogPostAnswer["item"] = { + content: { content: [], type: "doc" }, + coverImage: null, + coverImageAlt: null, + publishedAt: "2026-01-01T00:00:00.000Z", + title: "Hello", +}; + +const content = (hidden: boolean): BlogPostAnswer["resolution"] => ({ + alternates: [], + canonicalInternalPath: "/blog/hello", + canonicalPath: "/blog/hello", + hidden, + hreflang: { languages: {} }, + isFallback: false, + itemId: 1, + locale: "en", + openGraph: null, + requestedLocale: "en", + robots: null, + seo: { description: null, title: null }, + type: "content", +}); + +describe("blogPostQueryOptions", () => { + it.each<[string, number, BlogPostAnswer]>([ + ["a published article", 1, { item: ITEM, resolution: content(false) }], + [ + "a redirect", + 1, + { + item: null, + resolution: { location: "/blog/new", status: 308, type: "redirect" }, + }, + ], + [ + "a hidden article only staff see", + 2, + { item: ITEM, resolution: content(true) }, + ], + ["a miss", 2, { item: null, resolution: { type: "not_found" } }], + ])("loading %s twice asks the API %i time(s)", async (_, calls, answer) => { + const queryClient = createVitNodeQueryClient(); + const queryFn = vi.fn(async () => await Promise.resolve(answer)); + const options = { + ...blogPostQueryOptions({ locale: "en", slug: "hello" }), + queryFn, + }; + + await queryClient.query(options); + await queryClient.query(options); + + expect(queryFn).toHaveBeenCalledTimes(calls); + }); +}); diff --git a/plugins/blog/src/pages/post-query.ts b/plugins/blog/src/pages/post-query.ts new file mode 100644 index 000000000..d692e2cca --- /dev/null +++ b/plugins/blog/src/pages/post-query.ts @@ -0,0 +1,89 @@ +import { queryOptions } from "@tanstack/react-query"; +import { + zodContentFileDescriptor, + zodRichTextDocument, +} from "@vitnode/core/content"; +import { LOADER_DATA_QUERY_META } from "@vitnode/core/lib/query-client"; +import { RECORD_STALE_TIME } from "@vitnode/core/lib/query-freshness"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { z } from "zod"; + +import { CONFIG_PLUGIN } from "@/const"; + +const POST_STALE_TIME = RECORD_STALE_TIME; + +const zodBlogPost = z.object({ + content: zodRichTextDocument, + coverImage: zodContentFileDescriptor.nullable(), + coverImageAlt: z.string().nullable(), + publishedAt: z.string().nullable(), + title: z.string(), +}); + +const all = [CONFIG_PLUGIN.pluginId] as const; + +export const blogQueryKeys = { + all, + post: (request: { locale: string; slug: string }) => + [...all, "post", request] as const, +}; + +const fetchBlogPost = async ({ + locale, + slug, +}: { + locale: string; + slug: string; +}) => { + const [resolution, detail] = await Promise.all([ + fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "content/blog", + path: "/delivery/resolve/{slug}", + args: { + params: { slug: encodeURIComponent(slug) }, + query: { locale }, + }, + }), + fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "content/blog", + path: "/{slug}", + args: { + params: { slug: encodeURIComponent(slug) }, + query: { locale }, + }, + }), + ]); + + if (resolution.status !== 200) { + throw new Error( + `Resolving the blog article "${slug}" answered ${resolution.status}.`, + ); + } + + return { + item: detail.status === 200 ? zodBlogPost.parse(await detail.json()) : null, + resolution: await resolution.json(), + }; +}; + +export type BlogPostAnswer = Awaited>; + +const isSharedAnswer = ({ item, resolution }: BlogPostAnswer): boolean => + resolution.type === "redirect" || + (resolution.type === "content" && !resolution.hidden && item !== null); + +export const blogPostQueryOptions = (request: { + locale: string; + slug: string; +}) => + queryOptions({ + meta: LOADER_DATA_QUERY_META, + queryFn: async () => await fetchBlogPost(request), + queryKey: blogQueryKeys.post(request), + staleTime: ({ state }) => + state.data && isSharedAnswer(state.data) ? POST_STALE_TIME : 0, + }); diff --git a/plugins/example/src/pages/board-page.tsx b/plugins/example/src/pages/board-page.tsx index 9dc0cee8a..840008d63 100644 --- a/plugins/example/src/pages/board-page.tsx +++ b/plugins/example/src/pages/board-page.tsx @@ -20,6 +20,7 @@ import { } from "@vitnode/core/routing"; import { contentPageItem } from "@vitnode/core/tanstack/content"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { HrefLink } from "@vitnode/core/tanstack/link"; import { MessagesSquareIcon, SearchXIcon } from "lucide-react"; import { Fragment } from "react"; import { useTranslations } from "use-intl"; @@ -108,12 +109,12 @@ const BoardNotFound = () => { {t("desc")} - {t("back")} - + @@ -138,7 +139,7 @@ export const route = definePluginRoute({ group: ({ loaderData }) => ( <> - + }> @@ -146,7 +147,9 @@ export const route = definePluginRoute({ - + } + > {ancestor.name} @@ -185,12 +188,12 @@ const BoardPage = ({ ) : null} - {t("new_discussion")} - + {children.length > 0 ? ( @@ -232,12 +235,12 @@ const BoardPage = ({ {t("empty_board.desc")} - {t("new_discussion")} - + ) : ( @@ -249,12 +252,12 @@ const BoardPage = ({ {discussion.title} ) : ( - {discussion.title} - + )} {discussion.publishedAt ? (