From b6ec5f704f1ee647b824c1311927889d3eccd962 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 20:48:44 +0000 Subject: [PATCH 01/11] =?UTF-8?q?feat(rate-limiter):=20=E2=9C=A8=20split?= =?UTF-8?q?=20API=20rate=20limits=20into=20read,=20write,=20auth=20and=20c?= =?UTF-8?q?ontent-write=20budgets=20under=20a=20safety=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads (GET/HEAD) and mutations now spend independent budgets, so route preloading can no longer use up the allowance a visitor needs to submit a form. A global safety cap counts every request, including OPTIONS preflights. Legacy top-level rateLimiter options map onto the safety cap with their old defaults, so existing installs keep their protection. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F1sfMPmQoAgrC8t5g5Kk3n --- .../docs/content-engine/live-editing-api.mdx | 2 +- .../docs/content-engine/public-write-api.mdx | 4 +- .../docs/content-engine/public-write.mdx | 2 +- apps/web/content/docs/dev/advanced/meta.json | 8 +- .../dev/advanced/rate-limiter-debugging.mdx | 61 ++ .../docs/dev/advanced/rate-limiter.mdx | 177 ++--- packages/vitnode/src/api/config.ts | 27 +- .../api/middlewares/rate-limiter-config.ts | 148 +++++ .../rate-limiter.middleware.test.ts | 614 +++++++++++++----- .../middlewares/rate-limiter.middleware.ts | 290 ++++++--- packages/vitnode/src/vitnode.config.ts | 7 +- 11 files changed, 1002 insertions(+), 338 deletions(-) create mode 100644 apps/web/content/docs/dev/advanced/rate-limiter-debugging.mdx create mode 100644 packages/vitnode/src/api/middlewares/rate-limiter-config.ts 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 0b1998a046..75a1cd921c 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 e4e12f906d..a8feee0e83 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 14241e4d64..ea90b3fe5e 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 77eef557ae..e0cb3d7c69 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 0000000000..69010c4771 --- /dev/null +++ b/apps/web/content/docs/dev/advanced/rate-limiter-debugging.mdx @@ -0,0 +1,61 @@ +--- +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","method":"GET","path":"/api/@vitnode/forum/content/topics/:param","status":429,"retryAfter":24,"store":"redis"} +``` + +`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 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, so its people share every budget. Raise the budget they reach, such as `rateLimiter.read.points`, rather than the safety cap. +- **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. + + + The rate limiter runs before the API reads the session, so it cannot know who + is signed in without a session lookup on every request, including the ones it + rejects. Keying by IP keeps a refused request cheap. Per-user budgets for + signed-in members could stack on top of the IP budgets later, but the IP-based + safety cap would stay. + diff --git a/apps/web/content/docs/dev/advanced/rate-limiter.mdx b/apps/web/content/docs/dev/advanced/rate-limiter.mdx index 9640c9f5da..181e15c886 100644 --- a/apps/web/content/docs/dev/advanced/rate-limiter.mdx +++ b/apps/web/content/docs/dev/advanced/rate-limiter.mdx @@ -1,87 +1,94 @@ --- 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. Reads, writes, sign-in attempts and public content writes each get their own budget, and one global safety cap 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 per IP | 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`: +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. + +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 +97,18 @@ Content-Type: application/json } ``` ---- +`X-RateLimit-Category` names the budget that refused the request: `safety`, `read`, `write`, `auth` or `content-write`. 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 +116,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 +141,68 @@ 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/packages/vitnode/src/api/config.ts b/packages/vitnode/src/api/config.ts index ff91a1ba84..241890fea4 100644 --- a/packages/vitnode/src/api/config.ts +++ b/packages/vitnode/src/api/config.ts @@ -37,11 +37,7 @@ import { globalAdminMiddleware, globalMiddleware, } from "./middlewares/global.middleware"; -import { - authRateLimiterMiddleware, - contentWriteRateLimiterMiddleware, - rateLimiterMiddleware, -} from "./middlewares/rate-limiter.middleware"; +import { rateLimiterMiddleware } from "./middlewares/rate-limiter.middleware"; import { registerCronJobs } from "./modules/cron/helpers/register-cron-jobs"; /** 25 MB: room for an image upload, and nothing like enough to be a weapon. */ @@ -135,6 +131,10 @@ export function VitNodeAPI({ } const webOrigin = CONFIG.web.origin; + const rateLimiter = rateLimiterMiddleware( + vitNodeApiConfig.rateLimiter, + redisClient, + ); app.use( secureHeaders({ @@ -143,6 +143,10 @@ export function VitNodeAPI({ strictTransportSecurity: false, }), ); + app.use("*", clientIpMiddleware); + // `cors()` answers a preflight itself and never calls `next`, so `OPTIONS` + // spends its budget here or nowhere. + app.use("*", rateLimiter.preflight); app.use(cors(corsOptions ?? { credentials: true, origin: webOrigin })); app.use( csrf( @@ -152,7 +156,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 +169,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" })); } 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 0000000000..a4da482ed2 --- /dev/null +++ b/packages/vitnode/src/api/middlewares/rate-limiter-config.ts @@ -0,0 +1,148 @@ +import type { IRateLimiterOptions } from "rate-limiter-flexible"; + +/** + * Tuning for one limiter. The store, the key prefix and the insurance limiter + * are VitNode's to set, so they are not configurable here. + */ +export type RateLimiterCategoryOptions = Partial< + Pick< + IRateLimiterOptions, + | "blockDuration" + | "duration" + | "execEvenly" + | "execEvenlyMinDelayMs" + | "points" + > +>; + +export interface RateLimiterConfig { + /** Sign-in, sign-up, password reset and passkey sign-in. Stacks on `write`. */ + auth?: RateLimiterCategoryOptions; + /** @deprecated Moved to `rateLimiter.safety.blockDuration`. */ + blockDuration?: number; + /** Public Content Engine creates and edits. Stacks on `write`. */ + 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; + /** @deprecated Moved to `rateLimiter.safety.points`. */ + points?: number; + /** `GET` and `HEAD` requests. */ + read?: RateLimiterCategoryOptions; + /** A cap on every request, whatever its method. */ + safety?: RateLimiterCategoryOptions; + /** Every request that is not a read or a CORS preflight. */ + 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 }, +}; + +// `auth` and `contentWrite` keep their prefixes, so live Redis counters carry +// over a deploy. The old single limiter's `vitnode-api-rate-limiter` is retired +// rather than handed to `safety`: it counted a different budget, and its keys +// expire within one window. +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)[]; + +// The budget the single limiter enforced on every request before categories. +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, + }; + + // Picked one by one, so nothing else on the object - a stray `keyPrefix` or + // `storeClient` from an untyped config - reaches a limiter's constructor. + 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; +}; + +/** + * Turns `rateLimiter` into complete options for every limiter. The deprecated + * top-level keys configured a cap on every request, which is what `safety` + * is, so they map there with the old 80-per-minute defaults - an old config + * keeps exactly the budget it had, and `read` and `write` apply underneath. + */ +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 382ca75dcd..f836e2be6b 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,550 @@ 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, + rateLimiterCategoriesFor, rateLimiterMiddleware, rateLimitKey, + rateLimitPathTemplate, } from "./rate-limiter.middleware"; interface Env { Variables: { ipAddress: string }; } -const buildApp = (ipAddress: string) => { - const app = new Hono(); +const METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]; + +// Budgets big enough to stay out of the way of the one a test exhausts. +const ROOMY = { points: 100, duration: 60 }; + +const buildApp = ( + config: RateLimiterConfig, + { + ipAddress = "203.0.113.7", + storeClient, + }: { ipAddress?: string; storeClient?: CacheClient } = {}, +) => { + const app = new Hono().basePath("/api"); + const limiter = rateLimiterMiddleware(config, storeClient); + app.use("*", async (c, next) => { - c.set("ipAddress", ipAddress); + c.set("ipAddress", c.req.header("x-test-ip") ?? ipAddress); return next(); }); - app.use("*", rateLimiterMiddleware({ points: 1, duration: 60 })); - app.get("/", c => c.json({ ok: true })); + app.use("*", limiter.preflight); + app.use("*", cors({ origin: "https://web.example" })); + app.use("*", limiter.requests); + app.all("*", c => c.json({ ok: true })); return app; }; +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); + } + + 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]); + }, + ); - expect(res.status).toBe(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"])); + + 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("/"); - - expect(res.status).toBe(200); + it("counts a CORS preflight once, against safety only", async () => { + const app = buildApp({ safety: { points: 2 }, read: ROOMY, write: ROOMY }); + const preflight = { + method: "OPTIONS", + headers: { + origin: "https://web.example", + "access-control-request-method": "POST", + }, + }; + + const first = await app.request("/api/topics", preflight); + const second = await app.request("/api/topics", preflight); + const third = await app.request("/api/topics", preflight); + + expect([first.status, second.status, third.status]).toEqual([ + 204, 204, 429, + ]); + expect(third.headers.get("X-RateLimit-Category")).toBe("safety"); }); -}); -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 } }); 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", + 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"; + + // Hover prefetching on a forum page spends reads fast. + 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"); + }); +}); + +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 } }); + app.use("*", clientIpMiddleware); + app.use("*", limiter.requests); + app.get("/", c => c.json({ ok: true })); - it("limits sign-in attempts with its own budget", async () => { - const app = buildAuthApp("auth-sign-in"); + 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 with Redis", () => { + // Just enough of node-redis for `RateLimiterRedis`: its Lua script increments + // a key that expires with the window. + 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 }; + }; + + it("uses Redis when a client is set, and memory otherwise", () => { + const limiter = createRateLimiter({ + ...ROOMY, + keyPrefix: "test", + storeClient: fakeRedis().storeClient, }); - expect(res.status).toBe(429); + expect(limiter).toBeInstanceOf(RateLimiterRedis); + expect(createRateLimiter({ ...ROOMY, keyPrefix: "test" })).toBeInstanceOf( + RateLimiterMemory, + ); }); - 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); - } + 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 }, + ); - expect(statuses).toEqual([200, 200, 200]); + await expect( + statusesOf(app, times(2, ["GET", "/api/topics"])), + ).resolves.toEqual([200, 429]); }); }); -describe("contentWriteRateLimiterMiddleware", () => { - beforeEach(() => { - vi.stubEnv("NODE_ENV", "production"); +// Configs written before the categories existed. Plain objects, the way an +// older config file hands them over. +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 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 }); + }); - 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", (ipAddress, key) => { + expect(rateLimitKey(ipAddress)).toBe(key); }); }); diff --git a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts index d2eede5c46..b5d1a114f8 100644 --- a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts +++ b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts @@ -1,23 +1,31 @@ -import type { Context, Next } from "hono"; +import type { Context, MiddlewareHandler } from "hono"; +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 +73,222 @@ 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"]); + +/** + * The limiters a request spends, in order. Anything that is not a read or a + * preflight counts as a write, so an unusual method gets the stricter budget. + */ +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; +}; + +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)); + +/** + * A path with its ids, hashes, tokens and slugs swapped for `:param`, so a + * log line names the route without naming the record or the visitor. + */ +export const rateLimitPathTemplate = (path: string): string => + path + .split("/") + .slice(0, MAX_TEMPLATE_SEGMENTS + 1) + .map(segment => (segment && isDynamicSegment(segment) ? ":param" : segment)) + .join("/"); + +// The router already knows the template of a matched route. A path no route +// matched (a scanner probing `/wp-admin`) only reaches the catch-all `*`. +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; +} + +/** + * Every limiter VitNode applies, built once. `preflight` runs before `cors()`, + * which answers an `OPTIONS` request without calling the next middleware, and + * spends only the `safety` budget. `requests` runs after it and skips + * `OPTIONS`, so each request is counted once and each limiter consumed at + * most once, stopping at the first that refuses. + */ export const rateLimiterMiddleware = ( - options?: Partial>, + config: RateLimiterConfig | undefined, storeClient?: CacheClient | null, - { - defaults = { duration: 60, points: 80 }, - keyPrefix = "vitnode-api-rate-limiter", - }: { - defaults?: { duration: number; points: number }; - keyPrefix?: string; - } = {}, -) => { +): { preflight: MiddlewareHandler; requests: MiddlewareHandler } => { + const resolved = resolveRateLimiterConfig(config); + + 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 }; + } - 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"), + }; - return async (c: Context, next: Next) => { - const key = rateLimitKey(c.get("ipAddress")); + const reject = ( + c: Context, + { category, duration, points }: CategoryLimiter, + 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 = rejection instanceof RateLimiterRes ? rejection : undefined; + const retryAfter = Math.ceil((res?.msBeforeNext ?? duration * 1000) / 1000); + const categoryHeader = CATEGORY_HEADER_VALUES[category]; - 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); + // Only the first refusal in a window is logged, so a flood stays one line + // per visitor per window instead of one per request. + if (res?.consumedPoints === points + 1) { + // oxlint-disable-next-line no-console + console.warn( + `\x1b[34m[VitNode]\x1b[0m \x1b[33mRate limit exceeded\x1b[0m: ${JSON.stringify( + { + category: categoryHeader, + method: KNOWN_METHODS.has(c.req.method) ? c.req.method : "OTHER", + path: requestPathTemplate(c), + status: 429, + retryAfter, + store, + }, + )}`, + ); } - await next(); - }; -}; + c.header("Retry-After", `${retryAfter}`); + c.header("X-RateLimit-Category", categoryHeader); -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; + return c.json({ error: "Too Many Requests", retryAfter }, 429); + }; -const isAuthRateLimitedRequest = (c: Context): boolean => - c.req.method === "POST" && - AUTH_RATE_LIMITED_PATHS.some(path => c.req.path.endsWith(path)); + const consume = async (c: Context, categories: RateLimiterCategory[]) => { + const key = rateLimitKey(c.get("ipAddress")); -export const authRateLimiterMiddleware = ( - options?: Partial>, - storeClient?: CacheClient | null, -) => { - const limit = rateLimiterMiddleware(options, storeClient, { - defaults: { duration: 60, points: 10 }, - keyPrefix: "vitnode-api-auth-rate-limiter", - }); + for (const category of categories) { + try { + await limiters[category].limiter.consume(key); + } catch (rejection) { + return reject(c, limiters[category], rejection); + } + } - return async (c: Context, next: Next) => - isAuthRateLimitedRequest(c) ? limit(c, next) : next(); -}; + return undefined; + }; -const isContentWriteRequest = (c: Context): boolean => - (c.req.method === "POST" || c.req.method === "PATCH") && - c.req.path.includes("/content/") && - !c.req.path.includes("/admin/"); + return { + preflight: async (c, next) => { + if (c.req.method === "OPTIONS") { + const rejected = await consume(c, ["safety"]); + if (rejected) return rejected; + } -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 rejected = await consume( + c, + rateLimiterCategoriesFor(c.req.method, c.req.path), + ); + if (rejected) return rejected; + } - return async (c: Context, next: Next) => - isContentWriteRequest(c) ? limit(c, next) : next(); + await next(); + }, + }; }; diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts index 7a90fd265d..6776d22d31 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; From 5104af4c759e53719a4ad1b609238bfd39afc356 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 20:52:16 +0000 Subject: [PATCH 02/11] =?UTF-8?q?feat(routing):=20=E2=9C=A8=20delay=20hove?= =?UTF-8?q?r=20preloads=20and=20let=20routes=20opt=20out=20with=20preload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app and starter router wait 250ms of hover before preloading, so a pointer sweeping across a list no longer runs a loader for every link. page(), index() and layout() accept preload: boolean, inherited through layouts, which maps to TanStack's route-level preload so a hover skips that route's loader. Guards still run on every navigation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F1sfMPmQoAgrC8t5g5Kk3n --- apps/web/src/router.tsx | 7 + .../copy-of-vitnode-app/root/src/router.tsx | 7 + .../vitnode/src/routing/authoring.test-d.ts | 44 ++++ packages/vitnode/src/routing/errors.ts | 1 + packages/vitnode/src/routing/flatten.ts | 36 +++ packages/vitnode/src/routing/manifest.test.ts | 28 +++ packages/vitnode/src/routing/manifest.ts | 1 + packages/vitnode/src/routing/tree.test.ts | 79 +++++++ packages/vitnode/src/routing/tree.ts | 20 ++ packages/vitnode/src/routing/types.ts | 6 + .../src/tanstack/plugin-routes/mount.tsx | 7 + .../tanstack/plugin-routes/preload.test.tsx | 212 ++++++++++++++++++ 12 files changed, 448 insertions(+) create mode 100644 packages/vitnode/src/tanstack/plugin-routes/preload.test.tsx diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index d7dd181c1c..9cfca26767 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -58,6 +58,13 @@ export function getRouter() { defaultPendingMs: 150, defaultPendingMinMs: 300, defaultPreload: 'intent', + // A pointer sweeping across a list shouldn't fire a loader - and its API + // reads - for every link it crosses: only a hover that rests this long is + // intent. Focus waits the same; a touch preloads at once. + defaultPreloadDelay: 250, + // TanStack Query owns freshness, not the router: a preloaded match is stale + // at once, so the click reruns the loader - and that costs a cache read, + // because loaders read through `queryClient.query` with their own staleTime. 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 2a879f03cc..037fc978c9 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,13 @@ export function getRouter() { defaultPendingMs: 150, defaultPendingMinMs: 300, defaultPreload: "intent", + // A pointer sweeping across a list shouldn't fire a loader - and its API + // reads - for every link it crosses: only a hover that rests this long is + // intent. Focus waits the same; a touch preloads at once. + defaultPreloadDelay: 250, + // TanStack Query owns freshness, not the router: a preloaded match is stale + // at once, so the click reruns the loader - and that costs a cache read, + // because loaders read through `queryClient.query` with their own staleTime. defaultPreloadStaleTime: 0, defaultViewTransition: navigationViewTransition( vitNodeConfig.viewTransitions, diff --git a/packages/vitnode/src/routing/authoring.test-d.ts b/packages/vitnode/src/routing/authoring.test-d.ts index c628b5ea56..e4f21927c1 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,41 @@ describe("a plugin route module's default export", () => { >().toEqualTypeOf<{ title: string }>(); }); }); + +/** + * `preload` is a boolean on all three shapes - the setting is about a route's + * loader, which a layout has as much as a page. The words a link or a router + * takes (`"intent"`, `"viewport"`) are refused here before they reach + * `flattenPluginRoutes`, which refuses them again for a tree typed as `unknown`. + */ +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 da99e0bef1..3060df2a32 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 f968006cae..40187431f0 100644 --- a/packages/vitnode/src/routing/flatten.ts +++ b/packages/vitnode/src/routing/flatten.ts @@ -27,6 +27,8 @@ export interface FlatPluginRoute { parentId: null | string; path: string; pendingComponent: null | React.FunctionComponent; + /** Resolved: the route's own setting, else its nearest layout's, else none. */ + preload: boolean | undefined; requires: null | PluginRouteRequirement; routeId: string; search: null | PluginRouteSearchValidator; @@ -175,6 +177,39 @@ const readPendingComponent = ( return pendingComponent as React.FunctionComponent; }; +/** + * A route's preload setting, with inheritance already applied. + * + * Resolved here rather than left to the router, because TanStack reads a + * route's `preload` for that route's own loader and no other: a layout that + * declared `false` would still let every page inside it run its loader on a + * hover, which is not what anybody writing it on a layout means. The parent has + * already resolved its own, so one step up is the whole chain. + */ +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 +414,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 cf5599d380..a60d49e87c 100644 --- a/packages/vitnode/src/routing/manifest.test.ts +++ b/packages/vitnode/src/routing/manifest.test.ts @@ -234,6 +234,34 @@ describe("a page cache policy", () => { }); }); +/** + * Carried only when it resolved to a boolean, the way `cache` is: a manifest for + * a tree that never mentions `preload` is the manifest it was before the option + * existed, and the router's `defaultPreload` keeps deciding for those routes. + */ +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 b319e1fce2..f752c6210a 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 7cabcee512..17677de6f1 100644 --- a/packages/vitnode/src/routing/tree.test.ts +++ b/packages/vitnode/src/routing/tree.test.ts @@ -564,3 +564,82 @@ 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]); + + /** + * Inherited because TanStack reads a route's `preload` for that route's own + * loader only - a layout's `false` that stopped at the layout would still let + * every page inside it run its loader on a hover. + */ + 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 3a981434d7..138b612887 100644 --- a/packages/vitnode/src/routing/tree.ts +++ b/packages/vitnode/src/routing/tree.ts @@ -55,6 +55,23 @@ interface PluginRouteDeclarationShared { * bundle, and a heavy one is worth a second thought. */ pendingComponent?: React.FunctionComponent; + /** + * Whether hovering or focusing a link to this route may run its loader ahead + * of the click. Left out, a route inherits the nearest layout around it that + * declares one, and otherwise follows the application's `defaultPreload`. + * + * `false` keeps a preload from running this route's loader - its module's + * `load` and the warm-up of its `messages` - which is the setting for a page + * whose `load` is costly, or not worth an API read nobody may look at. It is + * the loader alone: a preload still fetches the route's chunk, and still runs + * the `requires` guard, which reads the session through the query cache. The + * click is untouched - it runs the guard and the loader in full, as always. + * + * `true` turns preloading back on under a layout that turned it off. A link's + * own `preload` prop decides *whether* that link preloads, but cannot make a + * route declared `false` run its loader early. + */ + preload?: boolean; requires?: PluginRouteRequirement; } @@ -108,6 +125,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 +151,7 @@ interface PluginRouteDeclarationOptions { component: unknown; messages?: readonly string[]; pendingComponent?: unknown; + preload?: unknown; requires?: PluginRouteRequirement; search?: unknown; } @@ -154,6 +173,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 7055b06691..8ea5d77c04 100644 --- a/packages/vitnode/src/routing/types.ts +++ b/packages/vitnode/src/routing/types.ts @@ -50,6 +50,12 @@ export interface PluginRoute { /** Canonical path, normalised (no trailing slash). */ path: string; pluginId: string; + /** + * Whether a link preload may run this route's loader, resolved through its + * layouts. Absent when neither the route nor a layout around it declared one, + * so the router's `defaultPreload` decides. + */ + preload?: boolean; /** As declared. `null` means the route is offered to everybody. */ requires: null | PluginRouteRequirement; /** diff --git a/packages/vitnode/src/tanstack/plugin-routes/mount.tsx b/packages/vitnode/src/tanstack/plugin-routes/mount.tsx index 47427cbc48..078435cee8 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/mount.tsx +++ b/packages/vitnode/src/tanstack/plugin-routes/mount.tsx @@ -199,6 +199,13 @@ const pluginRouteOptions = ( ...(beforeLoad ? { beforeLoad } : {}), ...(spec.validateSearch ? { validateSearch: spec.validateSearch } : {}), + // Already resolved through the route's layouts, and absent when nothing + // declared one, so the router's `defaultPreload` and a link's own `preload` + // keep deciding. `false` skips only the loader during a preload: TanStack + // still runs `beforeLoad` - the guard above - and still fetches the chunk. + ...(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 0000000000..9039af5b49 --- /dev/null +++ b/packages/vitnode/src/tanstack/plugin-routes/preload.test.tsx @@ -0,0 +1,212 @@ +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"; + +/** `defaultPreloadDelay`, as both `router.tsx` files set it. */ +const PRELOAD_DELAY = 250; + +const pageHead: PluginRoutePageHead = () => ({ meta: [] }); + +/** A page whose module's `load` is the spy this file counts. */ +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 }, + }), + ), + }); + +/** + * An application as `router.tsx` builds one: plugin routes mounted through + * `withVitNodeRoutes`, the query client on the router context, and the same + * preload options - so what a hover does here is what it does on the site. + */ +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 }; +}; + +/** Lets the router's timers and the promises they start run to the end. */ +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(); + }); + + /** + * The delay is what keeps a pointer sweeping down a list of topics from + * firing one loader - and its API reads - per link it crosses. + */ + 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(); + }); + + /** + * `preload: false` turns off a loader, never a guard: the click still runs + * `requires`, and an anonymous visitor still ends up at the sign-in page + * without the page's `load` ever having run for them. + */ + 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" }), + ]); + + // Read by the guard through the query cache, as the session is on the site. + 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(); + }); + + /** + * Why `defaultPreloadStaleTime` stays `0`: the click after a preload reruns + * the route's loader, and a `load` that reads through `queryClient.query` + * turns that rerun into a cache hit - one request for the hover and the + * click together, with freshness decided by the query's own `staleTime`. + */ + 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); + }); +}); From 34fb4bf6596d741046ec1d1310fa6fda59b03ae5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 20:53:00 +0000 Subject: [PATCH 03/11] =?UTF-8?q?docs(routing):=20=F0=9F=93=9D=20document?= =?UTF-8?q?=20preloading,=20the=20preload=20route=20option=20and=20cache-b?= =?UTF-8?q?acked=20loaders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F1sfMPmQoAgrC8t5g5Kk3n --- apps/web/content/docs/dev/cache/app.mdx | 2 + apps/web/content/docs/dev/routing/index.mdx | 13 ++ apps/web/content/docs/dev/routing/meta.json | 9 +- .../content/docs/dev/routing/navigation.mdx | 6 +- .../content/docs/dev/routing/preloading.mdx | 135 ++++++++++++++++++ 5 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 apps/web/content/docs/dev/routing/preloading.mdx diff --git a/apps/web/content/docs/dev/cache/app.mdx b/apps/web/content/docs/dev/cache/app.mdx index 6f1b427093..2add96c38e 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 4aa5d95dca..1fe7f32b07 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 b61a08cf10..f17f02937e 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 e505a86586..af8ade0d0e 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 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 0000000000..82b07474cc --- /dev/null +++ b/apps/web/content/docs/dev/routing/preloading.mdx @@ -0,0 +1,135 @@ +--- +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 { 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 ++] + }) + +export const route = defineRoute({ + load: async ({ context, params }) => + await context.queryClient.query(noteQuery(params.slug)), // [!code ++] +}) +``` + +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. 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 + + + + + From 816ef48ca49095e0260b2b86620144e6007b3662 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 21:02:05 +0000 Subject: [PATCH 04/11] =?UTF-8?q?feat(blog,forum):=20=E2=9A=A1=20read=20ro?= =?UTF-8?q?ute=20loaders=20through=20TanStack=20Query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blog and Forum loaders called the fetcher directly, so every hover preload and the click after it re-sent their API requests. They now warm per-feature queryOptions with deliberate stale times, so repeat preloads and hover-then- click share one request. Answers that differ by viewer (hidden records, private responses, a member's submissions) are never reused across users, and forum writes invalidate the lists, threads and stats they change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F1sfMPmQoAgrC8t5g5Kk3n --- plugins/blog/package.json | 1 + plugins/blog/src/pages/post-page.tsx | 75 ++-------- plugins/blog/src/pages/post-query.test.ts | 60 ++++++++ plugins/blog/src/pages/post-query.ts | 94 +++++++++++++ plugins/forum/package.json | 1 + .../forum/src/pages/comment-redirect-page.tsx | 82 +++++++---- plugins/forum/src/pages/forum-page.tsx | 118 +++++++++------- plugins/forum/src/pages/forum-queries.test.ts | 50 +++++++ plugins/forum/src/pages/forum-queries.ts | 132 ++++++++++++++++++ plugins/forum/src/pages/forums-page.tsx | 22 +-- plugins/forum/src/pages/pending-page.tsx | 78 +++++++---- plugins/forum/src/pages/thread.tsx | 4 + plugins/forum/src/pages/topic-edit-page.tsx | 4 + plugins/forum/src/pages/topic-new-page.tsx | 18 +-- plugins/forum/src/pages/topic-page.tsx | 91 ++++++------ pnpm-lock.yaml | 6 + 16 files changed, 615 insertions(+), 221 deletions(-) create mode 100644 plugins/blog/src/pages/post-query.test.ts create mode 100644 plugins/blog/src/pages/post-query.ts create mode 100644 plugins/forum/src/pages/forum-queries.test.ts create mode 100644 plugins/forum/src/pages/forum-queries.ts diff --git a/plugins/blog/package.json b/plugins/blog/package.json index ae7d2af219..f6e5c9755e 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 f6fb161e6b..fd6ab643c0 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 0000000000..39ed779182 --- /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 0000000000..1c0c1bdae3 --- /dev/null +++ b/plugins/blog/src/pages/post-query.ts @@ -0,0 +1,94 @@ +import { queryOptions } from "@tanstack/react-query"; +import { + zodContentFileDescriptor, + zodRichTextDocument, +} from "@vitnode/core/content"; +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"; + +/** {@link RECORD_STALE_TIME} - An article changes when an editor saves it, well inside the five minutes its HTML is cached for. */ +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; + +/** Every key starts with the plugin id, so a prefix never reaches another plugin's entries. */ +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>; + +/** + * Whether every visitor gets this same answer. A hidden article is answered + * only to staff, and a miss may be one they can see - reusing either across a + * sign-in or sign-out would show the next visitor somebody else's page. + */ +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({ + queryFn: async () => await fetchBlogPost(request), + queryKey: blogQueryKeys.post(request), + staleTime: ({ state }) => + state.data && isSharedAnswer(state.data) ? POST_STALE_TIME : 0, + }); diff --git a/plugins/forum/package.json b/plugins/forum/package.json index 6d8280af66..81668dc526 100644 --- a/plugins/forum/package.json +++ b/plugins/forum/package.json @@ -24,6 +24,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:*", "drizzle-kit": "1.0.0-rc.4", diff --git a/plugins/forum/src/pages/comment-redirect-page.tsx b/plugins/forum/src/pages/comment-redirect-page.tsx index 8262ea48d9..1154e16747 100644 --- a/plugins/forum/src/pages/comment-redirect-page.tsx +++ b/plugins/forum/src/pages/comment-redirect-page.tsx @@ -1,55 +1,83 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { queryOptions } from "@tanstack/react-query"; import { redirect } from "@tanstack/react-router"; -import { definePluginRoute } from "@vitnode/core/routing"; import { contentPageItem } from "@vitnode/core/tanstack/content"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { defineRoute } from "@vitnode/core/tanstack/plugin-routes"; import { z } from "zod"; import { CONFIG_PLUGIN } from "@/const"; import { commentAnchorId, topicHref } from "./forum-links"; +import { + forumQueryKeys, + isSharedResponse, + TOPIC_STALE_TIME, +} from "./forum-queries"; import { zodRelationId } from "./thread-data"; const zodCommentLink = z.object({ slug: z.string(), topic: zodRelationId }); -/** A comment has no page of its own: its permalink is its place in the thread. */ -const resolveComment = async (slug: string): Promise => { - const comment = await fetcher({ - plugin: CONFIG_PLUGIN.pluginId, - method: "get", - module: "content/comments", - path: "/{slug}", - args: { params: { slug: encodeURIComponent(slug) } }, - }); +/** Where a comment lives: its topic's slug and its own, `null` when there is none for this visitor. */ +const commentLinkQueryOptions = (slug: string) => + queryOptions({ + queryFn: async () => { + const comment = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "content/comments", + path: "/{slug}", + args: { params: { slug: encodeURIComponent(slug) } }, + }); - if (comment.status !== 200 && comment.status !== 404) { - throw new Error(`Resolving the comment "${slug}" failed.`); - } + if (comment.status === 404) return { link: null, shared: false }; + if (comment.status !== 200) { + throw new Error(`Resolving the comment "${slug}" failed.`); + } - const record = contentPageItem( - comment.status === 200 ? zodCommentLink.parse(await comment.json()) : null, - ); + const record = zodCommentLink.parse(await comment.json()); + + const topic = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "forum", + path: "/topics/{id}/slug", + args: { params: { id: record.topic } }, + }); - const topic = await fetcher({ - plugin: CONFIG_PLUGIN.pluginId, - method: "get", - module: "forum", - path: "/topics/{id}/slug", - args: { params: { id: record.topic } }, + return { + link: + topic.status === 200 + ? { comment: record.slug, topic: (await topic.json()).slug } + : null, + shared: isSharedResponse(comment) && topic.status === 200, + }; + }, + queryKey: forumQueryKeys.commentLink(slug), + // The same rule as a topic's: a miss or a hidden comment is asked again. + staleTime: ({ state }) => (state.data?.shared ? TOPIC_STALE_TIME : 0), }); - const target = contentPageItem( - topic.status === 200 ? await topic.json() : null, +/** A comment has no page of its own: its permalink is its place in the thread. */ +const resolveComment = async ( + queryClient: QueryClient, + slug: string, +): Promise => { + const { comment, topic } = contentPageItem( + (await queryClient.query(commentLinkQueryOptions(slug))).link, ); // oxlint-disable-next-line typescript/only-throw-error throw redirect({ - href: `${topicHref(target.slug)}#${commentAnchorId(record.slug)}`, + href: `${topicHref(topic)}#${commentAnchorId(comment)}`, statusCode: 302, }); }; -export const route = definePluginRoute({ - load: async ({ params }) => await resolveComment(params.slug), +export const route = defineRoute({ + load: async ({ context, params }) => + await resolveComment(context.queryClient, params.slug), head: ({ t }) => ({ robots: "noindex, nofollow", diff --git a/plugins/forum/src/pages/forum-page.tsx b/plugins/forum/src/pages/forum-page.tsx index e78fcc5eb1..dbcddfb8ed 100644 --- a/plugins/forum/src/pages/forum-page.tsx +++ b/plugins/forum/src/pages/forum-page.tsx @@ -1,3 +1,7 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { PluginRoutePageProps } from "@vitnode/core/routing"; + +import { queryOptions } from "@tanstack/react-query"; import { BreadcrumbItem, BreadcrumbLink, @@ -13,12 +17,9 @@ import { EmptyMedia, EmptyTitle, } from "@vitnode/core/components/ui/empty"; -import { - definePluginRoute, - type PluginRoutePageProps, -} from "@vitnode/core/routing"; import { contentPageItem } from "@vitnode/core/tanstack/content"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { defineRoute } from "@vitnode/core/tanstack/plugin-routes"; import { MessageSquareIcon, MessagesSquareIcon, @@ -34,14 +35,15 @@ import { CONFIG_PLUGIN } from "@/const"; import type { TopicRow, TopicStats } from "./forum-data"; import type { ForumSearch } from "./forum-links"; -import { - fetchForumStats, - fetchTopicStats, - indexBy, - zodTopicRow, -} from "./forum-data"; +import { fetchTopicStats, indexBy, zodTopicRow } from "./forum-data"; import { FORUM_HREF, forumHref, topicHref, topicNewHref } from "./forum-links"; import { ForumList } from "./forum-list"; +import { + FORUM_ACTIVITY_STALE_TIME, + forumQueryKeys, + forumQueryOptions, + forumStatsQueryOptions, +} from "./forum-queries"; import { ForumIcon, ForumShell, @@ -52,48 +54,63 @@ import { const PAGE_SIZE = 25; -const loadForum = async (slug: string, { cursor }: ForumSearch) => { - const detail = await fetcher({ - plugin: CONFIG_PLUGIN.pluginId, - method: "get", - module: "content/forums", - path: "/{idOrSlug}", - args: { params: { idOrSlug: encodeURIComponent(slug) } }, - }); +/** One page of a forum's topics, newest first, with each topic's comment stats. */ +const forumTopicsQueryOptions = (request: ForumSearch & { forumId: number }) => + queryOptions({ + queryFn: async () => { + // Spread rather than written inline: the generated list query types its + // filters loosely, so an inline key reads as an excess property. + const filters = { forum: String(request.forumId) }; - if (detail.status !== 200 && detail.status !== 404) { - throw new Error("Loading the forum failed."); - } + const list = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "content/topics", + path: "/", + args: { + query: { + ...filters, + first: String(PAGE_SIZE), + ...(request.cursor ? { cursor: request.cursor } : {}), + }, + }, + }); - const { ancestors, children, container } = contentPageItem( - detail.status === 200 ? await detail.json() : null, - ); + if (list.status !== 200) throw new Error("Loading the topics failed."); - // Spread rather than written inline: the generated list query types its - // filters loosely, so an inline key reads as an excess property. - const filters = { forum: String(container.id) }; + const { edges, pageInfo } = await list.json(); + const topics = z.array(zodTopicRow).parse(edges); - const [list, forumStats] = await Promise.all([ - fetcher({ - plugin: CONFIG_PLUGIN.pluginId, - method: "get", - module: "content/topics", - path: "/", - args: { - query: { - ...filters, - first: String(PAGE_SIZE), - ...(cursor ? { cursor } : {}), - }, - }, - }), - children.length > 0 ? fetchForumStats() : Promise.resolve([]), - ]); + return { + nextCursor: pageInfo.hasNextPage ? pageInfo.endCursor : null, + topicStats: await fetchTopicStats(topics.map(topic => topic.id)), + topics, + }; + }, + queryKey: forumQueryKeys.topicList(request), + staleTime: FORUM_ACTIVITY_STALE_TIME, + }); - if (list.status !== 200) throw new Error("Loading the topics failed."); +const loadForum = async ( + queryClient: QueryClient, + slug: string, + { cursor }: ForumSearch, +) => { + const { ancestors, children, container } = contentPageItem( + await queryClient.query(forumQueryOptions(slug)), + ); - const { edges, pageInfo } = await list.json(); - const topics = z.array(zodTopicRow).parse(edges); + const [{ nextCursor, topicStats, topics }, forumStats] = await Promise.all([ + queryClient.query( + forumTopicsQueryOptions({ + forumId: container.id, + ...(cursor ? { cursor } : {}), + }), + ), + children.length > 0 + ? queryClient.query(forumStatsQueryOptions()) + : Promise.resolve([]), + ]); return { ancestors, @@ -101,8 +118,8 @@ const loadForum = async (slug: string, { cursor }: ForumSearch) => { container, forumStats, isFirstPage: cursor === undefined, - nextCursor: pageInfo.hasNextPage ? pageInfo.endCursor : null, - topicStats: await fetchTopicStats(topics.map(topic => topic.id)), + nextCursor, + topicStats, topics, }; }; @@ -141,8 +158,9 @@ const ForumsCrumb = () => { return <>{t("title")}; }; -export const route = definePluginRoute({ - load: async ({ params, search }) => await loadForum(params.slug, search), +export const route = defineRoute({ + load: async ({ context, params, search }) => + await loadForum(context.queryClient, params.slug, search), head: ({ loaderData, t }) => ({ description: diff --git a/plugins/forum/src/pages/forum-queries.test.ts b/plugins/forum/src/pages/forum-queries.test.ts new file mode 100644 index 0000000000..9409150bfa --- /dev/null +++ b/plugins/forum/src/pages/forum-queries.test.ts @@ -0,0 +1,50 @@ +import { createVitNodeQueryClient } from "@vitnode/core/lib/query-client"; +import { describe, expect, it, vi } from "vitest"; + +import { + forumStatsQueryOptions, + invalidateForumComments, + isSharedResponse, + threadQueryOptions, +} from "./forum-queries"; + +describe("isSharedResponse", () => { + it.each<[null | string, boolean]>([ + [null, true], + ["public, max-age=60", true], + ["private, no-store", false], + ["no-store", false], + ["Private", false], + ])("Cache-Control %s is shared: %s", (cacheControl, shared) => { + const headers = new Headers(); + if (cacheControl !== null) headers.set("Cache-Control", cacheControl); + + expect(isSharedResponse(new Response(null, { headers }))).toBe(shared); + }); +}); + +describe("invalidateForumComments", () => { + it("makes the next visit refetch the thread and the counts, not another topic's thread", async () => { + const queryClient = createVitNodeQueryClient(); + const thread = vi.fn(async () => await Promise.resolve(null)); + const otherThread = vi.fn(async () => await Promise.resolve(null)); + const stats = vi.fn(async () => await Promise.resolve([])); + const load = async () => { + await queryClient.query({ ...threadQueryOptions(7), queryFn: thread }); + await queryClient.query({ + ...threadQueryOptions(8), + queryFn: otherThread, + }); + await queryClient.query({ ...forumStatsQueryOptions(), queryFn: stats }); + }; + + await load(); + await load(); + await invalidateForumComments(queryClient, 7); + await load(); + + expect(thread).toHaveBeenCalledTimes(2); + expect(stats).toHaveBeenCalledTimes(2); + expect(otherThread).toHaveBeenCalledTimes(1); + }); +}); diff --git a/plugins/forum/src/pages/forum-queries.ts b/plugins/forum/src/pages/forum-queries.ts new file mode 100644 index 0000000000..d06f629ffe --- /dev/null +++ b/plugins/forum/src/pages/forum-queries.ts @@ -0,0 +1,132 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { queryOptions } from "@tanstack/react-query"; +import { + OPERATIONAL_STALE_TIME, + RECORD_STALE_TIME, +} from "@vitnode/core/lib/query-freshness"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; + +import { CONFIG_PLUGIN } from "@/const"; + +import type { ForumSearch } from "./forum-links"; + +import { fetchForums, fetchForumStats } from "./forum-data"; +import { fetchThreadPage } from "./thread-data"; + +/** {@link RECORD_STALE_TIME} - Administrators arrange forums rarely, and the forum pages' HTML is shared for a minute anyway. */ +const FORUM_TREE_STALE_TIME = RECORD_STALE_TIME; + +/** {@link RECORD_STALE_TIME} - A topic's own text changes only when its author edits it, and this tab's edits invalidate it. */ +export const TOPIC_STALE_TIME = RECORD_STALE_TIME; + +/** {@link OPERATIONAL_STALE_TIME} - Counts, topic lists and threads move with every post; this tab's own posts invalidate them at once. */ +export const FORUM_ACTIVITY_STALE_TIME = OPERATIONAL_STALE_TIME; + +/** {@link OPERATIONAL_STALE_TIME} - A moderator decides a submission somewhere else while its author waits to see the outcome. */ +export const PENDING_STALE_TIME = OPERATIONAL_STALE_TIME; + +const all = [CONFIG_PLUGIN.pluginId] as const; +const commentLinks = [...all, "comment-link"] as const; +const submissions = [...all, "pending"] as const; +const topicDetails = [...all, "topic"] as const; +const topicLists = [...all, "topic-list"] as const; + +/** Every key starts with the plugin id, so a prefix never reaches another plugin's entries. */ +export const forumQueryKeys = { + all, + commentLink: (slug: string) => [...commentLinks, slug] as const, + commentLinks: () => commentLinks, + forum: (idOrSlug: string) => [...all, "forum", idOrSlug] as const, + forums: () => [...all, "forums"] as const, + forumStats: () => [...all, "forum-stats"] as const, + pending: (userId: number, page: number) => + [...submissions, userId, { page }] as const, + submissions: () => submissions, + thread: (topicId: number) => [...all, "thread", topicId] as const, + topic: (slug: string) => [...topicDetails, slug] as const, + topicDetails: () => topicDetails, + topicList: (request: ForumSearch & { forumId: number }) => + [...topicLists, request] as const, + topicLists: () => topicLists, +}; + +/** + * Whether every visitor is given this same answer. The Content Engine marks a + * hidden record it shows to staff `private, no-store`; reusing that across a + * sign-out would show it to whoever is in the tab next. + */ +export const isSharedResponse = (res: Response): boolean => + !/\b(?:no-store|private)\b/i.test(res.headers.get("cache-control") ?? ""); + +export const forumsQueryOptions = () => + queryOptions({ + queryFn: fetchForums, + queryKey: forumQueryKeys.forums(), + staleTime: FORUM_TREE_STALE_TIME, + }); + +export const forumStatsQueryOptions = () => + queryOptions({ + queryFn: fetchForumStats, + queryKey: forumQueryKeys.forumStats(), + staleTime: FORUM_ACTIVITY_STALE_TIME, + }); + +/** One forum with its ancestors and children, by id or slug; `null` when there is none. */ +export const forumQueryOptions = (idOrSlug: string) => + queryOptions({ + queryFn: async () => { + const res = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "content/forums", + path: "/{idOrSlug}", + args: { params: { idOrSlug: encodeURIComponent(idOrSlug) } }, + }); + + if (res.status === 404) return null; + if (res.status !== 200) throw new Error("Loading the forum failed."); + + return await res.json(); + }, + queryKey: forumQueryKeys.forum(idOrSlug), + // A miss is asked again: the forum may be created a moment later. + staleTime: ({ state }) => (state.data ? FORUM_TREE_STALE_TIME : 0), + }); + +/** The first page of a topic's thread - what the topic page renders on arrival. */ +export const threadQueryOptions = (topicId: number) => + queryOptions({ + queryFn: async () => await fetchThreadPage({ topicId }), + queryKey: forumQueryKeys.thread(topicId), + staleTime: FORUM_ACTIVITY_STALE_TIME, + }); + +/** After a comment is written: its thread, every count that includes it, and the author's submissions if it awaits approval. */ +export const invalidateForumComments = async ( + queryClient: QueryClient, + topicId: number, +): Promise => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: forumQueryKeys.submissions() }), + queryClient.invalidateQueries({ queryKey: forumQueryKeys.thread(topicId) }), + queryClient.invalidateQueries({ queryKey: forumQueryKeys.topicLists() }), + queryClient.invalidateQueries({ queryKey: forumQueryKeys.forumStats() }), + ]); +}; + +/** After a topic is edited: the topic under any slug, everything that shows its title or links to its slug, and the author's submissions. */ +export const invalidateForumTopics = async ( + queryClient: QueryClient, +): Promise => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: forumQueryKeys.commentLinks() }), + queryClient.invalidateQueries({ queryKey: forumQueryKeys.submissions() }), + queryClient.invalidateQueries({ + queryKey: forumQueryKeys.topicDetails(), + }), + queryClient.invalidateQueries({ queryKey: forumQueryKeys.topicLists() }), + queryClient.invalidateQueries({ queryKey: forumQueryKeys.forumStats() }), + ]); +}; diff --git a/plugins/forum/src/pages/forums-page.tsx b/plugins/forum/src/pages/forums-page.tsx index ae520e944c..6ec0da204b 100644 --- a/plugins/forum/src/pages/forums-page.tsx +++ b/plugins/forum/src/pages/forums-page.tsx @@ -1,3 +1,6 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { PluginRoutePageProps } from "@vitnode/core/routing"; + import { buttonVariants } from "@vitnode/core/components/ui/button"; import { Empty, @@ -8,20 +11,21 @@ import { EmptyTitle, } from "@vitnode/core/components/ui/empty"; import { contentContainerTree } from "@vitnode/core/content"; -import { - definePluginRoute, - type PluginRoutePageProps, -} from "@vitnode/core/routing"; +import { defineRoute } from "@vitnode/core/tanstack/plugin-routes"; import { MessagesSquareIcon, PenSquareIcon } from "lucide-react"; import { useTranslations } from "use-intl"; -import { fetchForums, fetchForumStats, indexBy } from "./forum-data"; +import { indexBy } from "./forum-data"; import { topicNewHref } from "./forum-links"; import { ForumList } from "./forum-list"; +import { forumsQueryOptions, forumStatsQueryOptions } from "./forum-queries"; import { ForumShell } from "./forum-ui"; -const loadForums = async () => { - const [forums, stats] = await Promise.all([fetchForums(), fetchForumStats()]); +const loadForums = async (queryClient: QueryClient) => { + const [forums, stats] = await Promise.all([ + queryClient.query(forumsQueryOptions()), + queryClient.query(forumStatsQueryOptions()), + ]); return { stats, tree: contentContainerTree(forums) }; }; @@ -34,8 +38,8 @@ const ForumsBreadcrumb = () => { return {t("title")}; }; -export const route = definePluginRoute({ - load: loadForums, +export const route = defineRoute({ + load: async ({ context }) => await loadForums(context.queryClient), head: ({ t }) => ({ description: t("@vitnode/forum.forums.desc"), diff --git a/plugins/forum/src/pages/pending-page.tsx b/plugins/forum/src/pages/pending-page.tsx index db7d10c925..c30f66146a 100644 --- a/plugins/forum/src/pages/pending-page.tsx +++ b/plugins/forum/src/pages/pending-page.tsx @@ -1,3 +1,6 @@ +import type { PluginRoutePageProps } from "@vitnode/core/routing"; + +import { queryOptions } from "@tanstack/react-query"; import { Badge } from "@vitnode/core/components/ui/badge"; import { buttonVariants } from "@vitnode/core/components/ui/button"; import { @@ -8,11 +11,11 @@ import { EmptyMedia, EmptyTitle, } from "@vitnode/core/components/ui/empty"; -import { - definePluginRoute, - type PluginRoutePageProps, -} from "@vitnode/core/routing"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { + type AuthenticatedRouteLoadContext, + defineAuthenticatedRoute, +} from "@vitnode/core/tanstack/plugin-routes"; import { InboxIcon } from "lucide-react"; import { useTranslations } from "use-intl"; import { z } from "zod"; @@ -29,6 +32,7 @@ import { pendingHref, topicEditHref, } from "./forum-links"; +import { forumQueryKeys, PENDING_STALE_TIME } from "./forum-queries"; import { ForumShell, TimeAgo } from "./forum-ui"; const PAGE_SIZE = 25; @@ -49,29 +53,49 @@ const zodSubmission = z.object({ type Submission = z.infer; -const loadPending = async ({ page = 1 }: PendingSearch) => { - const res = await fetcher({ - plugin: "@vitnode/core", - method: "get", - module: "moderation", - path: "/approvals/mine", - args: { query: { page, pageSize: PAGE_SIZE } }, +/** The viewer's own submissions, keyed by who they are so another account in this tab never reads them. */ +const pendingQueryOptions = ({ + page, + userId, +}: { + page: number; + userId: number; +}) => + queryOptions({ + queryFn: async () => { + const res = await fetcher({ + plugin: "@vitnode/core", + method: "get", + module: "moderation", + path: "/approvals/mine", + args: { query: { page, pageSize: PAGE_SIZE } }, + }); + + if (res.status !== 200) { + throw new Error("Loading your submissions failed."); + } + + const data = await res.json(); + + return { + // The queue is every plugin's; this page is the forum's corner of it. + items: z + .array(zodSubmission) + .parse(data.items) + .filter(item => item.pluginId === CONFIG_PLUGIN.pluginId), + page: data.page, + pageCount: Math.max(1, Math.ceil(data.total / data.pageSize)), + }; + }, + queryKey: forumQueryKeys.pending(userId, page), + staleTime: PENDING_STALE_TIME, }); - if (res.status !== 200) throw new Error("Loading your submissions failed."); - - const data = await res.json(); - - return { - // The queue is every plugin's; this page is the forum's corner of it. - items: z - .array(zodSubmission) - .parse(data.items) - .filter(item => item.pluginId === CONFIG_PLUGIN.pluginId), - page: data.page, - pageCount: Math.max(1, Math.ceil(data.total / data.pageSize)), - }; -}; +const loadPending = async ( + { auth, queryClient }: AuthenticatedRouteLoadContext, + { page = 1 }: PendingSearch, +) => + await queryClient.query(pendingQueryOptions({ page, userId: auth.user.id })); type PendingPageData = Awaited>; @@ -81,8 +105,8 @@ const PendingBreadcrumb = () => { return {t("title")}; }; -export const route = definePluginRoute({ - load: async ({ search }) => await loadPending(search), +export const route = defineAuthenticatedRoute({ + load: async ({ context, search }) => await loadPending(context, search), head: ({ t }) => ({ description: t("@vitnode/forum.pending.desc"), diff --git a/plugins/forum/src/pages/thread.tsx b/plugins/forum/src/pages/thread.tsx index e4ea15482c..aec951564e 100644 --- a/plugins/forum/src/pages/thread.tsx +++ b/plugins/forum/src/pages/thread.tsx @@ -1,3 +1,4 @@ +import { useQueryClient } from "@tanstack/react-query"; import { RichTextContent } from "@vitnode/core/components/rich-text"; import { Button, buttonVariants } from "@vitnode/core/components/ui/button"; import { @@ -31,6 +32,7 @@ import type { ThreadComment, ThreadPage, ThreadTreeNode } from "./thread-data"; import { useForumRejection } from "./forum-errors"; import { commentAnchorId, loginHref, topicHref } from "./forum-links"; +import { invalidateForumComments } from "./forum-queries"; import { PersonAvatar, PersonName, TimeAgo } from "./forum-ui"; import { buildThreadTree, @@ -306,6 +308,7 @@ export const ForumThread = ({ topicSlug: string; }) => { const t = useTranslations("@vitnode/forum.comments"); + const queryClient = useQueryClient(); const { data: session, isPending: isSessionPending } = useSessionQuery(); const currentUserId = session?.user?.id ?? null; const [pages, setPages] = useState([initial]); @@ -359,6 +362,7 @@ export const ForumThread = ({ canComment: currentUserId !== null, currentUserId, onChanged: ({ pending }) => { + void invalidateForumComments(queryClient, topicId); if (!pending) void refresh(); }, topicId, diff --git a/plugins/forum/src/pages/topic-edit-page.tsx b/plugins/forum/src/pages/topic-edit-page.tsx index 5198399728..94d5ab86db 100644 --- a/plugins/forum/src/pages/topic-edit-page.tsx +++ b/plugins/forum/src/pages/topic-edit-page.tsx @@ -1,5 +1,6 @@ import type { ContentApprovalState } from "@vitnode/core/content"; +import { useQueryClient } from "@tanstack/react-query"; import { type AutoFormOnSubmit, setFormFieldError, @@ -46,6 +47,7 @@ import { topicEditHref, topicHref, } from "./forum-links"; +import { invalidateForumTopics } from "./forum-queries"; import { ForumShell, RefusedState } from "./forum-ui"; import { TOPIC_FORM_FIELDS, @@ -195,6 +197,7 @@ const TopicEditForm = ({ const t = useTranslations("@vitnode/forum.topics"); const tErrors = useTranslations("@vitnode/forum.forums.errors"); const rejectTopic = useForumRejection(TOPIC_FORM_FIELDS); + const queryClient = useQueryClient(); const versionRef = useRef(record.version); const [current, setCurrent] = useState({ approval: record.approval, @@ -217,6 +220,7 @@ const TopicEditForm = ({ if (res.status === 200) { const updated = await res.json(); + await invalidateForumTopics(queryClient); versionRef.current = updated.version; setCurrent({ approval: updated.approval, diff --git a/plugins/forum/src/pages/topic-new-page.tsx b/plugins/forum/src/pages/topic-new-page.tsx index 9e9eb6d9a0..8cdced1e39 100644 --- a/plugins/forum/src/pages/topic-new-page.tsx +++ b/plugins/forum/src/pages/topic-new-page.tsx @@ -1,3 +1,6 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { PluginRoutePageProps } from "@vitnode/core/routing"; + import { type AutoFormOnSubmit, setFormFieldError, @@ -11,11 +14,8 @@ import { EmptyMedia, EmptyTitle, } from "@vitnode/core/components/ui/empty"; -import { - definePluginRoute, - type PluginRoutePageProps, -} from "@vitnode/core/routing"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { defineRoute } from "@vitnode/core/tanstack/plugin-routes"; import { FolderTreeIcon } from "lucide-react"; import { useState } from "react"; import { useTranslations } from "use-intl"; @@ -24,9 +24,9 @@ import { CONFIG_PLUGIN } from "@/const"; import type { TopicNewSearch } from "./forum-links"; -import { fetchForums } from "./forum-data"; import { useForumRejection } from "./forum-errors"; import { FORUM_HREF, topicEditHref, topicHref } from "./forum-links"; +import { forumsQueryOptions } from "./forum-queries"; import { ForumShell } from "./forum-ui"; import { TOPIC_FORM_FIELDS, @@ -34,8 +34,8 @@ import { type TopicCreateFormSchema, } from "./topic-form"; -const loadForumOptions = async () => - (await fetchForums()).map(forum => ({ +const loadForumOptions = async (queryClient: QueryClient) => + (await queryClient.query(forumsQueryOptions())).map(forum => ({ label: `${"— ".repeat(forum.depth - 1)}${forum.name}`, value: String(forum.id), })); @@ -48,8 +48,8 @@ const TopicNewBreadcrumb = () => { return {t("title")}; }; -export const route = definePluginRoute({ - load: loadForumOptions, +export const route = defineRoute({ + load: async ({ context }) => await loadForumOptions(context.queryClient), head: ({ t }) => ({ description: t("@vitnode/forum.topics.new.desc"), diff --git a/plugins/forum/src/pages/topic-page.tsx b/plugins/forum/src/pages/topic-page.tsx index 3dea00c987..8853cb7dea 100644 --- a/plugins/forum/src/pages/topic-page.tsx +++ b/plugins/forum/src/pages/topic-page.tsx @@ -1,3 +1,7 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { PluginRoutePageProps } from "@vitnode/core/routing"; + +import { queryOptions } from "@tanstack/react-query"; import { RichTextContent } from "@vitnode/core/components/rich-text"; import { BreadcrumbItem, @@ -10,14 +14,11 @@ import { richTextToPlainText, zodRichTextDocument, } from "@vitnode/core/content"; -import { - definePluginRoute, - type PluginRoutePageProps, -} from "@vitnode/core/routing"; import { useSessionQuery } from "@vitnode/core/tanstack/auth"; import { contentPageItem } from "@vitnode/core/tanstack/content"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; import { localeAlternateLinks } from "@vitnode/core/tanstack/metadata"; +import { defineRoute } from "@vitnode/core/tanstack/plugin-routes"; import { useRecordView } from "@vitnode/core/tanstack/search"; import { PencilIcon } from "lucide-react"; import { Fragment, useEffect, useRef } from "react"; @@ -39,9 +40,15 @@ import { topicHref, topicNewHref, } from "./forum-links"; +import { + forumQueryKeys, + forumQueryOptions, + isSharedResponse, + threadQueryOptions, + TOPIC_STALE_TIME, +} from "./forum-queries"; import { PersonAvatar, PersonName, TimeAgo } from "./forum-ui"; import { ForumThread } from "./thread"; -import { fetchThreadPage } from "./thread-data"; const DESCRIPTION_MAX_LENGTH = 160; @@ -73,46 +80,49 @@ const absoluteUrl = (path: string, base: string): string => { } }; -const loadTopic = async ({ - locale, - slug, -}: { - locale: string; - slug: string; -}) => { - const detail = await fetcher({ - plugin: CONFIG_PLUGIN.pluginId, - method: "get", - module: "content/topics", - path: "/{slug}", - args: { params: { slug: encodeURIComponent(slug) } }, +/** One topic by slug, `null` when there is none for this visitor. */ +const topicQueryOptions = (slug: string) => + queryOptions({ + queryFn: async () => { + const res = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "content/topics", + path: "/{slug}", + args: { params: { slug: encodeURIComponent(slug) } }, + }); + + if (res.status === 404) return { shared: false, topic: null }; + if (res.status !== 200) { + throw new Error(`Loading the topic "${slug}" failed.`); + } + + return { + shared: isSharedResponse(res), + topic: zodTopic.parse(await res.json()), + }; + }, + queryKey: forumQueryKeys.topic(slug), + // A miss may be a hidden topic a moderator can see, and a hidden one is + // that moderator's alone - only an answer every visitor gets is reused. + staleTime: ({ state }) => (state.data?.shared ? TOPIC_STALE_TIME : 0), }); - if (detail.status !== 200 && detail.status !== 404) { - throw new Error(`Loading the topic "${slug}" failed.`); - } - +const loadTopic = async ( + queryClient: QueryClient, + { locale, slug }: { locale: string; slug: string }, +) => { const topic = contentPageItem( - detail.status === 200 ? zodTopic.parse(await detail.json()) : null, + (await queryClient.query(topicQueryOptions(slug))).topic, ); const [forum, thread] = await Promise.all([ - fetcher({ - plugin: CONFIG_PLUGIN.pluginId, - method: "get", - module: "content/forums", - path: "/{idOrSlug}", - args: { params: { idOrSlug: String(topic.forum) } }, - }), - fetchThreadPage({ topicId: topic.id }), + // The trail is decoration: a forum that fails to load leaves it empty. + queryClient.query(forumQueryOptions(String(topic.forum))).catch(() => null), + queryClient.query(threadQueryOptions(topic.id)), ]); - const trail = - forum.status === 200 - ? await forum - .json() - .then(({ ancestors, container }) => [...ancestors, container]) - : []; + const trail = forum ? [...forum.ancestors, forum.container] : []; const path = topicHref(topic.slug); const canonicalUrl = @@ -149,9 +159,12 @@ const ForumsCrumb = () => { return <>{t("title")}; }; -export const route = definePluginRoute({ +export const route = defineRoute({ load: async ({ context, params }) => - await loadTopic({ locale: context.locale, slug: params.slug }), + await loadTopic(context.queryClient, { + locale: context.locale, + slug: params.slug, + }), head: ({ loaderData }) => ({ alternates: loaderData diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41a9b07e82..14d8183d9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -823,6 +823,9 @@ importers: '@tanstack/react-form': specifier: ^1.33.5 version: 1.33.5(@tanstack/react-start@1.169.1(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tanstack/react-query': + specifier: ^5.104.1 + version: 5.104.1(react@19.3.0) '@tanstack/react-router': specifier: ^1.170.44 version: 1.170.44(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -984,6 +987,9 @@ importers: '@tanstack/react-form': specifier: ^1.33.5 version: 1.33.5(@tanstack/react-start@1.169.1(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tanstack/react-query': + specifier: ^5.104.1 + version: 5.104.1(react@19.3.0) '@tanstack/react-router': specifier: ^1.170.44 version: 1.170.44(react-dom@19.3.0(react@19.3.0))(react@19.3.0) From 024c7a6a71ea4df289819f0aa5647e6e9d5479b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 07:19:42 +0000 Subject: [PATCH 05/11] =?UTF-8?q?perf(query):=20=E2=9A=A1=20skip=20dehydra?= =?UTF-8?q?ting=20queries=20a=20loader=20already=20ships=20as=20loaderData?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blog and Forum loaders read through TanStack Query and also return the data as loaderData, so the server-rendered HTML carried it twice. Queries marked with LOADER_DATA_QUERY_META are now left out of the SSR dehydration that createVitNodeQueryClient configures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F1sfMPmQoAgrC8t5g5Kk3n --- packages/vitnode/src/lib/query-client.test.ts | 45 +++++++++++++++++++ packages/vitnode/src/lib/query-client.ts | 21 ++++++++- plugins/blog/src/pages/post-query.ts | 2 + .../forum/src/pages/comment-redirect-page.tsx | 2 + plugins/forum/src/pages/forum-page.tsx | 2 + plugins/forum/src/pages/forum-queries.ts | 5 +++ plugins/forum/src/pages/pending-page.tsx | 2 + plugins/forum/src/pages/topic-page.tsx | 2 + 8 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 packages/vitnode/src/lib/query-client.test.ts 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 0000000000..206db46f69 --- /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 e8d81e0a50..193b7d2d47 100644 --- a/packages/vitnode/src/lib/query-client.ts +++ b/packages/vitnode/src/lib/query-client.ts @@ -1,7 +1,22 @@ -import type { QueryClientConfig } from "@tanstack/react-query"; +import type { Query, QueryClientConfig } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query"; +/** + * `meta` for a query whose data a route loader returns as its `loaderData`. + * + * The router already ships `loaderData` to the browser with the HTML, so + * dehydrating the query as well would put the same data in the page twice. The + * browser starts without the entry, renders from `loaderData`, and the next + * loader run fills the cache. + */ +export const LOADER_DATA_QUERY_META = { dehydrate: false } as const; + +// Every query streams to the browser - pending ones included, as the router's +// SSR integration does by default - except the ones a loader already ships. +const shouldDehydrateQuery = (query: Query): boolean => + query.meta?.dehydrate !== false; + export const createVitNodeQueryClient = ( config?: QueryClientConfig, ): QueryClient => @@ -9,6 +24,10 @@ export const createVitNodeQueryClient = ( ...config, defaultOptions: { ...config?.defaultOptions, + dehydrate: { + shouldDehydrateQuery, + ...config?.defaultOptions?.dehydrate, + }, queries: { refetchOnMount: false, refetchOnReconnect: true, diff --git a/plugins/blog/src/pages/post-query.ts b/plugins/blog/src/pages/post-query.ts index 1c0c1bdae3..75f1d61e6c 100644 --- a/plugins/blog/src/pages/post-query.ts +++ b/plugins/blog/src/pages/post-query.ts @@ -3,6 +3,7 @@ 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"; @@ -87,6 +88,7 @@ export const blogPostQueryOptions = (request: { slug: string; }) => queryOptions({ + meta: LOADER_DATA_QUERY_META, queryFn: async () => await fetchBlogPost(request), queryKey: blogQueryKeys.post(request), staleTime: ({ state }) => diff --git a/plugins/forum/src/pages/comment-redirect-page.tsx b/plugins/forum/src/pages/comment-redirect-page.tsx index 1154e16747..97333fcd8c 100644 --- a/plugins/forum/src/pages/comment-redirect-page.tsx +++ b/plugins/forum/src/pages/comment-redirect-page.tsx @@ -2,6 +2,7 @@ import type { QueryClient } from "@tanstack/react-query"; import { queryOptions } from "@tanstack/react-query"; import { redirect } from "@tanstack/react-router"; +import { LOADER_DATA_QUERY_META } from "@vitnode/core/lib/query-client"; import { contentPageItem } from "@vitnode/core/tanstack/content"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; import { defineRoute } from "@vitnode/core/tanstack/plugin-routes"; @@ -22,6 +23,7 @@ const zodCommentLink = z.object({ slug: z.string(), topic: zodRelationId }); /** Where a comment lives: its topic's slug and its own, `null` when there is none for this visitor. */ const commentLinkQueryOptions = (slug: string) => queryOptions({ + meta: LOADER_DATA_QUERY_META, queryFn: async () => { const comment = await fetcher({ plugin: CONFIG_PLUGIN.pluginId, diff --git a/plugins/forum/src/pages/forum-page.tsx b/plugins/forum/src/pages/forum-page.tsx index dbcddfb8ed..6c0fbe2734 100644 --- a/plugins/forum/src/pages/forum-page.tsx +++ b/plugins/forum/src/pages/forum-page.tsx @@ -17,6 +17,7 @@ import { EmptyMedia, EmptyTitle, } from "@vitnode/core/components/ui/empty"; +import { LOADER_DATA_QUERY_META } from "@vitnode/core/lib/query-client"; import { contentPageItem } from "@vitnode/core/tanstack/content"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; import { defineRoute } from "@vitnode/core/tanstack/plugin-routes"; @@ -57,6 +58,7 @@ const PAGE_SIZE = 25; /** One page of a forum's topics, newest first, with each topic's comment stats. */ const forumTopicsQueryOptions = (request: ForumSearch & { forumId: number }) => queryOptions({ + meta: LOADER_DATA_QUERY_META, queryFn: async () => { // Spread rather than written inline: the generated list query types its // filters loosely, so an inline key reads as an excess property. diff --git a/plugins/forum/src/pages/forum-queries.ts b/plugins/forum/src/pages/forum-queries.ts index d06f629ffe..d323029a9c 100644 --- a/plugins/forum/src/pages/forum-queries.ts +++ b/plugins/forum/src/pages/forum-queries.ts @@ -1,6 +1,7 @@ import type { QueryClient } from "@tanstack/react-query"; import { queryOptions } from "@tanstack/react-query"; +import { LOADER_DATA_QUERY_META } from "@vitnode/core/lib/query-client"; import { OPERATIONAL_STALE_TIME, RECORD_STALE_TIME, @@ -61,6 +62,7 @@ export const isSharedResponse = (res: Response): boolean => export const forumsQueryOptions = () => queryOptions({ + meta: LOADER_DATA_QUERY_META, queryFn: fetchForums, queryKey: forumQueryKeys.forums(), staleTime: FORUM_TREE_STALE_TIME, @@ -68,6 +70,7 @@ export const forumsQueryOptions = () => export const forumStatsQueryOptions = () => queryOptions({ + meta: LOADER_DATA_QUERY_META, queryFn: fetchForumStats, queryKey: forumQueryKeys.forumStats(), staleTime: FORUM_ACTIVITY_STALE_TIME, @@ -76,6 +79,7 @@ export const forumStatsQueryOptions = () => /** One forum with its ancestors and children, by id or slug; `null` when there is none. */ export const forumQueryOptions = (idOrSlug: string) => queryOptions({ + meta: LOADER_DATA_QUERY_META, queryFn: async () => { const res = await fetcher({ plugin: CONFIG_PLUGIN.pluginId, @@ -98,6 +102,7 @@ export const forumQueryOptions = (idOrSlug: string) => /** The first page of a topic's thread - what the topic page renders on arrival. */ export const threadQueryOptions = (topicId: number) => queryOptions({ + meta: LOADER_DATA_QUERY_META, queryFn: async () => await fetchThreadPage({ topicId }), queryKey: forumQueryKeys.thread(topicId), staleTime: FORUM_ACTIVITY_STALE_TIME, diff --git a/plugins/forum/src/pages/pending-page.tsx b/plugins/forum/src/pages/pending-page.tsx index c30f66146a..ed09abcd6c 100644 --- a/plugins/forum/src/pages/pending-page.tsx +++ b/plugins/forum/src/pages/pending-page.tsx @@ -11,6 +11,7 @@ import { EmptyMedia, EmptyTitle, } from "@vitnode/core/components/ui/empty"; +import { LOADER_DATA_QUERY_META } from "@vitnode/core/lib/query-client"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; import { type AuthenticatedRouteLoadContext, @@ -62,6 +63,7 @@ const pendingQueryOptions = ({ userId: number; }) => queryOptions({ + meta: LOADER_DATA_QUERY_META, queryFn: async () => { const res = await fetcher({ plugin: "@vitnode/core", diff --git a/plugins/forum/src/pages/topic-page.tsx b/plugins/forum/src/pages/topic-page.tsx index 8853cb7dea..d5e7c8c981 100644 --- a/plugins/forum/src/pages/topic-page.tsx +++ b/plugins/forum/src/pages/topic-page.tsx @@ -14,6 +14,7 @@ import { richTextToPlainText, zodRichTextDocument, } from "@vitnode/core/content"; +import { LOADER_DATA_QUERY_META } from "@vitnode/core/lib/query-client"; import { useSessionQuery } from "@vitnode/core/tanstack/auth"; import { contentPageItem } from "@vitnode/core/tanstack/content"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; @@ -83,6 +84,7 @@ const absoluteUrl = (path: string, base: string): string => { /** One topic by slug, `null` when there is none for this visitor. */ const topicQueryOptions = (slug: string) => queryOptions({ + meta: LOADER_DATA_QUERY_META, queryFn: async () => { const res = await fetcher({ plugin: CONFIG_PLUGIN.pluginId, From 9f883f4959dfef5d9492ed442368998cc36aa57a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 07:20:28 +0000 Subject: [PATCH 06/11] =?UTF-8?q?docs(routing):=20=F0=9F=93=9D=20mark=20lo?= =?UTF-8?q?ader-returned=20queries=20with=20LOADER=5FDATA=5FQUERY=5FMETA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F1sfMPmQoAgrC8t5g5Kk3n --- apps/web/content/docs/dev/routing/preloading.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/content/docs/dev/routing/preloading.mdx b/apps/web/content/docs/dev/routing/preloading.mdx index 82b07474cc..dceed3ab24 100644 --- a/apps/web/content/docs/dev/routing/preloading.mdx +++ b/apps/web/content/docs/dev/routing/preloading.mdx @@ -44,6 +44,7 @@ Read your API through `context.queryClient.query` in `load`, with a `staleTime`. ```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' @@ -53,6 +54,7 @@ const noteQuery = (slug: string) => 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({ @@ -61,6 +63,8 @@ export const route = defineRoute({ }) ``` +`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 From 20de8ea1953a3e7b99a6d497b3da6b5ee69bfb70 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 07:27:08 +0000 Subject: [PATCH 07/11] =?UTF-8?q?feat(rate-limiter):=20=E2=9C=A8=20per-mem?= =?UTF-8?q?ber=20read=20and=20write=20budgets,=20readable=20429s=20after?= =?UTF-8?q?=20preflights?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A signed-in member's read, write and content-write budgets are now keyed by their user ID once globalMiddleware has verified the session, so people behind one office IP no longer share them and a member cannot refresh them by switching networks. Guests and unverified cookies stay keyed by IP, and the safety cap and sign-in budget always are. rateLimiter.perUser: false restores IP-only keys. A preflight is still counted against the safety cap but never refused: the real request that follows gets the 429, which runs after cors() and so carries CORS headers. Retry-After and X-RateLimit-Category are exposed to cross-origin pages, merged into any custom cors.exposeHeaders. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F1sfMPmQoAgrC8t5g5Kk3n --- packages/vitnode/src/api/config.ts | 30 +- packages/vitnode/src/api/lib/auth-cookie.ts | 3 + .../src/api/middlewares/global.middleware.ts | 3 +- .../api/middlewares/rate-limiter-config.ts | 7 + .../rate-limiter.middleware.test.ts | 398 ++++++++++++++++-- .../middlewares/rate-limiter.middleware.ts | 213 ++++++++-- 6 files changed, 566 insertions(+), 88 deletions(-) diff --git a/packages/vitnode/src/api/config.ts b/packages/vitnode/src/api/config.ts index 241890fea4..cd1db4db1a 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"; @@ -37,7 +38,10 @@ import { globalAdminMiddleware, globalMiddleware, } from "./middlewares/global.middleware"; -import { rateLimiterMiddleware } from "./middlewares/rate-limiter.middleware"; +import { + exposeRateLimitHeaders, + rateLimiterMiddleware, +} from "./middlewares/rate-limiter.middleware"; import { registerCronJobs } from "./modules/cron/helpers/register-cron-jobs"; /** 25 MB: room for an image upload, and nothing like enough to be a weapon. */ @@ -131,10 +135,12 @@ export function VitNodeAPI({ } const webOrigin = CONFIG.web.origin; - const rateLimiter = rateLimiterMiddleware( - vitNodeApiConfig.rateLimiter, - redisClient, - ); + 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({ @@ -145,9 +151,16 @@ export function VitNodeAPI({ ); app.use("*", clientIpMiddleware); // `cors()` answers a preflight itself and never calls `next`, so `OPTIONS` - // spends its budget here or nowhere. + // spends its budget here or nowhere. Every 429 comes from a stage after + // `cors()`, so it carries CORS headers, and the exposed rate limit headers + // let a cross-origin page read when to retry. app.use("*", rateLimiter.preflight); - app.use(cors(corsOptions ?? { credentials: true, origin: webOrigin })); + app.use( + cors({ + ...corsConfig, + exposeHeaders: exposeRateLimitHeaders(corsConfig.exposeHeaders), + }), + ); app.use( csrf( csrfOptions ?? { @@ -194,6 +207,9 @@ export function VitNodeAPI({ cacheClient: redisClient, }), ); + // After the session lookup, so a member's reads and writes count against + // their own budget rather than their address's. + 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 7284ac4292..95dbc92cf5 100644 --- a/packages/vitnode/src/api/lib/auth-cookie.ts +++ b/packages/vitnode/src/api/lib/auth-cookie.ts @@ -3,6 +3,9 @@ import type { CookieOptions } from "hono/utils/cookie"; import { deleteCookie, setCookie } from "hono/cookie"; +/** The public session cookie, unless `authorization.cookieName` renames it. */ +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 22a2f10b64..95a3c372da 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 index a4da482ed2..ed58193a37 100644 --- a/packages/vitnode/src/api/middlewares/rate-limiter-config.ts +++ b/packages/vitnode/src/api/middlewares/rate-limiter-config.ts @@ -28,6 +28,13 @@ export interface RateLimiterConfig { execEvenly?: boolean; /** @deprecated Moved to `rateLimiter.safety.execEvenlyMinDelayMs`. */ execEvenlyMinDelayMs?: number; + /** + * Gives each signed-in member their own `read`, `write` and `contentWrite` + * budget, so people behind one office address don't share one. Guests and + * invalid sessions stay keyed by address, and `safety` and `auth` always + * are. `false` keys every budget by address. Defaults to `true`. + */ + perUser?: boolean; /** @deprecated Moved to `rateLimiter.safety.points`. */ points?: number; /** `GET` and `HEAD` requests. */ 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 f836e2be6b..dfe4817f48 100644 --- a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.test.ts +++ b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.test.ts @@ -1,3 +1,5 @@ +import type { ContextVariableMap } from "hono"; + import { Hono } from "hono"; import { cors } from "hono/cors"; import { RateLimiterMemory, RateLimiterRedis } from "rate-limiter-flexible"; @@ -15,6 +17,8 @@ import { } from "./rate-limiter-config"; import { createRateLimiter, + exposeRateLimitHeaders, + RATE_LIMIT_RESPONSE_HEADERS, rateLimiterCategoriesFor, rateLimiterMiddleware, rateLimitKey, @@ -25,11 +29,65 @@ interface Env { Variables: { ipAddress: string }; } +const SESSION_COOKIE = "vitnode_auth"; +const WEB_ORIGIN = "https://web.example"; + const METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]; // Budgets big enough to stay out of the way of the one a test exhausts. 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, +}); + +// Just enough of node-redis for `RateLimiterRedis`: its Lua script increments +// a key that expires with the window. +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 }; +}; + +/** + * The middleware order `VitNodeAPI` uses. A session cookie `member-` + * stands in for a valid session; any other value resolves to no user, the + * way `globalMiddleware` treats a forged or expired one. + */ const buildApp = ( config: RateLimiterConfig, { @@ -38,7 +96,11 @@ const buildApp = ( }: { ipAddress?: string; storeClient?: CacheClient } = {}, ) => { const app = new Hono().basePath("/api"); - const limiter = rateLimiterMiddleware(config, storeClient); + 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); @@ -46,20 +108,54 @@ const buildApp = ( return next(); }); app.use("*", limiter.preflight); - app.use("*", cors({ origin: "https://web.example" })); + app.use( + "*", + cors({ + credentials: true, + exposeHeaders: exposeRateLimitHeaders(), + origin: WEB_ORIGIN, + }), + ); app.use("*", limiter.requests); + app.use("*", async (c, next) => { + 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("*", limiter.identified); app.all("*", c => c.json({ ok: true })); - return app; + return Object.assign(app, { sessionLookups }); }; -const statusesOf = async ( - app: ReturnType, - requests: [method: string, path: string][], +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 [method, path] of requests) { - statuses.push((await app.request(path, { method })).status); + for (const request of requests) { + statuses.push((await visit(app, request)).status); } return statuses; @@ -144,24 +240,26 @@ describe("rateLimiterMiddleware", () => { }); }); - it("counts a CORS preflight once, against safety only", async () => { + 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: "https://web.example", + origin: WEB_ORIGIN, "access-control-request-method": "POST", }, }; - const first = await app.request("/api/topics", preflight); - const second = await app.request("/api/topics", preflight); - const third = await app.request("/api/topics", preflight); + 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([first.status, second.status, third.status]).toEqual([ - 204, 204, 429, - ]); - expect(third.headers.get("X-RateLimit-Category")).toBe("safety"); + 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); }); it("keeps different visitors on separate budgets", async () => { @@ -197,7 +295,10 @@ describe("rateLimiterMiddleware", () => { 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 } }); + const limiter = rateLimiterMiddleware( + { write: { points: 1 } }, + { sessionCookieName: SESSION_COOKIE }, + ); app.use("*", async (c, next) => { c.set("ipAddress", "198.51.100.77"); @@ -216,6 +317,7 @@ describe("rateLimiterMiddleware", () => { expect(line).toContain( JSON.stringify({ category: "write", + subject: "ip", method: "PATCH", path: "/api/@acme/forum/topics/:id", status: 429, @@ -314,7 +416,10 @@ describe("rateLimiterMiddleware identity", () => { vi.stubEnv("CRON_SECRET", ""); vi.stubEnv("VITNODE_TRUSTED_PROXY_HOPS", "0"); const app = new Hono(); - const limiter = rateLimiterMiddleware({ read: { points: 2 } }); + const limiter = rateLimiterMiddleware( + { read: { points: 2 } }, + { sessionCookieName: SESSION_COOKIE }, + ); app.use("*", clientIpMiddleware); app.use("*", limiter.requests); app.get("/", c => c.json({ ok: true })); @@ -334,31 +439,234 @@ describe("rateLimiterMiddleware identity", () => { }); }); -describe("rateLimiterMiddleware with Redis", () => { - // Just enough of node-redis for `RateLimiterRedis`: its Lua script increments - // a key that expires with the window. - 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]); - }, - }; +describe("rateLimiterMiddleware per member", () => { + const OFFICE = "198.51.100.10"; - return { counters, storeClient: client as unknown as CacheClient }; - }; + 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]); + }); +}); + +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, @@ -486,8 +794,11 @@ describe("resolveRateLimiterConfig", () => { 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); expect(resolved.write).toEqual({ points: 5, duration: 60 }); + expect(legacy.safety).toEqual({ points: 5, duration: 60 }); }); it("gives every category its own key prefix", () => { @@ -544,7 +855,8 @@ describe("rateLimitKey", () => { ["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) => { + ])("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 b5d1a114f8..22ecb98fdb 100644 --- a/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts +++ b/packages/vitnode/src/api/middlewares/rate-limiter.middleware.ts @@ -1,5 +1,6 @@ import type { Context, MiddlewareHandler } from "hono"; +import { getCookie } from "hono/cookie"; import { routePath } from "hono/route"; import { isIP } from "node:net"; import { @@ -115,6 +116,57 @@ export const rateLimiterCategoriesFor = ( return categories; }; +/** + * The headers a 429 carries. A cross-origin caller reads them only when CORS + * exposes them, so `VitNodeAPI` adds these to `exposeHeaders`. + */ +export const RATE_LIMIT_RESPONSE_HEADERS = [ + "Retry-After", + "X-RateLimit-Category", +] as const; + +/** + * An app's `exposeHeaders` with the rate limit headers added, keeping the + * app's own entries and spelling. Header names are case-insensitive. + */ +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()), + ), + ]; +}; + +// Spent per signed-in member when `perUser` is on. `safety` and `auth` always +// stay per address: sign-in is anonymous, and the address cap is the one +// protection that never depends on who the caller claims to be. +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", +}); + +// `rateLimitKey` never returns a string starting with `user:`, so a member's +// counters can share a limiter with addresses without colliding. +export const userRateLimitKey = (userId: number): string => `user:${userId}`; + const CATEGORY_HEADER_VALUES: Record = { safety: "safety", read: "read", @@ -171,18 +223,42 @@ interface CategoryLimiter { points: number; } +export interface RateLimiterMiddlewareOptions { + /** The public session cookie, to tell a member's request from a guest's. */ + sessionCookieName: string; + storeClient?: CacheClient | null; +} + /** - * Every limiter VitNode applies, built once. `preflight` runs before `cors()`, - * which answers an `OPTIONS` request without calling the next middleware, and - * spends only the `safety` budget. `requests` runs after it and skips - * `OPTIONS`, so each request is counted once and each limiter consumed at - * most once, stopping at the first that refuses. + * Every limiter VitNode applies, built once, in three stages. + * + * - `preflight` runs before `cors()`, which answers an `OPTIONS` request + * without calling the next middleware. It counts the preflight against + * `safety` but never refuses it: a refused preflight reaches the page as an + * opaque network error, while the real request that follows gets a 429 that + * carries CORS headers and a readable `Retry-After`. + * - `requests` runs after `cors()` and before the session is resolved. It + * spends `safety` and `auth` per address and, for a request with no session + * cookie, `read`, `write` and `contentWrite` per address too. + * - `identified` runs after `globalMiddleware`. A request with a session + * cookie spends `read`, `write` and `contentWrite` here, per member when the + * session is valid and per address when it is not. Such a request is + * counted after its session lookup, so the address `safety` cap is what + * bounds how many lookups one address can cause. + * + * Each limiter is consumed at most once per request, stopping at the first + * that refuses. */ export const rateLimiterMiddleware = ( config: RateLimiterConfig | undefined, - storeClient?: CacheClient | null, -): { preflight: MiddlewareHandler; requests: MiddlewareHandler } => { + { 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 @@ -197,7 +273,7 @@ export const rateLimiterMiddleware = ( await next(); }; - return { preflight: skip, requests: skip }; + return { preflight: skip, requests: skip, identified: skip }; } const store = storeClient ? "redis" : "memory"; @@ -219,9 +295,43 @@ export const rateLimiterMiddleware = ( contentWrite: build("contentWrite"), }; + const retryAfterOf = ( + { duration }: CategoryLimiter, + res: RateLimiterRes | undefined, + ) => Math.ceil((res?.msBeforeNext ?? duration * 1000) / 1000); + + // Only the first refusal in a window is logged, so a flood stays one line + // per visitor per window instead of one per request. `subject` says whether + // a member or an address ran out, never which one. + 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, - { category, duration, points }: CategoryLimiter, + limiter: CategoryLimiter, + subject: RateLimitSubject, rejection: unknown, ) => { // `rate-limiter-flexible` rejects with a `RateLimiterRes` carrying @@ -229,61 +339,90 @@ export const rateLimiterMiddleware = ( // 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 = rejection instanceof RateLimiterRes ? rejection : undefined; - const retryAfter = Math.ceil((res?.msBeforeNext ?? duration * 1000) / 1000); - const categoryHeader = CATEGORY_HEADER_VALUES[category]; - - // Only the first refusal in a window is logged, so a flood stays one line - // per visitor per window instead of one per request. - if (res?.consumedPoints === points + 1) { - // oxlint-disable-next-line no-console - console.warn( - `\x1b[34m[VitNode]\x1b[0m \x1b[33mRate limit exceeded\x1b[0m: ${JSON.stringify( - { - category: categoryHeader, - method: KNOWN_METHODS.has(c.req.method) ? c.req.method : "OTHER", - path: requestPathTemplate(c), - status: 429, - retryAfter, - store, - }, - )}`, - ); - } + const retryAfter = retryAfterOf(limiter, res); + const [retryAfterHeader, categoryHeader] = RATE_LIMIT_RESPONSE_HEADERS; + + logFirstRefusal(c, limiter, subject, res, 429); - c.header("Retry-After", `${retryAfter}`); - c.header("X-RateLimit-Category", categoryHeader); + 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[]) => { - const key = rateLimitKey(c.get("ipAddress")); + const consume = async ( + c: Context, + categories: RateLimiterCategory[], + identity: RateLimitIdentity, + ) => { + const ip = ipIdentity(c); 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], rejection); + return reject(c, limiters[category], subject, rejection); } } return undefined; }; + // The same test `SessionModel.getUser` makes before it looks anything up: + // without a non-empty cookie the request is a guest's, whatever it says. + const carriesSession = (c: Context): boolean => + perUser && !!getCookie(c, sessionCookieName); + return { preflight: async (c, next) => { if (c.req.method === "OPTIONS") { - const rejected = await consume(c, ["safety"]); - if (rejected) return rejected; + const limiter = limiters.safety; + + try { + await limiter.limiter.consume(ipIdentity(c).key); + } catch (rejection) { + logFirstRefusal( + c, + limiter, + "ip", + rejection instanceof RateLimiterRes ? rejection : undefined, + 204, + ); + } } 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), + 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; } From 982f2aede111297fbf8c4d4f7f4b845405264078 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 07:27:34 +0000 Subject: [PATCH 08/11] =?UTF-8?q?docs(rate-limiter):=20=F0=9F=93=9D=20docu?= =?UTF-8?q?ment=20per-member=20budgets,=20perUser=20and=20readable=20prefl?= =?UTF-8?q?ight=20429s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F1sfMPmQoAgrC8t5g5Kk3n --- .../dev/advanced/rate-limiter-debugging.mdx | 17 +++++++------ .../docs/dev/advanced/rate-limiter.mdx | 24 +++++++++++++++---- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/apps/web/content/docs/dev/advanced/rate-limiter-debugging.mdx b/apps/web/content/docs/dev/advanced/rate-limiter-debugging.mdx index 69010c4771..6940c2a27c 100644 --- a/apps/web/content/docs/dev/advanced/rate-limiter-debugging.mdx +++ b/apps/web/content/docs/dev/advanced/rate-limiter-debugging.mdx @@ -29,10 +29,10 @@ Open the browser's Network tab, repeat what the visitor did, and select the firs The API prints one line the first time a client goes over a budget in each window: ```txt -[VitNode] Rate limit exceeded: {"category":"read","method":"GET","path":"/api/@vitnode/forum/content/topics/:param","status":429,"retryAfter":24,"store":"redis"} +[VitNode] Rate limit exceeded: {"category":"read","subject":"user","method":"GET","path":"/api/@vitnode/forum/content/topics/:param","status":429,"retryAfter":24,"store":"redis"} ``` -`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 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. +`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. @@ -41,7 +41,7 @@ The API prints one line the first time a client goes over a budget in each windo - **`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, so its people share every budget. Raise the budget they reach, such as `rateLimiter.read.points`, rather than the safety cap. +- **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. @@ -52,10 +52,9 @@ The API prints one line the first time a client goes over a budget in each windo Repeat the visitor's steps with the Network tab open. You see no `429`, and the API prints no new `Rate limit exceeded` line. - - The rate limiter runs before the API reads the session, so it cannot know who - is signed in without a session lookup on every request, including the ones it - rejects. Keying by IP keeps a refused request cheap. Per-user budgets for - signed-in members could stack on top of the IP budgets later, but the IP-based - safety cap would stay. + + 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 181e15c886..2ab10d2fb4 100644 --- a/apps/web/content/docs/dev/advanced/rate-limiter.mdx +++ b/apps/web/content/docs/dev/advanced/rate-limiter.mdx @@ -6,13 +6,13 @@ icon: ShieldAlert import { TypeTable } from "fumadocs-ui/components/type-table" -VitNode limits API requests per client IP. Reads, writes, sign-in attempts and public content writes each get their own budget, and one global safety cap sits above them all. A request over any budget gets `429 Too Many Requests` with a `Retry-After` header. +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. ## How the budgets work Every request spends points from the budgets that apply to it, in this order, and stops at the first one that is used up: -| Budget | Spent by | Default per IP | Config key | +| 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` | @@ -29,6 +29,17 @@ The sign-in budget covers: - `POST /users/verify-email` and `POST /users/verify-email/resend` - `POST /users/passkeys/sign-in` and `POST /users/passkeys/admin-sign-in` +## 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. @@ -42,7 +53,7 @@ The router [preloads a page](/docs/dev/routing/preloading) when a visitor rests 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. +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. @@ -97,7 +108,7 @@ Content-Type: application/json } ``` -`X-RateLimit-Category` names the budget that refused the request: `safety`, `read`, `write`, `auth` or `content-write`. In the browser, VitNode shows one toast that uses `Retry-After` for its countdown, even when several requests fail at once. +`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. @@ -174,6 +185,11 @@ Every budget takes the same options: `points` (requests per window), `duration` description: "Public Content Engine creates and edits (POST and PATCH under /content/). Spent on top of write.", type: "RateLimiterCategoryOptions", }, + perUser: { + default: "true", + description: "Count read, write and content-write budgets per signed-in member instead of per IP address. The safety cap and sign-in budget are always per IP.", + type: "boolean", + }, points: { default: "-", description: "Deprecated. Configures the safety cap with the old defaults (80 per 60 s). Move it to safety.points.", From c1362ddb5698f3bd771bb19c544773ed42bf0913 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 07:47:04 +0000 Subject: [PATCH 09/11] =?UTF-8?q?feat(link):=20=E2=9C=A8=20add=20HrefLink?= =?UTF-8?q?=20and=20switch=20Forum=20and=20Example=20links=20to=20client?= =?UTF-8?q?=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin pages rendered internal navigation as plain , so the router never preloaded or client-navigated it and the Forum's query cache never paid off. HrefLink (@vitnode/core/tanstack/link) turns an href string with query and hash into a router Link, so plugins keep using strings without importing the host route tree; useHrefNavigate does the same from code. AdminLink now builds on it, which also fixes admin hrefs with a query. Creating a topic invalidates the forum queries and navigates client-side instead of reloading the page. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F1sfMPmQoAgrC8t5g5Kk3n --- .../src/tanstack/link/href-link.test.tsx | 127 ++++++++++++++++++ .../vitnode/src/tanstack/link/href-link.tsx | 114 ++++++++++++++++ packages/vitnode/src/tanstack/link/index.ts | 2 + .../src/views/admin/layouts/admin-link.tsx | 21 +-- plugins/example/src/pages/board-page.tsx | 31 +++-- plugins/example/src/pages/board-tree.tsx | 6 +- plugins/example/src/pages/boards-page.tsx | 12 +- .../src/pages/discussion-edit-page.tsx | 13 +- .../example/src/pages/discussion-new-page.tsx | 9 +- plugins/example/src/pages/discussion-page.tsx | 17 +-- plugins/forum/src/pages/forum-list.tsx | 9 +- plugins/forum/src/pages/forum-page.tsx | 34 +++-- plugins/forum/src/pages/forum-queries.ts | 2 +- plugins/forum/src/pages/forums-page.tsx | 12 +- plugins/forum/src/pages/pending-page.tsx | 21 +-- plugins/forum/src/pages/thread.tsx | 9 +- plugins/forum/src/pages/topic-edit-page.tsx | 17 +-- plugins/forum/src/pages/topic-new-page.tsx | 14 +- plugins/forum/src/pages/topic-page.tsx | 19 +-- 19 files changed, 379 insertions(+), 110 deletions(-) create mode 100644 packages/vitnode/src/tanstack/link/href-link.test.tsx create mode 100644 packages/vitnode/src/tanstack/link/href-link.tsx create mode 100644 packages/vitnode/src/tanstack/link/index.ts 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 0000000000..b9c3aa9c45 --- /dev/null +++ b/packages/vitnode/src/tanstack/link/href-link.test.tsx @@ -0,0 +1,127 @@ +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", + // Search values reach the URL as written - not JSON-quoted (`%22123%22`). + "/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" }); + + // `false`: the click was taken over, so the browser loads no document. + 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 0000000000..5f057dfae1 --- /dev/null +++ b/packages/vitnode/src/tanstack/link/href-link.tsx @@ -0,0 +1,114 @@ +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"; + +/** + * A base for `URL` to resolve an internal href against. Never requested; only + * `pathname`, `search` and `hash` are read back off it. + */ +const RELATIVE_BASE = "https://vitnode.invalid"; + +/** + * An internal href split into what a router navigation takes. The query goes + * through the router's own `parseSearch`, so `?forum=123` arrives as the number + * a full page load would give the route's validator, and is written back as + * `?forum=123` - not JSON-quoted, as a `"123"` string would be. + */ +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"> { + /** When the link counts as the current page. Defaults to this exact URL, query and hash included. */ + activeOptions?: ActiveOptions; + /** + * An internal path in the route tree's spelling - no locale prefix - with + * any query and hash (`/forum/news?cursor=abc#top`), or an absolute URL. + */ + href: string; + preload?: LinkProps["preload"]; + preloadDelay?: number; +} + +/** + * `aria-current="page"` only on a link to exactly this URL. The router's own + * default matches by prefix, which would mark "Back to forums" as the page + * you are on from anywhere under `/forum`. + */ +const EXACT_URL: ActiveOptions = { exact: true, includeHash: true }; + +const InternalHrefLink = ({ + activeOptions = EXACT_URL, + href, + ...props +}: HrefLinkProps) => { + const { parseSearch } = useRouter().options; + + return ( + + ); +}; + +/** + * A link built from an href string, for code that cannot name a typed route - + * a plugin page, or an href that arrives as data. + * + * An internal href becomes a router link: it client-navigates, preloads on + * intent, and the locale rewrite adds the language prefix. An external one is + * a plain ``. + */ +export const HrefLink = ({ + activeOptions, + preload, + preloadDelay, + ...props +}: HrefLinkProps) => + isExternalHref(props.href) ? ( + + ) : ( + + ); + +/** {@link HrefLink}'s counterpart for a navigation nobody clicked, such as after a form is saved. */ +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 0000000000..02903b54bf --- /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/views/admin/layouts/admin-link.tsx b/packages/vitnode/src/views/admin/layouts/admin-link.tsx index d049448e8b..eee3f04324 100644 --- a/packages/vitnode/src/views/admin/layouts/admin-link.tsx +++ b/packages/vitnode/src/views/admin/layouts/admin-link.tsx @@ -1,17 +1,10 @@ -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; -} +/** The router's prefix match, as the AdminCP navigation has always had it. */ +const PREFIX_MATCH = { exact: false }; -export const AdminLink = ({ href, ...props }: AdminLinkProps) => - isExternalHref(href) ? ( - - ) : ( - - ); +export const AdminLink = (props: AdminLinkProps) => ( + +); diff --git a/plugins/example/src/pages/board-page.tsx b/plugins/example/src/pages/board-page.tsx index 9dc0cee8aa..840008d639 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 ? (