From d5a9eb799c72c68e5ac05e1853eb16e03cb1779c Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 3 Sep 2026 20:54:49 +0200 Subject: [PATCH 1/7] refactor: Fetcher for tanstack start --- AGENTS.md | 11 +- apps/web/content/docs/dev/architecture.mdx | 46 +-- apps/web/content/docs/dev/data-loading.mdx | 4 + apps/web/content/docs/dev/fetcher.mdx | 271 ++++++++++++------ .../web/content/docs/dev/server-functions.mdx | 124 ++++---- .../vitnode/src/lib/fetcher-client.test.ts | 64 +++++ packages/vitnode/src/lib/fetcher-client.ts | 40 ++- .../src/tanstack/admin/content/form/server.ts | 40 --- .../tanstack/admin/content/form/transport.ts | 20 +- .../src/tanstack/admin/content/query.ts | 11 +- .../src/tanstack/admin/content/server.ts | 26 -- .../src/tanstack/admin/content/transport.ts | 6 + .../vitnode/src/tanstack/admin/cron/query.ts | 10 +- .../vitnode/src/tanstack/admin/cron/server.ts | 32 --- .../src/tanstack/admin/dashboard/query.tsx | 10 +- .../src/tanstack/admin/dashboard/server.ts | 17 -- .../vitnode/src/tanstack/admin/debug/query.ts | 16 +- .../src/tanstack/admin/debug/server.ts | 47 --- .../vitnode/src/tanstack/admin/files/query.ts | 11 +- .../src/tanstack/admin/files/server.ts | 32 --- .../src/tanstack/admin/integrations/query.ts | 11 +- .../src/tanstack/admin/integrations/server.ts | 22 -- .../vitnode/src/tanstack/admin/queue/query.ts | 11 +- .../src/tanstack/admin/queue/server.ts | 32 --- .../vitnode/src/tanstack/admin/roles/query.ts | 9 +- .../src/tanstack/admin/roles/server.ts | 30 -- .../src/tanstack/admin/search-index/query.ts | 11 +- .../src/tanstack/admin/search-index/server.ts | 22 -- .../vitnode/src/tanstack/admin/staff/query.ts | 26 +- .../src/tanstack/admin/staff/server.ts | 74 ----- .../vitnode/src/tanstack/admin/users/query.ts | 15 +- .../src/tanstack/admin/users/server.ts | 45 --- .../tanstack/auth/middleware-config-server.ts | 31 -- .../src/tanstack/auth/middleware-config.ts | 44 ++- .../vitnode/src/tanstack/devices/query.ts | 10 +- .../vitnode/src/tanstack/devices/server.ts | 18 -- .../src/tanstack/fetcher/fetcher.test-d.ts | 112 ++++++++ .../src/tanstack/fetcher/fetcher.test.ts | 260 +++++++++++++++++ .../vitnode/src/tanstack/fetcher/index.ts | 43 +++ packages/vitnode/src/tanstack/files/query.ts | 10 +- packages/vitnode/src/tanstack/files/server.ts | 25 -- .../vitnode/src/tanstack/search/feed.test.tsx | 158 ++++++++++ packages/vitnode/src/tanstack/search/feed.ts | 12 +- .../vitnode/src/tanstack/search/server.ts | 24 -- .../admin/views/content/content-request.ts | 56 ++-- .../admin/views/content/form/item-query.ts | 36 ++- .../admin/views/content/table/list-query.ts | 33 ++- .../views/core/advanced/cron/cron-query.ts | 44 +-- .../views/core/advanced/queue/queue-query.ts | 45 +-- .../advanced/search/search-index-query.ts | 12 +- .../core/dashboard/widgets/layout-query.ts | 12 +- .../admin/views/core/debug/debug-query.ts | 44 +-- .../admin/views/core/staff/staff-query.ts | 92 +++--- .../views/core/system/files/files-query.ts | 12 +- .../system/integrations/integrations-query.ts | 33 ++- .../views/core/users/detail/user-query.ts | 31 +- .../views/core/users/list/users-query.ts | 43 +-- .../views/core/users/roles/roles-query.ts | 10 +- .../auth/settings/devices/devices-query.ts | 24 +- .../vitnode/src/views/files/my-files-query.ts | 35 +-- .../src/views/search/search-feed-query.ts | 41 +-- 61 files changed, 1417 insertions(+), 1079 deletions(-) create mode 100644 packages/vitnode/src/lib/fetcher-client.test.ts delete mode 100644 packages/vitnode/src/tanstack/admin/content/form/server.ts delete mode 100644 packages/vitnode/src/tanstack/admin/content/server.ts create mode 100644 packages/vitnode/src/tanstack/admin/content/transport.ts delete mode 100644 packages/vitnode/src/tanstack/admin/cron/server.ts delete mode 100644 packages/vitnode/src/tanstack/admin/dashboard/server.ts delete mode 100644 packages/vitnode/src/tanstack/admin/debug/server.ts delete mode 100644 packages/vitnode/src/tanstack/admin/files/server.ts delete mode 100644 packages/vitnode/src/tanstack/admin/integrations/server.ts delete mode 100644 packages/vitnode/src/tanstack/admin/queue/server.ts delete mode 100644 packages/vitnode/src/tanstack/admin/roles/server.ts delete mode 100644 packages/vitnode/src/tanstack/admin/search-index/server.ts delete mode 100644 packages/vitnode/src/tanstack/admin/staff/server.ts delete mode 100644 packages/vitnode/src/tanstack/admin/users/server.ts delete mode 100644 packages/vitnode/src/tanstack/auth/middleware-config-server.ts delete mode 100644 packages/vitnode/src/tanstack/devices/server.ts create mode 100644 packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts create mode 100644 packages/vitnode/src/tanstack/fetcher/fetcher.test.ts create mode 100644 packages/vitnode/src/tanstack/fetcher/index.ts delete mode 100644 packages/vitnode/src/tanstack/files/server.ts create mode 100644 packages/vitnode/src/tanstack/search/feed.test.tsx delete mode 100644 packages/vitnode/src/tanstack/search/server.ts diff --git a/AGENTS.md b/AGENTS.md index cd2daaf02..3e620da89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,12 +32,15 @@ import { Activity } from "react"; ### Fetching APIs -- `fetcher(module, {...})` on the server (takes the real API module), `fetcherClient(clientModule(pluginId), {...})` in the browser. +- `fetcher` from `@vitnode/core/tanstack/fetcher` is universal - one call, SSR and browser. Never hand-write `createIsomorphicFn().server(...).client(...)` for a fetch. +- It takes a lightweight `clientModule(pluginId)` reference, which is safe in both runtimes; only the explicit server fetcher takes the real API module. +- `@vitnode/core/tanstack/fetcher/server` is for work that is genuinely server-only: server functions, `allowSaveCookies` cookie relay, cron/jobs, upstream secrets or a different `origin`. +- `@vitnode/core/lib/fetcher-client` stays the framework-neutral browser default. A shared `views/*` module takes its transport as a `UniversalFetcher` argument and defaults it to `fetcherClient`; the `tanstack/*` adapter binds the universal one. - Write the route inline at the call site - never build a request object elsewhere and pass it in. -- Never annotate the result; the fetcher infers it. Put the shared contract on the `createIsomorphicFn` result instead. +- Never annotate the result; the fetcher infers it. Put the shared contract on the feature's own `*Fetcher` type instead. - `args` is required exactly when the route declares a body, params or a query. -- `allowSaveCookies: true` when a route mints a session; `captchaToken` for captcha-gated routes. -- `rawFetcher` only for generated Content Engine modules, which have no type to infer from. +- `captchaToken` for captcha-gated routes. +- `rawFetcher` only for generated Content Engine modules, which have no type to infer from. It is universal too, with the same server-only twin. ### Caching APIs diff --git a/apps/web/content/docs/dev/architecture.mdx b/apps/web/content/docs/dev/architecture.mdx index 006b2db0f..c794b7eb1 100644 --- a/apps/web/content/docs/dev/architecture.mdx +++ b/apps/web/content/docs/dev/architecture.mdx @@ -5,6 +5,7 @@ icon: Blocks --- VitNode separates concerns between two core layers: + 1. **TanStack Start**: Frontend UI, SSR, isomorphic routing, and client caching. 2. **Hono API**: Backend routing, session management, staff permissions, and database operations. @@ -12,15 +13,17 @@ VitNode separates concerns between two core layers: ## System Boundaries -| Responsibility | TanStack Start (Web App) | Hono (API) | -| :--- | :--- | :--- | -| **Routing** | Page URLs, dynamic parameters, nested layouts | REST/RPC endpoints under `/api/*` | -| **Data Fetching** | Route loaders and `createIsomorphicFn` | Query execution via Drizzle ORM | -| **State & Cache** | TanStack Query client cache | Redis domain cache & database storage | +| Responsibility | TanStack Start (Web App) | Hono (API) | +| :-------------------- | :-------------------------------------------- | :-------------------------------------------------------------- | +| **Routing** | Page URLs, dynamic parameters, nested layouts | REST/RPC endpoints under `/api/*` | +| **Data Fetching** | Route loaders and the universal `fetcher` | Query execution via Drizzle ORM | +| **State & Cache** | TanStack Query client cache | Redis domain cache & database storage | | **Security Boundary** | UI guards (redirecting unauthenticated users) | **Enforces authentication, permissions, CSRF, and rate limits** | - Route guards (`beforeLoad`) enhance UX by redirecting visitors early, but the Hono API is the true security boundary. All private endpoints strictly verify cookies and permissions on every request. + Route guards (`beforeLoad`) enhance UX by redirecting visitors early, but the + Hono API is the true security boundary. All private endpoints strictly verify + cookies and permissions on every request. --- @@ -29,15 +32,15 @@ VitNode separates concerns between two core layers: When a user visits a page (e.g. `/blog`): -| Phase | Runtime | Action | -| :--- | :--- | :--- | -| **1. Request** | Browser | Visitor navigates to `/blog` | -| **2. Routing** | Server (SSR) / Browser | TanStack Router matches route and executes `loader` | -| **3. Query Warming** | Server / Browser | `context.queryClient.ensureQueryData` executes isomorphic fetcher | -| **4. RPC Call** | Server / Browser | `fetcher` (server) or `fetcherClient` (browser) calls Hono endpoint | -| **5. API Middleware** | Server (Hono) | Verifies session cookie, applies rate limits, injects `c.get(db)` | -| **6. Handler & Database** | Server (Hono) | Handler validates input and queries PostgreSQL via Drizzle | -| **7. Response** | Server / Browser | JSON data hydrates TanStack Query cache and paints component | +| Phase | Runtime | Action | +| :------------------------ | :--------------------- | :------------------------------------------------------------------ | +| **1. Request** | Browser | Visitor navigates to `/blog` | +| **2. Routing** | Server (SSR) / Browser | TanStack Router matches route and executes `loader` | +| **3. Query Warming** | Server / Browser | `context.queryClient.ensureQueryData` executes isomorphic fetcher | +| **4. RPC Call** | Server / Browser | `fetcher` (server) or `fetcherClient` (browser) calls Hono endpoint | +| **5. API Middleware** | Server (Hono) | Verifies session cookie, applies rate limits, injects `c.get(db)` | +| **6. Handler & Database** | Server (Hono) | Handler validates input and queries PostgreSQL via Drizzle | +| **7. Response** | Server / Browser | JSON data hydrates TanStack Query cache and paints component | --- @@ -47,12 +50,12 @@ Before route matching, every request passes through the middleware `createVitNodeStart` installs - in this order, and an app cannot get in front of any of it: -| Order | Middleware | Applies to | -| :--- | :--- | :--- | -| 1 | **CSRF** | Server function calls (`handlerType === 'serverFn'`) | -| 2 | **Locale** | Page requests: canonical `308` redirects and the locale cookie | -| 3 | **Document cache** | HTML responses: forced `Cache-Control: private, no-store` | -| 4 | Your own | Whatever `requestMiddleware` lists | +| Order | Middleware | Applies to | +| :---- | :----------------- | :------------------------------------------------------------- | +| 1 | **CSRF** | Server function calls (`handlerType === 'serverFn'`) | +| 2 | **Locale** | Page requests: canonical `308` redirects and the locale cookie | +| 3 | **Document cache** | HTML responses: forced `Cache-Control: private, no-store` | +| 4 | Your own | Whatever `requestMiddleware` lists | `/api/*` reaches the same middleware and passes through untouched - no redirect, no rewrite, no cache directive - so the Hono bridge sees the request exactly as @@ -64,6 +67,7 @@ the client sent it and keeps its own caching policy. See ## Plugin System Architecture VitNode is built around modular plugins located in `plugins/*`: + - **Independent Packages**: Plugins compile to their own `dist/` with isolated dependencies. - **Unified Manifest**: Routes, AdminCP navigation, and database models are registered declaratively. - **Zero Overhead**: Inactive plugins contribute no code or overhead to production bundles. diff --git a/apps/web/content/docs/dev/data-loading.mdx b/apps/web/content/docs/dev/data-loading.mdx index 2b2c7fe44..65992a1ce 100644 --- a/apps/web/content/docs/dev/data-loading.mdx +++ b/apps/web/content/docs/dev/data-loading.mdx @@ -7,6 +7,10 @@ icon: DownloadCloud VitNode loads feature data through plugin routes. `definePluginRoute({ load })` runs for SSR and client navigation, then hands typed data to the plugin page. +Fetch with the universal [`fetcher`](/docs/dev/fetcher): one call site serves +both, forwarding the visitor's request during SSR and calling `/api/*` directly +from the browser afterwards. + ## Quick start ### 1. In a Plugin Route (Recommended) diff --git a/apps/web/content/docs/dev/fetcher.mdx b/apps/web/content/docs/dev/fetcher.mdx index 5b12b49ee..7cf8b92b9 100644 --- a/apps/web/content/docs/dev/fetcher.mdx +++ b/apps/web/content/docs/dev/fetcher.mdx @@ -4,125 +4,227 @@ description: End-to-end type-safe RPC client for calling your Hono API from SSR icon: ArrowRightLeft --- -import { TypeTable } from 'fumadocs-ui/components/type-table' +VitNode's fetchers are type-safe RPC clients wired straight to your Hono API +modules: the route, the method, the arguments and the response body all come +from the module's own Zod schemas. -VitNode provides type-safe RPC fetchers directly linked to your Hono API modules: +There are three of them, and the first one is almost always the right answer. -- `fetcher` on the **server** (SSR / server functions) using the real API module. -- `fetcherClient` in the **browser** using a lightweight module type reference. +| Import | Use it for | +| :--------------------------------------------------- | :------------------------------------------------------------------------------ | +| `@vitnode/core/tanstack/fetcher` → `fetcher` | Normal TanStack Start reads and mutations. Works in SSR and browser navigation. | +| `@vitnode/core/tanstack/fetcher/server` → `fetcher` | Explicit server-only work: cookie relay, server functions, cron, upstream keys. | +| `@vitnode/core/lib/fetcher-client` → `fetcherClient` | Framework-neutral browser components, or a host that is not TanStack Start. | -## 1. Server-Side Fetching (`fetcher`) +## 1. The universal fetcher -Use `fetcher` in SSR renders and `.server()` branches of `createIsomorphicFn`: +One function, one call site, both runtimes: -```ts -import { usersModule } from '@vitnode/core/api/modules/users/users.module' -import { fetcher } from '@vitnode/core/tanstack/fetcher/server' +```ts title="plugins/devices/src/lib/devices-query.ts" +import type { usersModule } from '@vitnode/core/api/modules/users/users.module' -// [!code ++:8] -const response = await fetcher(usersModule, { - method: 'get', - module: 'users', - path: '/session', -}) +import { clientModule } from '@vitnode/core/lib/fetcher-client' +import { fetcher } from '@vitnode/core/tanstack/fetcher' + +// A lightweight typed reference: the module's *type* for inference, its +// plugin id at runtime. No API code reaches the browser bundle. +const users = clientModule('@vitnode/core') + +// [!code ++:9] +export const fetchDevices = async ({ + signal, +}: { signal?: AbortSignal } = {}) => { + const response = await fetcher(users, { + method: 'get', + module: 'users', + options: { signal }, + path: '/devices', + }) -if (response.ok) { - const session = await response.json() // Automatically typed from Zod schema + if (!response.ok) + throw new Error(`The devices API answered ${response.status}.`) + + return await response.json() // Typed from the route's Zod schema } ``` -`fetcher()` automatically forwards incoming cookies, user-agent, and client IP headers. +Drop it into a query definition and hand that one definition to both the route +loader and the component: ---- +```ts title="plugins/devices/src/lib/devices-query.ts" +import { queryOptions } from '@tanstack/react-query' -## 2. Browser-Side Fetching (`fetcherClient`) - -In client components and browser query functions, use `fetcherClient`: +export const devicesQueryOptions = () => + queryOptions({ + queryFn: async ({ signal }) => await fetchDevices({ signal }), + queryKey: ['devices'], + }) +``` -```ts -import { clientModule, fetcherClient } from '@vitnode/core/lib/fetcher-client' -import type { usersModule } from '@vitnode/core/api/modules/users/users.module' +```tsx title="plugins/devices/src/pages/devices-page.tsx" +import { definePluginRoute } from '@vitnode/core/routing' +import { devicesQueryOptions } from '../lib/devices-query' -// Create lightweight module reference (no runtime backend imports bundled) -const moduleRef = clientModule('@vitnode/core') - -// [!code ++:13] -const response = await fetcherClient(moduleRef, { - method: 'post', - module: 'users', - path: '/sign_in', - args: { - body: { - email: 'user@example.com', - password: 'password123', - }, - }, - allowSaveCookies: true, // Necessary when the route mints a session +export const route = definePluginRoute({ + // [!code ++] + load: async ({ context }) => + await context.queryClient.ensureQueryData(devicesQueryOptions()), }) ``` +### Which transport it picks, and when + +You never choose. TanStack Start's compiler resolves the branch per bundle: + +- **During SSR** the call uses the request-aware server transport. It forwards + the visitor's `Cookie`, `user-agent` and client IP, and resolves the API + origin from the request being served (or `NEXT_PUBLIC_API_URL` when a separate + API server is configured). +- **After hydration** the call is a plain same-origin `fetch` to `/api/…`, + straight from the browser to Hono. Cookies ride along through normal browser + behaviour. There is **no** server-function hop and no extra round trip. + +Because it is one function, the browser build never contains the server +transport - the compiler drops that branch, along with +`@tanstack/react-start/server-only` and everything behind it. + + + The universal `fetcher` deliberately does not accept `allowSaveCookies`, + `additionalHeaders` or `origin`. A browser cannot honour any of them, so + TypeScript rejects them here and points you at the server fetcher. + + --- -## 3. Use it on a plugin page +## 2. The server fetcher -Plugin route loaders run for SSR and client navigation. Wrap both transports -once, then call that function from the page that owns the feature: +`@vitnode/core/tanstack/fetcher/server` is the same client with the server-only +options still on it. Reach for it when the request itself is the point: -```ts title="plugins/devices/src/lib/fetch-devices.ts" -import { createIsomorphicFn } from '@tanstack/react-start' -import { clientModule, fetcherClient } from '@vitnode/core/lib/fetcher-client' -import { fetcher } from '@vitnode/core/tanstack/fetcher/server' -import type { usersModule } from '@vitnode/core/api/modules/users/users.module' +- a `createServerFn` in your host application; +- login, registration and other flows that must relay the API's `Set-Cookie` + onto the response (`allowSaveCookies: true`); +- cron jobs and queue workers, which serve no visitor request; +- calls that need a server secret or a different upstream `origin`. -const usersModuleRef = clientModule('@vitnode/core') +```ts title="apps/web/src/lib/auth.ts" +import { createServerFn } from '@tanstack/react-start' +import { usersModule } from '@vitnode/core/api/modules/users/users.module' +import { fetcher } from '@vitnode/core/tanstack/fetcher/server' -export const fetchDevices = createIsomorphicFn() - .server(async () => { +export const signIn = createServerFn({ method: 'POST' }) + .validator((body: { email: string; password: string }) => body) + .handler(async ({ data }) => { const response = await fetcher(usersModule, { - method: 'get', + allowSaveCookies: true, // [!code ++] + args: { body: data }, + method: 'post', module: 'users', - path: '/devices', + path: '/sign_in', }) - return await response.json() + + return { ok: response.ok } }) - .client(async () => { - const response = await fetcherClient(usersModuleRef, { - method: 'get', - module: 'users', - path: '/devices', - }) - return await response.json() +``` + +Note it takes the **real** API module (`usersModule`), not a `clientModule` +reference - it only ever runs on the server, so importing the module is free. + + + A module that imports the server fetcher must never be reachable from a client + component. Keep it in `*.server.ts`, or behind the universal `fetcher`. + + +--- + +## 3. The framework-neutral browser fetcher + +`fetcherClient` is the direct Hono transport. Use it in components and query +modules that must stay usable outside TanStack Start - the shared views inside +`@vitnode/core` do exactly that, and default to it. + +```ts title="plugins/blog/src/views/comments-query.ts" +import type { commentsModule } from '@vitnode/blog/api/modules/comments/comments.module' + +import { clientModule, fetcherClient } from '@vitnode/core/lib/fetcher-client' + +const comments = clientModule('@vitnode/blog') + +// [!code ++:8] +export const postComment = async (body: { text: string }) => + await fetcherClient(comments, { + args: { body }, + method: 'post', + module: 'comments', + path: '/', }) ``` -```tsx title="plugins/devices/src/pages/devices-page.tsx" -import { definePluginRoute } from '@vitnode/core/routing' -import { fetchDevices } from '../lib/fetch-devices' +It sends `credentials: "include"`, so the session cookie reaches the API even +when `NEXT_PUBLIC_API_URL` points at a separate host, and it routes a `429` to +the global rate-limit notice. -// [!code ++:3] -export const route = definePluginRoute({ - load: async () => await fetchDevices(), -}) + + A browser response's `Set-Cookie` is applied by the browser itself. There is + no `allowSaveCookies` on `fetcherClient` - that option exists only so a + *server* render can copy the API's cookies onto the response it is building. + + +### Taking the transport as an argument + +A shared query module that wants to serve both hosts should take the transport +in and keep `fetcherClient` as its default. `UniversalFetcher` is the signature +both fetchers satisfy: + +```ts +import type { UniversalFetcher } from '@vitnode/core/lib/fetcher-client' + +import { fetcherClient } from '@vitnode/core/lib/fetcher-client' + +export const commentsFetcher = + (transport: UniversalFetcher) => async (postId: number) => + await transport(comments, { + args: { params: { postId: String(postId) } }, + method: 'get', + module: 'comments', + path: '/{postId}', + }) + +/** The neutral default, for a non-TanStack host. */ +export const fetchCommentsInBrowser = commentsFetcher(fetcherClient) ``` -That is the page-level usage: the initial render uses `fetcher`; later -navigations use `fetcherClient`. Same result, no extra host route file. +A TanStack Start adapter then binds the universal one instead: + +```ts +import { fetcher } from '@vitnode/core/tanstack/fetcher' + +const fetchComments = commentsFetcher(fetcher) +``` --- ## Fetcher Options -| Option | Required When | Description | -| :----------------- | :-------------------------- | :--------------------------------------------------- | -| `module` | **Always** | Target module key declared on API | -| `method` | **Always** | HTTP method (`get`, `post`, `put`, `delete`) | -| `path` | **Always** | Route path pattern (e.g. `/`, `/:id`) | -| `args` | Body / params / query exist | Strongly typed payload: `{ body?, params?, query? }` | -| `allowSaveCookies` | Optional | Set `true` when logging in or saving session cookies | -| `captchaToken` | Optional | Challenge token for routes guarded by `withCaptcha` | - - - For generated Content Engine routes that have no static TypeScript module definitions, use `rawFetcher({ pluginId, path, method })`. +| Option | Required when | Description | +| :------------------ | :---------------------------- | :------------------------------------------------------------- | +| `module` | Always | Target module key declared on the API | +| `method` | Always | HTTP method (`get`, `post`, `put`, `delete`) | +| `path` | Always | Route path pattern, e.g. `/` or `/{id}` | +| `args` | The route declares one | Strongly typed payload: `{ body?, params?, query? }` | +| `options` | Optional | Extra `fetch` init - an `AbortSignal`, `credentials` | +| `prefixPath` | The module is not at the root | Mount prefix, e.g. `/admin` or `/users` | +| `captchaToken` | The route uses `withCaptcha` | Solved token; an empty string sends no header at all | +| `allowSaveCookies` | **Server fetcher only** | Copies the API's `Set-Cookie` onto the response being rendered | +| `additionalHeaders` | **Server fetcher only** | Extra request headers, on top of the forwarded ones | +| `origin` | **Server fetcher only** | Call a different upstream instead of the resolved API origin | + + + Generated Content Engine routes have no static TypeScript module to infer + from, so they use `rawFetcher` instead of `fetcher`. It is universal in the + same way - `@vitnode/core/tanstack/fetcher` exports one, and + `@vitnode/core/tanstack/fetcher/server` the explicit server-only one - and + takes `{ pluginId, module, path, method }` directly. ## Learn More @@ -130,9 +232,14 @@ navigations use `fetcherClient`. Same result, no extra host route file. + + A plugin package may declare `createIsomorphicFn`, but never `createServerFn`. Server functions belong exclusively to the host application. --- -## Isomorphic Data Fetching (`createIsomorphicFn`) +## Fetching does not need `createIsomorphicFn` -TanStack Router loaders run on the server during the initial paint, and on the client for subsequent navigations. `createIsomorphicFn` bridges both environments seamlessly: +TanStack Router loaders run on the server for the first paint and in the browser +for every navigation after it. That used to mean writing both halves by hand: -```ts title="plugins/devices/src/lib/fetcher.ts" -import { createIsomorphicFn } from '@tanstack/react-start' -import { clientModule, fetcherClient } from '@vitnode/core/lib/fetcher-client' -import { fetcher } from '@vitnode/core/tanstack/fetcher/server' +```ts +// [!code --:3] +export const fetchDevices = createIsomorphicFn() + .server(fetchDevicesOnServer) + .client(fetchDevicesInBrowser) +``` + +The universal `fetcher` already is that boundary, so a feature writes the fetch +once: + +```ts title="plugins/devices/src/lib/fetch-devices.ts" import type { usersModule } from '@vitnode/core/api/modules/users/users.module' -const moduleRef = clientModule('@vitnode/core') +import { clientModule } from '@vitnode/core/lib/fetcher-client' +import { fetcher } from '@vitnode/core/tanstack/fetcher' -export const fetchDevices = createIsomorphicFn() - // Server execution (SSR) - .server(async () => { - const res = await fetcher(usersModule, { - method: 'get', - module: 'users', - path: '/devices', - }) - return await res.json() - }) - // Client execution (SPA navigation) - .client(async () => { - const res = await fetcherClient(moduleRef, { - method: 'get', - module: 'users', - path: '/devices', - }) - return await res.json() +const users = clientModule('@vitnode/core') + +// [!code ++:8] +export const fetchDevices = async () => { + const response = await fetcher(users, { + method: 'get', + module: 'users', + path: '/devices', }) -``` -Consume `fetchDevices` from the plugin route that owns the devices screen: + return await response.json() +} +``` ```tsx title="plugins/devices/src/pages/devices-page.tsx" import { definePluginRoute } from '@vitnode/core/routing' @@ -65,35 +67,59 @@ export const route = definePluginRoute({ }) ``` +The initial render forwards the visitor's cookie, user-agent and IP through the +request-aware transport; later navigations are a direct same-origin `fetch` to +Hono. See [Fetcher](/docs/dev/fetcher) for the full contract. + +`createIsomorphicFn` is still the right tool when the _two implementations +genuinely differ_ - reading a cookie from the request on the server and from +`document.cookie` in the browser, for instance. It is no longer how you fetch. + --- -## When to use `createServerFn` (Host App Only) +## When to use `createServerFn` (host app only) -Use `createServerFn` only when your host application needs to modify response cookies directly: +Use `createServerFn` when your host application has to touch the response +itself - which in practice means cookies: -```ts title="apps/web/src/features/auth/server-fn.ts" +```ts title="apps/web/src/lib/auth.ts" import { createServerFn } from '@tanstack/react-start' -import { setCookie } from 'vinxi/http' +import { usersModule } from '@vitnode/core/api/modules/users/users.module' +import { fetcher } from '@vitnode/core/tanstack/fetcher/server' -export const setSessionTheme = createServerFn({ method: 'POST' }) - .validator((theme: string) => theme) +export const signIn = createServerFn({ method: 'POST' }) + .validator((body: { email: string; password: string }) => body) .handler(async ({ data }) => { - setCookie('theme', data, { path: '/', httpOnly: true }) - return { success: true } + const response = await fetcher(usersModule, { + // [!code ++] + allowSaveCookies: true, + args: { body: data }, + method: 'post', + module: 'users', + path: '/sign_in', + }) + + return { ok: response.ok } }) ``` +Two things make this the exception rather than the rule: + +- it needs `allowSaveCookies`, which only the **server** fetcher offers; +- it costs an extra hop - browser → server function → API. Reads should not pay + it, which is exactly why they use the universal `fetcher` instead. + ## Learn More + - diff --git a/packages/vitnode/src/lib/fetcher-client.test.ts b/packages/vitnode/src/lib/fetcher-client.test.ts new file mode 100644 index 000000000..9182b2d31 --- /dev/null +++ b/packages/vitnode/src/lib/fetcher-client.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { middlewareModule } from "@/api/modules/middleware/middleware.module"; + +import { clientModule, fetcherClient } from "./fetcher-client"; + +const middleware = clientModule("@vitnode/core"); + +const lastInit = (fetchMock: ReturnType): RequestInit => + fetchMock.mock.calls.at(-1)?.[1] as RequestInit; + +describe("fetcherClient", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_API_URL", "http://localhost:8000"); + fetchMock = vi.fn(async () => + Promise.resolve(new Response("{}", { status: 200 })), + ); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it("sends cookies by default, so a cross-origin API still knows who is asking", async () => { + await fetcherClient(middleware, { + method: "get", + module: "middleware", + path: "/", + }); + + expect(lastInit(fetchMock).credentials).toBe("include"); + }); + + it("lets a caller opt out", async () => { + await fetcherClient(middleware, { + method: "get", + module: "middleware", + options: { credentials: "omit" }, + path: "/", + }); + + expect(lastInit(fetchMock).credentials).toBe("omit"); + }); + + it("keeps the caller's other request options", async () => { + const controller = new AbortController(); + + await fetcherClient(middleware, { + method: "get", + module: "middleware", + options: { signal: controller.signal }, + path: "/", + }); + + const init = lastInit(fetchMock); + expect(init.credentials).toBe("include"); + expect(init.signal).toBe(controller.signal); + }); +}); diff --git a/packages/vitnode/src/lib/fetcher-client.ts b/packages/vitnode/src/lib/fetcher-client.ts index f32c05449..03825cf34 100644 --- a/packages/vitnode/src/lib/fetcher-client.ts +++ b/packages/vitnode/src/lib/fetcher-client.ts @@ -4,6 +4,7 @@ import type { } from "@/api/lib/module"; import type { Route } from "@/api/lib/route"; +import type { RawApiFetchArgs } from "./fetcher/raw"; import type { FetcherParams, FetcherRequestOptions, @@ -15,6 +16,7 @@ import type { import { coreFetcher } from "./fetcher/core"; import { isRateLimited, notifyRateLimited } from "./fetcher/rate-limit"; +import { rawApiFetch } from "./fetcher/raw"; import { CAPTCHA_TOKEN_HEADER } from "./fetcher/request-context"; export const clientModule = ( @@ -64,7 +66,7 @@ export async function fetcherClient< method, module, args, - options, + options: { credentials: "include", ...options }, withPagination, prefixPath, additionalHeaders, @@ -78,3 +80,39 @@ export async function fetcherClient< return response; } + +/** + * The one signature a fetch can have in **both** runtimes. + * + * The browser's, deliberately: it is the narrower of the two, so a call typed + * against it carries no `allowSaveCookies`, no forwarded headers and no origin + * override - none of which a browser can honour. `tanstack/fetcher`'s universal + * `fetcher` and this module's `fetcherClient` both satisfy it, which is what + * lets a feature take its transport as an argument. + */ +export type UniversalFetcher = typeof fetcherClient; + +/** {@link RawApiFetchArgs} minus the two fields only a server can act on. */ +export type UniversalRawFetchArgs = Omit< + RawApiFetchArgs, + "additionalHeaders" | "origin" +>; + +/** + * An untyped call from the browser - the Content Engine's generated modules. + * + * `credentials: "include"` for the reason `fetcherClient` has it: the API's + * origin may be a separate host, and a cross-origin `fetch` sends no cookie + * without it. + */ +export const rawFetcherClient = async ({ + options, + ...args +}: UniversalRawFetchArgs): Promise => + await rawApiFetch({ + ...args, + options: { credentials: "include", ...options }, + }); + +/** The {@link UniversalFetcher} of untyped calls. See {@link rawFetcherClient}. */ +export type UniversalRawFetcher = typeof rawFetcherClient; diff --git a/packages/vitnode/src/tanstack/admin/content/form/server.ts b/packages/vitnode/src/tanstack/admin/content/form/server.ts deleted file mode 100644 index 69e7c37f0..000000000 --- a/packages/vitnode/src/tanstack/admin/content/form/server.ts +++ /dev/null @@ -1,40 +0,0 @@ -import "@tanstack/react-start/server-only"; - -import type { TranslationRow } from "@/views/admin/views/content/content-mutation"; -import type { ContentItemRequest } from "@/views/admin/views/content/form/item-query"; - -import { readContentApiJson } from "@/views/admin/views/content/content-request"; -import { - describeContentItem, - zodContentItem, - zodContentTranslationList, -} from "@/views/admin/views/content/form/item-query"; - -import { contentApiFetchOnServer } from "../server"; - -export const fetchContentItemOnServer = async (request: ContentItemRequest) => - await readContentApiJson( - await contentApiFetchOnServer({ - method: "get", - path: `/${request.itemId}`, - target: request.target, - }), - { describe: describeContentItem(request), schema: zodContentItem }, - ); - -export const fetchContentTranslationsOnServer = async ( - request: ContentItemRequest, -) => - ( - await readContentApiJson( - await contentApiFetchOnServer({ - method: "get", - path: `/${request.itemId}/translations`, - target: request.target, - }), - { - describe: `${describeContentItem(request)} translations`, - schema: zodContentTranslationList, - }, - ) - ).edges as unknown as TranslationRow[]; diff --git a/packages/vitnode/src/tanstack/admin/content/form/transport.ts b/packages/vitnode/src/tanstack/admin/content/form/transport.ts index aa5dd7edb..5115c7433 100644 --- a/packages/vitnode/src/tanstack/admin/content/form/transport.ts +++ b/packages/vitnode/src/tanstack/admin/content/form/transport.ts @@ -1,7 +1,5 @@ import type { QueryClient } from "@tanstack/react-query"; -import { createIsomorphicFn } from "@tanstack/react-start"; - import type { ContentApiTarget } from "@/views/admin/views/content/content-request"; import type { ContentItemFetcher, @@ -11,8 +9,8 @@ import type { ContentFormTransport } from "@/views/admin/views/content/form/tran import { contentFrontendRegistry } from "@/content/index"; import { - fetchContentItemInBrowser, - fetchContentTranslationsInBrowser, + contentItemFetcher, + contentTranslationsFetcher, } from "@/views/admin/views/content/form/item-query"; import { createContentInBrowser, @@ -26,19 +24,13 @@ import { } from "@/views/admin/views/content/form/mutations-api"; import { contentApiTarget, invalidateContentAfterWrite } from "../query"; -import { - fetchContentItemOnServer, - fetchContentTranslationsOnServer, -} from "./server"; +import { contentApiFetch } from "../transport"; -const fetchContentItem: ContentItemFetcher = createIsomorphicFn() - .server(fetchContentItemOnServer) - .client(fetchContentItemInBrowser); +const fetchContentItem: ContentItemFetcher = + contentItemFetcher(contentApiFetch); const fetchContentTranslations: ContentTranslationsFetcher = - createIsomorphicFn() - .server(fetchContentTranslationsOnServer) - .client(fetchContentTranslationsInBrowser); + contentTranslationsFetcher(contentApiFetch); export { fetchContentItem, fetchContentTranslations }; diff --git a/packages/vitnode/src/tanstack/admin/content/query.ts b/packages/vitnode/src/tanstack/admin/content/query.ts index 2b672a247..ba26b087d 100644 --- a/packages/vitnode/src/tanstack/admin/content/query.ts +++ b/packages/vitnode/src/tanstack/admin/content/query.ts @@ -1,7 +1,5 @@ import type { QueryClient } from "@tanstack/react-query"; -import { createIsomorphicFn } from "@tanstack/react-start"; - import type { ContentPublicationAction } from "@/content/publication"; import type { AnyContentTypeDefinition } from "@/content/types"; import type { ContentApiTarget } from "@/views/admin/views/content/content-request"; @@ -25,18 +23,17 @@ import { setContentPublicationInBrowser, } from "@/views/admin/views/content/table/list-mutations"; import { + contentListPageFetcher, contentListQueryOptions, - fetchContentListPageInBrowser, } from "@/views/admin/views/content/table/list-query"; import type { ContentListParams } from "./route-search"; import { contentListQuery } from "./route-search"; -import { fetchContentListPageOnServer } from "./server"; +import { contentApiFetch } from "./transport"; -const fetchContentListPage: ContentListPageFetcher = createIsomorphicFn() - .server(fetchContentListPageOnServer) - .client(fetchContentListPageInBrowser); +const fetchContentListPage: ContentListPageFetcher = + contentListPageFetcher(contentApiFetch); /** Which generated module serves one content type's admin routes. */ export const contentApiTarget = ( diff --git a/packages/vitnode/src/tanstack/admin/content/server.ts b/packages/vitnode/src/tanstack/admin/content/server.ts deleted file mode 100644 index 64ce2eda4..000000000 --- a/packages/vitnode/src/tanstack/admin/content/server.ts +++ /dev/null @@ -1,26 +0,0 @@ -import "@tanstack/react-start/server-only"; - -import type { ContentApiRequest } from "@/views/admin/views/content/content-request"; -import type { ContentListRequest } from "@/views/admin/views/content/table/list-query"; - -import { contentApiFetchArgs } from "@/views/admin/views/content/content-request"; -import { readContentApiJson } from "@/views/admin/views/content/content-request"; -import { - contentListApiRequest, - describeContentList, - zodContentListPage, -} from "@/views/admin/views/content/table/list-query"; - -import { rawFetcher } from "../../fetcher/server"; - -export const contentApiFetchOnServer = async ( - request: ContentApiRequest, -): Promise => await rawFetcher(contentApiFetchArgs(request)); - -export const fetchContentListPageOnServer = async ( - request: ContentListRequest, -) => - await readContentApiJson( - await contentApiFetchOnServer(contentListApiRequest(request)), - { describe: describeContentList(request), schema: zodContentListPage }, - ); diff --git a/packages/vitnode/src/tanstack/admin/content/transport.ts b/packages/vitnode/src/tanstack/admin/content/transport.ts new file mode 100644 index 000000000..a5e9d2b84 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/content/transport.ts @@ -0,0 +1,6 @@ +import type { ContentApiFetch } from "@/views/admin/views/content/content-request"; + +import { rawFetcher } from "@/tanstack/fetcher"; +import { contentApiFetcher } from "@/views/admin/views/content/content-request"; + +export const contentApiFetch: ContentApiFetch = contentApiFetcher(rawFetcher); diff --git a/packages/vitnode/src/tanstack/admin/cron/query.ts b/packages/vitnode/src/tanstack/admin/cron/query.ts index b46767731..f127cae77 100644 --- a/packages/vitnode/src/tanstack/admin/cron/query.ts +++ b/packages/vitnode/src/tanstack/admin/cron/query.ts @@ -1,7 +1,6 @@ import type { QueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query"; -import { createIsomorphicFn } from "@tanstack/react-start"; import React from "react"; import type { @@ -13,18 +12,15 @@ import type { RunCronResult, } from "@/views/admin/views/core/advanced/cron/run-action/run-cron"; +import { fetcher } from "@/tanstack/fetcher"; import { + cronPageFetcher, cronQueryOptions, cronQueryRoot, - fetchCronPageInBrowser, } from "@/views/admin/views/core/advanced/cron/cron-query"; import { runCronInBrowser } from "@/views/admin/views/core/advanced/cron/run-action/run-cron"; -import { fetchCronPageOnServer } from "./server"; - -const fetchCronPage: CronPageFetcher = createIsomorphicFn() - .server(fetchCronPageOnServer) - .client(fetchCronPageInBrowser); +const fetchCronPage: CronPageFetcher = cronPageFetcher(fetcher); export const cronQuery = ({ params }: { params: CronParams }) => cronQueryOptions({ fetchPage: fetchCronPage, params }); diff --git a/packages/vitnode/src/tanstack/admin/cron/server.ts b/packages/vitnode/src/tanstack/admin/cron/server.ts deleted file mode 100644 index f637a876c..000000000 --- a/packages/vitnode/src/tanstack/admin/cron/server.ts +++ /dev/null @@ -1,32 +0,0 @@ -import "@tanstack/react-start/server-only"; - -import type { CronParams } from "@/views/admin/views/core/advanced/cron/cron-query"; - -import { cronAdminModule } from "@/api/modules/admin/advanced/cron/cron.admin.module"; -import { - AdminRequestError, - describeAdminParams, -} from "@/views/admin/admin-request"; -import { CRON_PREFIX_PATH } from "@/views/admin/views/core/advanced/cron/cron-query"; - -import { fetcher } from "../../fetcher/server"; - -export const fetchCronPageOnServer = async (params: CronParams) => { - const response = await fetcher(cronAdminModule, { - args: { query: params }, - method: "get", - module: "cron", - path: "/", - prefixPath: CRON_PREFIX_PATH, - }); - - if (!response.ok) { - throw new AdminRequestError( - response.status, - "the cron list", - describeAdminParams(params), - ); - } - - return await response.json(); -}; diff --git a/packages/vitnode/src/tanstack/admin/dashboard/query.tsx b/packages/vitnode/src/tanstack/admin/dashboard/query.tsx index 301cd03cd..5483409c3 100644 --- a/packages/vitnode/src/tanstack/admin/dashboard/query.tsx +++ b/packages/vitnode/src/tanstack/admin/dashboard/query.tsx @@ -3,18 +3,18 @@ import type { QueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query"; -import { createIsomorphicFn } from "@tanstack/react-start"; import React from "react"; import type { DashboardActions } from "@/views/admin/views/core/dashboard/widgets/dashboard-actions"; import type { DashboardLayoutFetcher } from "@/views/admin/views/core/dashboard/widgets/layout-query"; import type { ResolvedDashboardWidget } from "@/views/admin/views/core/dashboard/widgets/types"; +import { fetcher } from "@/tanstack/fetcher"; import { widgetIdOf } from "@/views/admin/views/core/dashboard/widgets/instance-id"; import { + dashboardLayoutFetcher, dashboardLayoutQueryKey, dashboardLayoutQueryOptions, - fetchDashboardLayoutInBrowser, saveDashboardLayoutInBrowser, } from "@/views/admin/views/core/dashboard/widgets/layout-query"; import { saveWidgetSettingsInBrowser } from "@/views/admin/views/core/dashboard/widgets/widget-mutations"; @@ -22,11 +22,9 @@ import { saveWidgetSettingsInBrowser } from "@/views/admin/views/core/dashboard/ import type { AdminIdentity } from "../identity"; import { useAdminIdentity } from "../identity"; -import { fetchDashboardLayoutOnServer } from "./server"; -const fetchDashboardLayout: DashboardLayoutFetcher = createIsomorphicFn() - .server(fetchDashboardLayoutOnServer) - .client(fetchDashboardLayoutInBrowser); +const fetchDashboardLayout: DashboardLayoutFetcher = + dashboardLayoutFetcher(fetcher); export const dashboardLayoutQuery = (adminUserId: AdminIdentity) => dashboardLayoutQueryOptions({ diff --git a/packages/vitnode/src/tanstack/admin/dashboard/server.ts b/packages/vitnode/src/tanstack/admin/dashboard/server.ts deleted file mode 100644 index 3806b090d..000000000 --- a/packages/vitnode/src/tanstack/admin/dashboard/server.ts +++ /dev/null @@ -1,17 +0,0 @@ -import "@tanstack/react-start/server-only"; - -import { adminModule } from "@/api/modules/admin/admin.module"; - -import { fetcher } from "../../fetcher/server"; - -export const fetchDashboardLayoutOnServer = async () => { - const response = await fetcher(adminModule, { - method: "get", - module: "admin/dashboard", - path: "/", - }); - - if (!response.ok) return []; - - return (await response.json()).widgets; -}; diff --git a/packages/vitnode/src/tanstack/admin/debug/query.ts b/packages/vitnode/src/tanstack/admin/debug/query.ts index ac84c7177..af86d8826 100644 --- a/packages/vitnode/src/tanstack/admin/debug/query.ts +++ b/packages/vitnode/src/tanstack/admin/debug/query.ts @@ -3,7 +3,6 @@ import type { AnyRouter } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import { useRouter } from "@tanstack/react-router"; -import { createIsomorphicFn } from "@tanstack/react-start"; import React from "react"; import type { @@ -12,22 +11,17 @@ import type { DebugQueueFetcher, } from "@/views/admin/views/core/debug/debug-query"; +import { fetcher } from "@/tanstack/fetcher"; import { + debugLogsPageFetcher, debugLogsQueryOptions, + debugQueueFetcher, debugQueueQueryOptions, - fetchDebugLogsPageInBrowser, - fetchDebugQueueInBrowser, } from "@/views/admin/views/core/debug/debug-query"; -import { fetchDebugLogsPageOnServer, fetchDebugQueueOnServer } from "./server"; +const fetchDebugLogsPage: DebugLogsPageFetcher = debugLogsPageFetcher(fetcher); -const fetchDebugLogsPage: DebugLogsPageFetcher = createIsomorphicFn() - .server(fetchDebugLogsPageOnServer) - .client(fetchDebugLogsPageInBrowser); - -const fetchDebugQueue: DebugQueueFetcher = createIsomorphicFn() - .server(fetchDebugQueueOnServer) - .client(fetchDebugQueueInBrowser); +const fetchDebugQueue: DebugQueueFetcher = debugQueueFetcher(fetcher); export const debugLogsQuery = ({ params }: { params: DebugLogsParams }) => debugLogsQueryOptions({ fetchPage: fetchDebugLogsPage, params }); diff --git a/packages/vitnode/src/tanstack/admin/debug/server.ts b/packages/vitnode/src/tanstack/admin/debug/server.ts deleted file mode 100644 index 528d89abd..000000000 --- a/packages/vitnode/src/tanstack/admin/debug/server.ts +++ /dev/null @@ -1,47 +0,0 @@ -import "@tanstack/react-start/server-only"; - -import type { DebugLogsParams } from "@/views/admin/views/core/debug/debug-query"; - -import { debugAdminModule } from "@/api/modules/admin/debug/debug.admin.module"; -import { - AdminRequestError, - describeAdminParams, -} from "@/views/admin/admin-request"; -import { DEBUG_PREFIX_PATH } from "@/views/admin/views/core/debug/debug-query"; - -import { fetcher } from "../../fetcher/server"; - -export const fetchDebugLogsPageOnServer = async (params: DebugLogsParams) => { - const response = await fetcher(debugAdminModule, { - args: { query: params }, - method: "get", - module: "debug", - path: "/logs", - prefixPath: DEBUG_PREFIX_PATH, - }); - - if (!response.ok) { - throw new AdminRequestError( - response.status, - "the system log", - describeAdminParams(params), - ); - } - - return await response.json(); -}; - -export const fetchDebugQueueOnServer = async () => { - const response = await fetcher(debugAdminModule, { - method: "get", - module: "debug", - path: "/queue", - prefixPath: DEBUG_PREFIX_PATH, - }); - - if (!response.ok) { - throw new AdminRequestError(response.status, "the queue snapshot"); - } - - return await response.json(); -}; diff --git a/packages/vitnode/src/tanstack/admin/files/query.ts b/packages/vitnode/src/tanstack/admin/files/query.ts index 07c13f5b5..da47d9f35 100644 --- a/packages/vitnode/src/tanstack/admin/files/query.ts +++ b/packages/vitnode/src/tanstack/admin/files/query.ts @@ -1,7 +1,6 @@ import type { QueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query"; -import { createIsomorphicFn } from "@tanstack/react-start"; import React from "react"; import type { BulkDeleteFilesResult } from "@/lib/files/bulk-delete"; @@ -18,21 +17,19 @@ import type { } from "@/views/admin/views/core/system/files/files-query"; import { shouldRefreshAfterBulkDelete } from "@/lib/files/bulk-delete"; +import { fetcher } from "@/tanstack/fetcher"; import { deleteAdminFileInBrowser, deleteAdminFilesInBrowser, } from "@/views/admin/views/core/system/files/files-delete"; import { + adminFilesPageFetcher, adminFilesQueryOptions, adminFilesQueryRoot, - fetchAdminFilesPageInBrowser, } from "@/views/admin/views/core/system/files/files-query"; -import { fetchAdminFilesPageOnServer } from "./server"; - -const fetchAdminFilesPage: AdminFilesPageFetcher = createIsomorphicFn() - .server(fetchAdminFilesPageOnServer) - .client(fetchAdminFilesPageInBrowser); +const fetchAdminFilesPage: AdminFilesPageFetcher = + adminFilesPageFetcher(fetcher); export const adminFilesQuery = ({ params }: { params: AdminFilesParams }) => adminFilesQueryOptions({ fetchPage: fetchAdminFilesPage, params }); diff --git a/packages/vitnode/src/tanstack/admin/files/server.ts b/packages/vitnode/src/tanstack/admin/files/server.ts deleted file mode 100644 index 5a6b8e295..000000000 --- a/packages/vitnode/src/tanstack/admin/files/server.ts +++ /dev/null @@ -1,32 +0,0 @@ -import "@tanstack/react-start/server-only"; - -import type { AdminFilesParams } from "@/views/admin/views/core/system/files/files-query"; - -import { filesAdminModule } from "@/api/modules/admin/files/files.admin.module"; -import { - AdminRequestError, - describeAdminParams, -} from "@/views/admin/admin-request"; -import { ADMIN_FILES_PREFIX_PATH } from "@/views/admin/views/core/system/files/files-query"; - -import { fetcher } from "../../fetcher/server"; - -export const fetchAdminFilesPageOnServer = async (params: AdminFilesParams) => { - const response = await fetcher(filesAdminModule, { - args: { query: params }, - method: "get", - module: "files", - path: "/", - prefixPath: ADMIN_FILES_PREFIX_PATH, - }); - - if (!response.ok) { - throw new AdminRequestError( - response.status, - "the uploaded files list", - describeAdminParams(params), - ); - } - - return await response.json(); -}; diff --git a/packages/vitnode/src/tanstack/admin/integrations/query.ts b/packages/vitnode/src/tanstack/admin/integrations/query.ts index 66e7cefc8..cb1cf89dd 100644 --- a/packages/vitnode/src/tanstack/admin/integrations/query.ts +++ b/packages/vitnode/src/tanstack/admin/integrations/query.ts @@ -1,20 +1,15 @@ import type { QueryClient } from "@tanstack/react-query"; -import { createIsomorphicFn } from "@tanstack/react-start"; - import type { IntegrationsFetcher } from "@/views/admin/views/core/system/integrations/integrations-query"; +import { fetcher } from "@/tanstack/fetcher"; import { - fetchIntegrationsInBrowser, + integrationsFetcher, integrationsQueryKey, integrationsQueryOptions, } from "@/views/admin/views/core/system/integrations/integrations-query"; -import { fetchIntegrationsOnServer } from "./server"; - -const fetchIntegrations: IntegrationsFetcher = createIsomorphicFn() - .server(fetchIntegrationsOnServer) - .client(fetchIntegrationsInBrowser); +const fetchIntegrations: IntegrationsFetcher = integrationsFetcher(fetcher); /** The board, as the one query definition the loader and the component share. */ export const integrationsQuery = () => diff --git a/packages/vitnode/src/tanstack/admin/integrations/server.ts b/packages/vitnode/src/tanstack/admin/integrations/server.ts deleted file mode 100644 index 5c2b7850d..000000000 --- a/packages/vitnode/src/tanstack/admin/integrations/server.ts +++ /dev/null @@ -1,22 +0,0 @@ -import "@tanstack/react-start/server-only"; - -import { debugAdminModule } from "@/api/modules/admin/debug/debug.admin.module"; -import { AdminRequestError } from "@/views/admin/admin-request"; -import { ADMIN_DEBUG_PREFIX_PATH } from "@/views/admin/views/core/system/integrations/integrations-query"; - -import { fetcher } from "../../fetcher/server"; - -export const fetchIntegrationsOnServer = async () => { - const response = await fetcher(debugAdminModule, { - method: "get", - module: "debug", - path: "/integrations", - prefixPath: ADMIN_DEBUG_PREFIX_PATH, - }); - - if (!response.ok) { - throw new AdminRequestError(response.status, "the integrations board"); - } - - return await response.json(); -}; diff --git a/packages/vitnode/src/tanstack/admin/queue/query.ts b/packages/vitnode/src/tanstack/admin/queue/query.ts index 483dee5a8..680c13746 100644 --- a/packages/vitnode/src/tanstack/admin/queue/query.ts +++ b/packages/vitnode/src/tanstack/admin/queue/query.ts @@ -1,20 +1,15 @@ -import { createIsomorphicFn } from "@tanstack/react-start"; - import type { QueuePageFetcher, QueueParams, } from "@/views/admin/views/core/advanced/queue/queue-query"; +import { fetcher } from "@/tanstack/fetcher"; import { - fetchQueuePageInBrowser, + queuePageFetcher, queueQueryOptions, } from "@/views/admin/views/core/advanced/queue/queue-query"; -import { fetchQueuePageOnServer } from "./server"; - -const fetchQueuePage: QueuePageFetcher = createIsomorphicFn() - .server(fetchQueuePageOnServer) - .client(fetchQueuePageInBrowser); +const fetchQueuePage: QueuePageFetcher = queuePageFetcher(fetcher); export const queueQuery = ({ params }: { params: QueueParams }) => queueQueryOptions({ fetchPage: fetchQueuePage, params }); diff --git a/packages/vitnode/src/tanstack/admin/queue/server.ts b/packages/vitnode/src/tanstack/admin/queue/server.ts deleted file mode 100644 index 8ab34a4b9..000000000 --- a/packages/vitnode/src/tanstack/admin/queue/server.ts +++ /dev/null @@ -1,32 +0,0 @@ -import "@tanstack/react-start/server-only"; - -import type { QueueParams } from "@/views/admin/views/core/advanced/queue/queue-query"; - -import { queueAdminModule } from "@/api/modules/admin/advanced/queue/queue.admin.module"; -import { - AdminRequestError, - describeAdminParams, -} from "@/views/admin/admin-request"; -import { QUEUE_PREFIX_PATH } from "@/views/admin/views/core/advanced/queue/queue-query"; - -import { fetcher } from "../../fetcher/server"; - -export const fetchQueuePageOnServer = async (params: QueueParams) => { - const response = await fetcher(queueAdminModule, { - args: { query: params }, - method: "get", - module: "queue", - path: "/", - prefixPath: QUEUE_PREFIX_PATH, - }); - - if (!response.ok) { - throw new AdminRequestError( - response.status, - "the queue list", - describeAdminParams(params), - ); - } - - return await response.json(); -}; diff --git a/packages/vitnode/src/tanstack/admin/roles/query.ts b/packages/vitnode/src/tanstack/admin/roles/query.ts index e140d1234..b2f171999 100644 --- a/packages/vitnode/src/tanstack/admin/roles/query.ts +++ b/packages/vitnode/src/tanstack/admin/roles/query.ts @@ -1,7 +1,6 @@ import type { QueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query"; -import { createIsomorphicFn } from "@tanstack/react-start"; import React from "react"; import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; @@ -12,30 +11,28 @@ import type { } from "@/views/admin/views/core/users/roles/roles-query"; import type { RolesAdminTableProps } from "@/views/admin/views/core/users/roles/roles-table-content"; +import { fetcher } from "@/tanstack/fetcher"; import { createAdminRole, deleteAdminRole, updateAdminRole, } from "@/views/admin/views/core/users/roles/roles-mutations"; import { + adminRolesPageFetcher, adminRolesQueryOptions, adminRolesQueryRoot, - fetchAdminRolesPageInBrowser, } from "@/views/admin/views/core/users/roles/roles-query"; import { useAdminIdentity } from "../identity"; import { invalidateAdminSession } from "../session-query"; import { invalidateAdminUsers } from "../users/query"; -import { fetchAdminRolesPageOnServer } from "./server"; /** * The AdminCP roles screen for a TanStack Start host: one query definition and * three mutations. */ -const fetchRolesPage: AdminRolesPageFetcher = createIsomorphicFn() - .server(fetchAdminRolesPageOnServer) - .client(fetchAdminRolesPageInBrowser); +const fetchRolesPage: AdminRolesPageFetcher = adminRolesPageFetcher(fetcher); export const adminRolesQuery = ({ adminUserId, diff --git a/packages/vitnode/src/tanstack/admin/roles/server.ts b/packages/vitnode/src/tanstack/admin/roles/server.ts deleted file mode 100644 index aaf623de2..000000000 --- a/packages/vitnode/src/tanstack/admin/roles/server.ts +++ /dev/null @@ -1,30 +0,0 @@ -import "@tanstack/react-start/server-only"; - -import type { AdminRolesParams } from "@/views/admin/views/core/users/roles/roles-query"; - -import { adminModule } from "@/api/modules/admin/admin.module"; -import { - AdminRequestError, - describeAdminParams, -} from "@/views/admin/admin-request"; - -import { fetcher } from "../../fetcher/server"; - -export const fetchAdminRolesPageOnServer = async (params: AdminRolesParams) => { - const response = await fetcher(adminModule, { - args: { query: params }, - method: "get", - module: "admin/roles", - path: "/list", - }); - - if (!response.ok) { - throw new AdminRequestError( - response.status, - "the roles list", - describeAdminParams(params), - ); - } - - return await response.json(); -}; diff --git a/packages/vitnode/src/tanstack/admin/search-index/query.ts b/packages/vitnode/src/tanstack/admin/search-index/query.ts index e53b80a82..b2b4580f1 100644 --- a/packages/vitnode/src/tanstack/admin/search-index/query.ts +++ b/packages/vitnode/src/tanstack/admin/search-index/query.ts @@ -1,7 +1,6 @@ import type { QueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query"; -import { createIsomorphicFn } from "@tanstack/react-start"; import React from "react"; import type { @@ -10,21 +9,19 @@ import type { } from "@/views/admin/views/core/advanced/search/search-index-mutations"; import type { SearchIndexStatusFetcher } from "@/views/admin/views/core/advanced/search/search-index-query"; +import { fetcher } from "@/tanstack/fetcher"; import { clearSearchCollectionInBrowser, rebuildSearchIndexInBrowser, } from "@/views/admin/views/core/advanced/search/search-index-mutations"; import { - fetchSearchIndexStatusInBrowser, searchIndexQueryKey, searchIndexQueryOptions, + searchIndexStatusFetcher, } from "@/views/admin/views/core/advanced/search/search-index-query"; -import { fetchSearchIndexStatusOnServer } from "./server"; - -const fetchSearchIndexStatus: SearchIndexStatusFetcher = createIsomorphicFn() - .server(fetchSearchIndexStatusOnServer) - .client(fetchSearchIndexStatusInBrowser); +const fetchSearchIndexStatus: SearchIndexStatusFetcher = + searchIndexStatusFetcher(fetcher); /** The status, as the one query definition the loader and the component share. */ export const searchIndexQuery = () => diff --git a/packages/vitnode/src/tanstack/admin/search-index/server.ts b/packages/vitnode/src/tanstack/admin/search-index/server.ts deleted file mode 100644 index 6e315a4a9..000000000 --- a/packages/vitnode/src/tanstack/admin/search-index/server.ts +++ /dev/null @@ -1,22 +0,0 @@ -import "@tanstack/react-start/server-only"; - -import { debugAdminModule } from "@/api/modules/admin/debug/debug.admin.module"; -import { AdminRequestError } from "@/views/admin/admin-request"; -import { ADMIN_DEBUG_PREFIX_PATH } from "@/views/admin/views/core/system/integrations/integrations-query"; - -import { fetcher } from "../../fetcher/server"; - -export const fetchSearchIndexStatusOnServer = async () => { - const response = await fetcher(debugAdminModule, { - method: "get", - module: "debug", - path: "/search/status", - prefixPath: ADMIN_DEBUG_PREFIX_PATH, - }); - - if (!response.ok) { - throw new AdminRequestError(response.status, "the search index status"); - } - - return await response.json(); -}; diff --git a/packages/vitnode/src/tanstack/admin/staff/query.ts b/packages/vitnode/src/tanstack/admin/staff/query.ts index 8ad9da30c..b76c3bcea 100644 --- a/packages/vitnode/src/tanstack/admin/staff/query.ts +++ b/packages/vitnode/src/tanstack/admin/staff/query.ts @@ -1,7 +1,6 @@ import type { QueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query"; -import { createIsomorphicFn } from "@tanstack/react-start"; import React from "react"; import type { PermissionStaffType } from "@/api/lib/permission-staff"; @@ -16,45 +15,36 @@ import type { } from "@/views/admin/views/core/staff/staff-query"; import type { StaffTableProps } from "@/views/admin/views/core/staff/table/staff-table-content"; +import { fetcher } from "@/tanstack/fetcher"; import { createStaffEntry, deleteStaffEntry, updateStaffPermissions, } from "@/views/admin/views/core/staff/staff-mutations"; import { + adminStaffCatalogFetcher, adminStaffCatalogQueryOptions, + adminStaffEntryFetcher, adminStaffEntryQueryOptions, + adminStaffPageFetcher, adminStaffQueryOptions, adminStaffQueryRoot, - fetchAdminStaffCatalogInBrowser, - fetchAdminStaffEntryInBrowser, - fetchAdminStaffPageInBrowser, } from "@/views/admin/views/core/staff/staff-query"; import { useAdminIdentity } from "../identity"; import { invalidateAdminSession } from "../session-query"; -import { - fetchAdminStaffCatalogOnServer, - fetchAdminStaffEntryOnServer, - fetchAdminStaffPageOnServer, -} from "./server"; /** * The AdminCP staff screens for a TanStack Start host: three query definitions * and three mutations. */ -const fetchStaffPage: AdminStaffPageFetcher = createIsomorphicFn() - .server(fetchAdminStaffPageOnServer) - .client(fetchAdminStaffPageInBrowser); +const fetchStaffPage: AdminStaffPageFetcher = adminStaffPageFetcher(fetcher); -const fetchCatalog: AdminStaffCatalogFetcher = createIsomorphicFn() - .server(fetchAdminStaffCatalogOnServer) - .client(fetchAdminStaffCatalogInBrowser); +const fetchCatalog: AdminStaffCatalogFetcher = + adminStaffCatalogFetcher(fetcher); -const fetchEntry: AdminStaffEntryFetcher = createIsomorphicFn() - .server(fetchAdminStaffEntryOnServer) - .client(fetchAdminStaffEntryInBrowser); +const fetchEntry: AdminStaffEntryFetcher = adminStaffEntryFetcher(fetcher); export const adminStaffQuery = ({ adminUserId, diff --git a/packages/vitnode/src/tanstack/admin/staff/server.ts b/packages/vitnode/src/tanstack/admin/staff/server.ts deleted file mode 100644 index 6a2f847b9..000000000 --- a/packages/vitnode/src/tanstack/admin/staff/server.ts +++ /dev/null @@ -1,74 +0,0 @@ -import "@tanstack/react-start/server-only"; - -import type { PermissionStaffType } from "@/api/lib/permission-staff"; -import type { AdminStaffParams } from "@/views/admin/views/core/staff/staff-query"; - -import { adminModule } from "@/api/modules/admin/admin.module"; -import { - AdminRequestError, - describeAdminParams, -} from "@/views/admin/admin-request"; -import { STAFF_TYPE_SEGMENT } from "@/views/admin/views/core/staff/staff-model"; - -import { fetcher } from "../../fetcher/server"; - -export const fetchAdminStaffPageOnServer = async ( - type: PermissionStaffType, - params: AdminStaffParams, -) => { - const response = await fetcher(adminModule, { - args: { query: params }, - method: "get", - module: "admin/staff", - path: type === "admin" ? "/admins" : "/moderators", - }); - - if (!response.ok) { - throw new AdminRequestError( - response.status, - `the ${STAFF_TYPE_SEGMENT[type]} staff list`, - describeAdminParams(params), - ); - } - - return await response.json(); -}; - -export const fetchAdminStaffCatalogOnServer = async () => { - const response = await fetcher(adminModule, { - method: "get", - module: "admin/staff", - path: "/permission-catalog", - }); - - if (!response.ok) { - throw new AdminRequestError( - response.status, - "the staff permission catalog", - ); - } - - return await response.json(); -}; - -export const fetchAdminStaffEntryOnServer = async ( - type: PermissionStaffType, - id: string, -) => { - const response = await fetcher(adminModule, { - args: { params: { id, type } }, - method: "get", - module: "admin/staff", - path: "/entry/{type}/{id}", - }); - - if (!response.ok) { - throw new AdminRequestError( - response.status, - "a staff entry", - `type=${type}, id=${id}`, - ); - } - - return await response.json(); -}; diff --git a/packages/vitnode/src/tanstack/admin/users/query.ts b/packages/vitnode/src/tanstack/admin/users/query.ts index cac51a893..1872aed2a 100644 --- a/packages/vitnode/src/tanstack/admin/users/query.ts +++ b/packages/vitnode/src/tanstack/admin/users/query.ts @@ -1,7 +1,6 @@ import type { QueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query"; -import { createIsomorphicFn } from "@tanstack/react-start"; import React from "react"; import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; @@ -17,14 +16,15 @@ import type { } from "@/views/admin/views/core/users/list/users-query"; import type { VerifyAdminUserEmail } from "@/views/admin/views/core/users/list/users-table-content"; +import { fetcher } from "@/tanstack/fetcher"; import { + adminUserFetcher, adminUserQueryOptions, - fetchAdminUserInBrowser, } from "@/views/admin/views/core/users/detail/user-query"; import { + adminUsersPageFetcher, adminUsersQueryOptions, adminUsersQueryRoot, - fetchAdminUsersPageInBrowser, } from "@/views/admin/views/core/users/list/users-query"; import { updateAdminUser, @@ -34,15 +34,10 @@ import { import { useAdminIdentity } from "../identity"; import { invalidateAdminSession } from "../session-query"; -import { fetchAdminUserOnServer, fetchAdminUsersPageOnServer } from "./server"; -const fetchUsersPage: AdminUsersPageFetcher = createIsomorphicFn() - .server(fetchAdminUsersPageOnServer) - .client(fetchAdminUsersPageInBrowser); +const fetchUsersPage: AdminUsersPageFetcher = adminUsersPageFetcher(fetcher); -const fetchUser: AdminUserFetcher = createIsomorphicFn() - .server(fetchAdminUserOnServer) - .client(fetchAdminUserInBrowser); +const fetchUser: AdminUserFetcher = adminUserFetcher(fetcher); export const adminUsersQuery = ({ adminUserId, diff --git a/packages/vitnode/src/tanstack/admin/users/server.ts b/packages/vitnode/src/tanstack/admin/users/server.ts deleted file mode 100644 index f544810a1..000000000 --- a/packages/vitnode/src/tanstack/admin/users/server.ts +++ /dev/null @@ -1,45 +0,0 @@ -import "@tanstack/react-start/server-only"; - -import type { AdminUsersParams } from "@/views/admin/views/core/users/list/users-query"; - -import { adminModule } from "@/api/modules/admin/admin.module"; -import { - AdminRequestError, - describeAdminParams, -} from "@/views/admin/admin-request"; - -import { fetcher } from "../../fetcher/server"; - -export const fetchAdminUsersPageOnServer = async (params: AdminUsersParams) => { - const response = await fetcher(adminModule, { - args: { query: params }, - method: "get", - module: "admin/users", - path: "/list", - }); - - if (!response.ok) { - throw new AdminRequestError( - response.status, - "the users list", - describeAdminParams(params), - ); - } - - return await response.json(); -}; - -export const fetchAdminUserOnServer = async (id: string) => { - const response = await fetcher(adminModule, { - args: { params: { id } }, - method: "get", - module: "admin/users", - path: "/{id}", - }); - - if (!response.ok) { - throw new AdminRequestError(response.status, "a user", `id=${id}`); - } - - return await response.json(); -}; diff --git a/packages/vitnode/src/tanstack/auth/middleware-config-server.ts b/packages/vitnode/src/tanstack/auth/middleware-config-server.ts deleted file mode 100644 index 4c3c2ea2c..000000000 --- a/packages/vitnode/src/tanstack/auth/middleware-config-server.ts +++ /dev/null @@ -1,31 +0,0 @@ -import "@tanstack/react-start/server-only"; - -import { middlewareModule } from "@/api/modules/middleware/middleware.module"; -import { fetcher } from "@/tanstack/fetcher/server"; - -import type { MiddlewareConfigState } from "./middleware-config"; - -import { - knownMiddlewareConfig, - UNKNOWN_MIDDLEWARE_CONFIG, -} from "./middleware-config"; - -export const fetchMiddlewareConfigOnServer = - async (): Promise => { - try { - const response = await fetcher(middlewareModule, { - method: "get", - module: "middleware", - path: "/", - }); - - if (response.status !== 200) return UNKNOWN_MIDDLEWARE_CONFIG; - - return knownMiddlewareConfig(await response.json()); - } catch (error) { - // eslint-disable-next-line no-console - console.error("[auth] middleware configuration unavailable", error); - - return UNKNOWN_MIDDLEWARE_CONFIG; - } - }; diff --git a/packages/vitnode/src/tanstack/auth/middleware-config.ts b/packages/vitnode/src/tanstack/auth/middleware-config.ts index 0304eb997..3f0debdd5 100644 --- a/packages/vitnode/src/tanstack/auth/middleware-config.ts +++ b/packages/vitnode/src/tanstack/auth/middleware-config.ts @@ -1,17 +1,15 @@ import type { z } from "zod"; import { queryOptions } from "@tanstack/react-query"; -import { createIsomorphicFn } from "@tanstack/react-start"; import type { middlewareModule } from "@/api/modules/middleware/middleware.module"; import type { routeMiddlewareSchema } from "@/api/modules/middleware/route"; import type { SSOProvider } from "@/views/auth/sso/providers"; -import { clientModule, fetcherClient } from "@/lib/fetcher-client"; +import { clientModule } from "@/lib/fetcher-client"; +import { fetcher } from "@/tanstack/fetcher"; import { normalizeSSOProviders } from "@/views/auth/sso/providers"; -import { fetchMiddlewareConfigOnServer } from "./middleware-config-server"; - export type MiddlewareConfig = z.infer; export interface MiddlewareConfigState extends MiddlewareConfig { @@ -31,26 +29,24 @@ export const knownMiddlewareConfig = ( const middleware = clientModule("@vitnode/core"); -const fetchMiddlewareConfigInBrowser = - async (): Promise => { - try { - const response = await fetcherClient(middleware, { - method: "get", - module: "middleware", - path: "/", - }); - - if (response.status !== 200) return UNKNOWN_MIDDLEWARE_CONFIG; - - return knownMiddlewareConfig(await response.json()); - } catch { - return UNKNOWN_MIDDLEWARE_CONFIG; - } - }; - -const fetchMiddlewareConfig = createIsomorphicFn() - .server(fetchMiddlewareConfigOnServer) - .client(fetchMiddlewareConfigInBrowser); +const fetchMiddlewareConfig = async (): Promise => { + try { + const response = await fetcher(middleware, { + method: "get", + module: "middleware", + path: "/", + }); + + if (response.status !== 200) return UNKNOWN_MIDDLEWARE_CONFIG; + + return knownMiddlewareConfig(await response.json()); + } catch (error) { + // eslint-disable-next-line no-console + console.error("[auth] middleware configuration unavailable", error); + + return UNKNOWN_MIDDLEWARE_CONFIG; + } +}; /** Everything a middleware-configuration cache entry's key starts with. */ const MIDDLEWARE_QUERY_KEY = ["vitnode", "middleware"] as const; diff --git a/packages/vitnode/src/tanstack/devices/query.ts b/packages/vitnode/src/tanstack/devices/query.ts index 9f7ac9bc3..98bc7c497 100644 --- a/packages/vitnode/src/tanstack/devices/query.ts +++ b/packages/vitnode/src/tanstack/devices/query.ts @@ -1,7 +1,6 @@ import type { QueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query"; -import { createIsomorphicFn } from "@tanstack/react-start"; import React from "react"; import type { DevicesFetcher } from "@/views/auth/settings/devices/devices-query"; @@ -11,21 +10,18 @@ import type { RevokeDeviceResult, } from "@/views/auth/settings/devices/devices-revoke"; +import { fetcher } from "@/tanstack/fetcher"; import { + devicesFetcher, devicesQueryKey, devicesQueryOptions, - fetchDevicesInBrowser, } from "@/views/auth/settings/devices/devices-query"; import { revokeDeviceInBrowser, shouldRefreshAfterRevoke, } from "@/views/auth/settings/devices/devices-revoke"; -import { fetchDevicesOnServer } from "./server"; - -const fetchDevices: DevicesFetcher = createIsomorphicFn() - .server(fetchDevicesOnServer) - .client(fetchDevicesInBrowser); +const fetchDevices: DevicesFetcher = devicesFetcher(fetcher); export const devicesQuery = (userId: number) => devicesQueryOptions({ fetchDevices, userId }); diff --git a/packages/vitnode/src/tanstack/devices/server.ts b/packages/vitnode/src/tanstack/devices/server.ts deleted file mode 100644 index ea8596cfa..000000000 --- a/packages/vitnode/src/tanstack/devices/server.ts +++ /dev/null @@ -1,18 +0,0 @@ -import "@tanstack/react-start/server-only"; - -import { usersModule } from "@/api/modules/users/users.module"; -import { DevicesRequestError } from "@/views/auth/settings/devices/devices-query"; - -import { fetcher } from "../fetcher/server"; - -export const fetchDevicesOnServer = async () => { - const response = await fetcher(usersModule, { - method: "get", - module: "users", - path: "/devices", - }); - - if (!response.ok) throw new DevicesRequestError(response.status); - - return await response.json(); -}; diff --git a/packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts b/packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts new file mode 100644 index 000000000..7af88a545 --- /dev/null +++ b/packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts @@ -0,0 +1,112 @@ +import { describe, expectTypeOf, it } from "vitest"; + +import type { usersModule } from "@/api/modules/users/users.module"; + +import { clientModule } from "@/lib/fetcher-client"; + +import { fetcher } from "./index"; + +const users = clientModule("@vitnode/core"); + +describe("the universal fetcher keeps the route in the type", () => { + it("infers the status and the body from the route's schema", async () => { + const response = await fetcher(users, { + method: "get", + module: "users", + path: "/session", + }); + + expectTypeOf(response.status).toEqualTypeOf<200>(); + expectTypeOf((await response.json()).user).not.toBeAny(); + }); + + it("rejects a path the module does not serve", async () => { + await fetcher(users, { + method: "get", + module: "users", + // @ts-expect-error -- not a route on `users` + path: "/not-a-route", + }); + }); + + it("rejects a method the route does not answer", async () => { + await fetcher(users, { + // @ts-expect-error -- `/session` is a `get` + method: "post", + module: "users", + path: "/session", + }); + }); + + it("rejects a call that omits a required body", async () => { + // @ts-expect-error -- `/sign_in` declares a body, so `args` is required + await fetcher(users, { + method: "post", + module: "users", + path: "/sign_in", + }); + }); + + it("rejects a body the route does not declare", async () => { + await fetcher(users, { + // @ts-expect-error -- `nickname` is not on the sign-in schema + args: { body: { email: "a@b.c", nickname: "x", password: "y" } }, + method: "post", + module: "users", + path: "/sign_in", + }); + }); + + it("rejects a call that omits a required path parameter", async () => { + // @ts-expect-error -- `/devices/{publicId}` declares params + await fetcher(users, { + method: "delete", + module: "users", + path: "/devices/{publicId}", + }); + }); +}); + +describe("the universal fetcher offers only what both runtimes can honour", () => { + it("rejects the cookie relay, which is the server transport's", async () => { + await fetcher(users, { + // @ts-expect-error -- `allowSaveCookies` is on `tanstack/fetcher/server` + allowSaveCookies: true, + args: { body: { email: "a@b.c", password: "secret" } }, + method: "post", + module: "users", + path: "/sign_in", + }); + }); + + it("rejects forwarded headers", async () => { + await fetcher(users, { + // @ts-expect-error -- a browser cannot forge request headers + additionalHeaders: { Cookie: "vitnode_auth=stolen" }, + method: "get", + module: "users", + path: "/session", + }); + }); + + it("rejects an origin override", async () => { + await fetcher(users, { + method: "get", + module: "users", + // @ts-expect-error -- the browser calls its own origin + origin: "https://api.example.com", + path: "/session", + }); + }); + + it("still accepts the options both runtimes share", async () => { + const controller = new AbortController(); + + await fetcher(users, { + method: "get", + module: "users", + options: { signal: controller.signal }, + path: "/session", + }); + }); +}); diff --git a/packages/vitnode/src/tanstack/fetcher/fetcher.test.ts b/packages/vitnode/src/tanstack/fetcher/fetcher.test.ts new file mode 100644 index 000000000..977c3cf59 --- /dev/null +++ b/packages/vitnode/src/tanstack/fetcher/fetcher.test.ts @@ -0,0 +1,260 @@ +// @vitest-environment node +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { usersModule } from "@/api/modules/users/users.module"; + +import { offenders, stripComments } from "@/tests/import-graph"; + +const requestHeaders = new Headers(); +let requestUrl = "https://preview.example.com/discover"; + +vi.mock("@tanstack/react-start/server-only", () => ({})); +vi.mock("@tanstack/react-start/server", () => ({ + getRequestHeaders: () => requestHeaders, + getRequestIP: () => "203.0.113.9", + getRequestUrl: () => new URL(requestUrl), + setCookie: vi.fn(), +})); + +const { createIsomorphicFn } = await import("@tanstack/react-start"); +const { clientModule, fetcherClient, rawFetcherClient } = + await import("@/lib/fetcher-client"); +const { fetcher, rawFetcher } = await import("./index"); + +const users = clientModule("@vitnode/core"); + +const apiFetch = vi.fn<(url: string | URL, init?: RequestInit) => Response>(); + +const callAt = (index: number) => { + const [url, init] = apiFetch.mock.calls[index] ?? []; + + return { + headers: new Headers(init?.headers), + init, + url: new URL(String(url)), + }; +}; + +const lastCall = () => callAt(apiFetch.mock.calls.length - 1); + +beforeEach(() => { + apiFetch.mockReset(); + apiFetch.mockReturnValue(new Response("{}", { status: 200 })); + vi.stubGlobal("fetch", apiFetch); + vi.stubEnv("NEXT_PUBLIC_API_URL", undefined); + requestUrl = "https://preview.example.com/discover"; + requestHeaders.set("cookie", "vitnode_auth=abc"); + requestHeaders.set("user-agent", "Mozilla/5.0"); + requestHeaders.set("x-forwarded-for", "198.51.100.7, 10.0.0.1"); +}); + +describe("which branch runs", () => { + it("resolves to the server branch wherever the Start compiler has not run", () => { + // Node, and therefore this suite. `createIsomorphicFn` keeps the server + // implementation as the callable and the compiler swaps in the client one + // per environment - so a server assertion below is a real assertion, and a + // browser one has to be made against the client transport itself. + const probe = createIsomorphicFn() + .server(() => "server") + .client(() => "client"); + + expect(probe()).toBe("server"); + }); +}); + +describe("the server branch is the request-aware transport", () => { + it("forwards the visitor's cookie, user agent and forwarded-for chain", async () => { + await fetcher(users, { method: "get", module: "users", path: "/session" }); + + const { headers } = lastCall(); + + expect(headers.get("Cookie")).toBe("vitnode_auth=abc"); + expect(headers.get("user-agent")).toBe("Mozilla/5.0"); + expect(headers.get("x-forwarded-for")).toBe("198.51.100.7, 10.0.0.1"); + }); + + it("resolves the API origin from the request it is serving", async () => { + await fetcher(users, { method: "get", module: "users", path: "/session" }); + + expect(lastCall().url.origin).toBe("https://preview.example.com"); + expect(lastCall().url.pathname).toBe("/api/@vitnode/core/users/session"); + }); + + it("calls a separately configured API server instead of itself", async () => { + vi.stubEnv("NEXT_PUBLIC_API_URL", "http://localhost:8000"); + + await fetcher(users, { method: "get", module: "users", path: "/session" }); + + expect(lastCall().url.origin).toBe("http://localhost:8000"); + }); + + it("mints no cookie of its own, because the universal call cannot ask for one", async () => { + const { setCookie } = await import("@tanstack/react-start/server"); + + await fetcher(users, { + args: { body: { email: "a@b.c", password: "secret" } }, + method: "post", + module: "users", + path: "/sign_in", + }); + + expect(setCookie).not.toHaveBeenCalled(); + }); + + it("carries the same request context for an untyped content call", async () => { + await rawFetcher({ + method: "get", + module: "content/articles", + path: "/", + pluginId: "@vitnode/blog", + prefixPath: "/admin", + }); + + const { headers, url } = lastCall(); + + expect(url.origin).toBe("https://preview.example.com"); + expect(url.pathname).toBe("/api/@vitnode/blog/admin/content/articles"); + expect(headers.get("Cookie")).toBe("vitnode_auth=abc"); + }); +}); + +describe("the browser branch talks to the Hono API directly", () => { + beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_API_URL", "http://localhost:8000"); + }); + + it("makes one request, to /api/*, with the browser's own cookies", async () => { + await fetcherClient(users, { + method: "get", + module: "users", + path: "/session", + }); + + expect(apiFetch).toHaveBeenCalledTimes(1); + expect(callAt(0).url.href).toBe( + "http://localhost:8000/api/@vitnode/core/users/session", + ); + expect(callAt(0).init?.credentials).toBe("include"); + }); + + it("routes nothing through a server-function endpoint", async () => { + await fetcherClient(users, { + args: { body: { email: "a@b.c", password: "secret" } }, + method: "post", + module: "users", + path: "/sign_in", + }); + + expect(apiFetch).toHaveBeenCalledTimes(1); + expect(callAt(0).url.pathname).toBe("/api/@vitnode/core/users/sign_in"); + expect(callAt(0).url.pathname).not.toContain("_serverFn"); + }); + + it("forges none of the headers only a server may send", async () => { + await fetcherClient(users, { + method: "get", + module: "users", + path: "/session", + }); + + const { headers } = callAt(0); + + expect(headers.has("Cookie")).toBe(false); + expect(headers.has("x-forwarded-for")).toBe(false); + }); + + it("takes an untyped content call to /api/* too", async () => { + await rawFetcherClient({ + method: "get", + module: "content/articles", + path: "/", + pluginId: "@vitnode/blog", + prefixPath: "/admin", + }); + + expect(callAt(0).url.pathname).toBe( + "/api/@vitnode/blog/admin/content/articles", + ); + expect(callAt(0).init?.credentials).toBe("include"); + }); +}); + +const here = dirname(fileURLToPath(import.meta.url)); +const UNIVERSAL_ENTRY = join(here, "index.ts"); +const SERVER_ENTRY = join(here, "server.ts"); +const BROWSER_ENTRY = join(here, "../../lib/fetcher-client.ts"); + +/** What must never reach a browser bundle through this module. */ +const SERVER_ONLY = [ + "@tanstack/react-start/server-only", + "@tanstack/react-start/server", + "dotenv", +]; + +/** The API's own runtime, which a server API module drags in behind it. */ +const API_RUNTIME = ["drizzle-orm", "hono", "postgres"]; + +const source = stripComments(readFileSync(UNIVERSAL_ENTRY, "utf8")); + +describe("what the Start compiler is given", () => { + it("writes both chains out directly, so the compiler can see them", () => { + // A wrapper around `createIsomorphicFn` compiles to nothing: the transform + // matches the literal `.server(x).client(y)` call and rewrites *that*. + expect(source).toMatch( + /export const fetcher = createIsomorphicFn\(\)\s*\.server\(serverFetcher\)\s*\.client\(fetcherClient\)/, + ); + expect(source).toMatch( + /export const rawFetcher = createIsomorphicFn\(\)\s*\.server\(serverRawFetcher\)\s*\.client\(rawFetcherClient\)/, + ); + expect(source.match(/createIsomorphicFn\(\)/g)).toHaveLength(2); + }); + + it("leaves the server import unreferenced once the client branch is chosen", () => { + // The transform, applied by hand: each chain becomes its `.client` argument. + // Nothing then refers to the server transports, which is the precondition + // for the dead-code pass to drop `./server` - and with it every specifier + // `SERVER_ONLY` names. + const clientOutput = source + .replace( + /createIsomorphicFn\(\)\s*\.server\((\w+)\)\s*\.client\((\w+)\)/g, + "$2", + ) + .replace(/import\s*\{[\s\S]*?\}\s*from\s*"\.\/server";/, ""); + + expect(clientOutput).not.toContain("serverFetcher"); + expect(clientOutput).not.toContain("serverRawFetcher"); + expect(clientOutput).toContain("export const fetcher = fetcherClient"); + expect(clientOutput).toContain( + "export const rawFetcher = rawFetcherClient", + ); + }); + + it("reaches the server-only specifiers through ./server and nothing else", () => { + // The control the assertion above needs: without it, "the client output has + // no server import" would pass on a module that never had one. + expect(offenders(SERVER_ENTRY, SERVER_ONLY)).not.toEqual([]); + + const chains = offenders(UNIVERSAL_ENTRY, SERVER_ONLY); + + expect(chains).not.toEqual([]); + for (const chain of chains) { + expect(chain).toContain("tanstack/fetcher/index.ts -> "); + expect(chain).toContain("tanstack/fetcher/server.ts"); + } + }); +}); + +describe("the browser transport is browser-shaped on its own", () => { + it("reaches nothing that only resolves on a server", () => { + expect(offenders(BROWSER_ENTRY, SERVER_ONLY)).toEqual([]); + }); + + it("never pulls the API's runtime in behind a module reference", () => { + // `clientModule()` is a type-only reference to an API module. A + // value import of one would bring Hono and Drizzle into the bundle. + expect(offenders(BROWSER_ENTRY, API_RUNTIME)).toEqual([]); + }); +}); diff --git a/packages/vitnode/src/tanstack/fetcher/index.ts b/packages/vitnode/src/tanstack/fetcher/index.ts new file mode 100644 index 000000000..bdd918525 --- /dev/null +++ b/packages/vitnode/src/tanstack/fetcher/index.ts @@ -0,0 +1,43 @@ +import { createIsomorphicFn } from "@tanstack/react-start"; + +import type { + UniversalFetcher, + UniversalRawFetcher, +} from "@/lib/fetcher-client"; + +import { fetcherClient, rawFetcherClient } from "@/lib/fetcher-client"; + +import { + fetcher as serverFetcher, + rawFetcher as serverRawFetcher, +} from "./server"; + +export type { UniversalFetcher, UniversalRawFetcher }; +export { clientModule } from "@/lib/fetcher-client"; + +/** + * One typed API call, wherever a TanStack Start route runs it. + * + * The chain below has to stay written out here: Start's compiler rewrites + * `createIsomorphicFn().server(x).client(y)` to `x` or `y` per environment and + * only recognises the literal chain, so a wrapper around it would ship both + * branches - and the server branch reaches `@tanstack/react-start/server-only`. + * + * The assertion is the price of that: `IsomorphicFn` collapses to + * `(...args) => TServer | TClient`, which drops the route-literal generics that + * are the whole point of the fetcher. Sound, because the two implementations it + * chooses between are the two functions {@link UniversalFetcher} describes. + */ +export const fetcher = createIsomorphicFn() + .server(serverFetcher) + .client(fetcherClient) as UniversalFetcher; + +/** + * {@link fetcher} for a route with no TypeScript module to infer from - which + * means the Content Engine's generated ones, and nothing else. + * + * Same chain, same reason it is written out here. + */ +export const rawFetcher = createIsomorphicFn() + .server(serverRawFetcher) + .client(rawFetcherClient) as UniversalRawFetcher; diff --git a/packages/vitnode/src/tanstack/files/query.ts b/packages/vitnode/src/tanstack/files/query.ts index 89791d2b2..2fde7f938 100644 --- a/packages/vitnode/src/tanstack/files/query.ts +++ b/packages/vitnode/src/tanstack/files/query.ts @@ -1,7 +1,6 @@ import type { QueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query"; -import { createIsomorphicFn } from "@tanstack/react-start"; import React from "react"; import type { @@ -17,22 +16,19 @@ import type { MyFilesParams, } from "@/views/files/my-files-query"; +import { fetcher } from "@/tanstack/fetcher"; import { deleteMyFileInBrowser, deleteMyFilesInBrowser, shouldRefreshAfterBulkDelete, } from "@/views/files/my-files-delete"; import { - fetchMyFilesPageInBrowser, + myFilesPageFetcher, myFilesQueryOptions, myFilesQueryRoot, } from "@/views/files/my-files-query"; -import { fetchMyFilesPageOnServer } from "./server"; - -const fetchMyFilesPage: MyFilesPageFetcher = createIsomorphicFn() - .server(fetchMyFilesPageOnServer) - .client(fetchMyFilesPageInBrowser); +const fetchMyFilesPage: MyFilesPageFetcher = myFilesPageFetcher(fetcher); export const myFilesQuery = ({ params, diff --git a/packages/vitnode/src/tanstack/files/server.ts b/packages/vitnode/src/tanstack/files/server.ts deleted file mode 100644 index 209b2b58f..000000000 --- a/packages/vitnode/src/tanstack/files/server.ts +++ /dev/null @@ -1,25 +0,0 @@ -import "@tanstack/react-start/server-only"; - -import type { MyFilesParams } from "@/views/files/my-files-query"; - -import { userFilesModule } from "@/api/modules/users/files/files.module"; -import { - FILES_PREFIX_PATH, - MyFilesRequestError, -} from "@/views/files/my-files-query"; - -import { fetcher } from "../fetcher/server"; - -export const fetchMyFilesPageOnServer = async (params: MyFilesParams) => { - const response = await fetcher(userFilesModule, { - args: { query: params }, - method: "get", - module: "files", - path: "/", - prefixPath: FILES_PREFIX_PATH, - }); - - if (!response.ok) throw new MyFilesRequestError(response.status, params); - - return await response.json(); -}; diff --git a/packages/vitnode/src/tanstack/search/feed.test.tsx b/packages/vitnode/src/tanstack/search/feed.test.tsx new file mode 100644 index 000000000..8f34e622d --- /dev/null +++ b/packages/vitnode/src/tanstack/search/feed.test.tsx @@ -0,0 +1,158 @@ +import { + QueryClient, + QueryClientProvider, + useInfiniteQuery, +} from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SearchFeedPage, SearchResultItem } from "@/views/search/types"; + +const requestHeaders = new Headers(); + +vi.mock("@tanstack/react-start/server-only", () => ({})); +vi.mock("@tanstack/react-start/server", () => ({ + getRequestHeaders: () => requestHeaders, + getRequestIP: () => "203.0.113.9", + getRequestUrl: () => new URL("https://preview.example.com/discover"), + setCookie: vi.fn(), +})); + +const { feedQueryOptions } = await import("./feed"); +const { searchFeedQueryKey, searchFeedQueryOptions } = + await import("@/views/search/search-feed-query"); + +const item = (id: number): SearchResultItem => ({ + author: { avatarColor: "ff0000", id: 1, name: "Ada", nameCode: "ada" }, + authorId: 1, + containerId: null, + containerType: null, + content: "A short body.", + createdAt: "2026-08-01T10:00:00.000Z", + id, + itemId: id, + itemType: "blog_post", + languageCode: "en", + metadata: {}, + pluginId: "@vitnode/core", + score: null, + title: `Post ${id}`, + url: `/blog/post-${id}`, +}); + +const page = (id: number, endCursor: null | number): SearchFeedPage => ({ + edges: [item(id)], + pageInfo: { + count: 1, + endCursor, + hasNextPage: endCursor !== null, + hasPreviousPage: false, + startCursor: id, + totalCount: 2, + }, +}); + +const apiFetch = vi.fn<(url: string | URL, init?: RequestInit) => Response>(); + +const cursorsAsked = (): (null | string)[] => + apiFetch.mock.calls.map( + ([url]) => new URL(String(url)).searchParams.get("cursor") ?? null, + ); + +const locale = "en"; +const params = { sort: "newest" } as const; + +let queryClient: QueryClient; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +beforeEach(() => { + apiFetch.mockReset(); + apiFetch.mockImplementation(url => { + const cursor = new URL(String(url)).searchParams.get("cursor"); + + return new Response( + JSON.stringify(cursor === null ? page(1, 1) : page(2, null)), + { headers: { "content-type": "application/json" }, status: 200 }, + ); + }); + vi.stubGlobal("fetch", apiFetch); + vi.stubEnv("NEXT_PUBLIC_API_URL", undefined); + requestHeaders.set("cookie", "vitnode_auth=abc"); + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +}); + +describe("an SSR-warmed feed is the feed the browser reads back", () => { + it("fills the cache entry the client's own definition names", async () => { + await queryClient.ensureInfiniteQueryData( + feedQueryOptions({ locale, params }), + ); + + // The route's `feedQueryOptions` and a framework-neutral component's + // `searchFeedQueryOptions` must name one entry, or the loader warms + // something the component never looks at. + expect(feedQueryOptions({ locale, params }).queryKey).toEqual( + searchFeedQueryKey({ locale, params }), + ); + expect(searchFeedQueryOptions({ locale, params }).queryKey).toEqual( + searchFeedQueryKey({ locale, params }), + ); + + const cached = queryClient.getQueryData<{ + pageParams: unknown[]; + pages: SearchFeedPage[]; + }>(searchFeedQueryKey({ locale, params })); + + expect(cached?.pages).toHaveLength(1); + expect(cached?.pageParams).toEqual([null]); + }); + + it("fetched that page over the request-aware transport", async () => { + await queryClient.ensureInfiniteQueryData( + feedQueryOptions({ locale, params }), + ); + + const [url, init] = apiFetch.mock.calls[0] ?? []; + const headers = new Headers(init?.headers); + + expect(new URL(String(url)).pathname).toBe("/api/@vitnode/core/search"); + expect(headers.get("Cookie")).toBe("vitnode_auth=abc"); + }); + + it("renders from that entry without asking again, then appends page two", async () => { + await queryClient.ensureInfiniteQueryData( + feedQueryOptions({ locale, params }), + ); + + expect(apiFetch).toHaveBeenCalledTimes(1); + + const { result } = renderHook( + () => useInfiniteQuery(feedQueryOptions({ locale, params })), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // The hydrated page is fresh, so mounting the feed costs no round trip. + expect(apiFetch).toHaveBeenCalledTimes(1); + expect(result.current.data?.pages).toHaveLength(1); + + await result.current.fetchNextPage(); + + await waitFor(() => expect(result.current.data?.pages).toHaveLength(2)); + + // Page one is not asked for a second time: the second request carries the + // cursor the first page ended on. + expect(apiFetch).toHaveBeenCalledTimes(2); + expect(cursorsAsked()).toEqual([null, "1"]); + expect(result.current.data?.pages.flatMap(one => one.edges)).toHaveLength( + 2, + ); + expect(result.current.hasNextPage).toBe(false); + }); +}); diff --git a/packages/vitnode/src/tanstack/search/feed.ts b/packages/vitnode/src/tanstack/search/feed.ts index 057dd15c5..71ea5cef9 100644 --- a/packages/vitnode/src/tanstack/search/feed.ts +++ b/packages/vitnode/src/tanstack/search/feed.ts @@ -1,21 +1,17 @@ -import { createIsomorphicFn } from "@tanstack/react-start"; - import type { SearchFeedPageFetcher, SearchFeedParams, } from "@/views/search/search-feed-query"; +import { fetcher } from "@/tanstack/fetcher"; import { - fetchSearchFeedPageInBrowser, + searchFeedPageFetcher, searchFeedQueryKey, searchFeedQueryOptions, } from "@/views/search/search-feed-query"; -import { fetchSearchFeedPageOnServer } from "./server"; - -export const fetchSearchFeedPage: SearchFeedPageFetcher = createIsomorphicFn() - .server(fetchSearchFeedPageOnServer) - .client(fetchSearchFeedPageInBrowser); +export const fetchSearchFeedPage: SearchFeedPageFetcher = + searchFeedPageFetcher(fetcher); export const feedQueryKey = ({ locale, diff --git a/packages/vitnode/src/tanstack/search/server.ts b/packages/vitnode/src/tanstack/search/server.ts deleted file mode 100644 index 316f241b6..000000000 --- a/packages/vitnode/src/tanstack/search/server.ts +++ /dev/null @@ -1,24 +0,0 @@ -import "@tanstack/react-start/server-only"; - -import type { SearchFeedPageArgs } from "@/views/search/search-feed-query"; - -import { searchModule } from "@/api/modules/search/search.module"; -import { - assertSearchFeedResponse, - searchFeedQuery, -} from "@/views/search/search-feed-query"; - -import { fetcher } from "../fetcher/server"; - -export const fetchSearchFeedPageOnServer = async (args: SearchFeedPageArgs) => { - const response = await fetcher(searchModule, { - args: { query: searchFeedQuery(args) }, - method: "get", - module: "search", - path: "/", - }); - - assertSearchFeedResponse(response, args); - - return await response.json(); -}; diff --git a/packages/vitnode/src/views/admin/views/content/content-request.ts b/packages/vitnode/src/views/admin/views/content/content-request.ts index ddf4493c4..b3b21d8ba 100644 --- a/packages/vitnode/src/views/admin/views/content/content-request.ts +++ b/packages/vitnode/src/views/admin/views/content/content-request.ts @@ -1,8 +1,9 @@ import type { z } from "zod"; +import type { UniversalRawFetcher } from "@/lib/fetcher-client"; import type { RawApiFetchArgs } from "@/lib/fetcher/raw"; -import { rawApiFetch } from "@/lib/fetcher/raw"; +import { rawFetcherClient } from "@/lib/fetcher-client"; import { AdminRequestError } from "@/views/admin/admin-request"; /** Which generated module a request is for. */ @@ -39,38 +40,35 @@ export const contentApiFetchArgs = ({ }); /** - * The browser half of the transport. + * One request to a generated content module, over whichever transport it is + * handed. See {@link ContentApiFetch}. * - * No headers of its own - the admin cookie is the browser's to attach, and the - * API derives who is asking from it. The server half lives in - * `tanstack/admin/content/server.ts`, where the request scope it needs actually - * exists. + * No headers of its own - the session cookie is the browser's to attach and the + * server transport's to forward, and the API derives who is asking from it. + */ +export const contentApiFetcher = + (transport: UniversalRawFetcher): ContentApiFetch => + async (request, { signal } = {}) => + await transport({ ...contentApiFetchArgs(request), options: { signal } }); + +/** + * How one content request is carried. * - * `credentials: "include"` because the API's origin is `NEXT_PUBLIC_API_URL`, - * which an installation is free to point at a separate host - and a - * cross-origin `fetch` sends no cookie at all without it. It is a no-op when the - * two are the same origin, which is the default (`CONFIG.api` falls through to - * `location.origin`), so this is right in both deployments rather than in one. - * Every other AdminCP write in this package does the same, and the API's CORS is - * configured `credentials: true` for exactly this. + * `signal` is the read's cancellation, when it has one. It reaches `fetch` + * untouched, and an abort therefore rejects at the transport rather than + * anywhere downstream: there is no response for `readContentApiJson` to inspect + * and no `catch` in the way, so a cancelled read cannot be mistaken for a + * refusal or for an empty list. Writes never pass one - a cancelled write leaves + * the server's state unknown and the cache un-invalidated. */ -export const contentApiFetchInBrowser = async ( +export type ContentApiFetch = ( request: ContentApiRequest, - /** - * The read's cancellation, when it has one. - * - * Reaches `fetch` untouched, and an abort therefore rejects here rather than - * anywhere downstream: there is no response for `readContentApiJson` to - * inspect and no `catch` in the way, so a cancelled read cannot be mistaken - * for a refusal or for an empty list. Writes never pass one - a cancelled - * write leaves the server's state unknown and the cache un-invalidated. - */ - { signal }: { signal?: AbortSignal } = {}, -): Promise => - await rawApiFetch({ - ...contentApiFetchArgs(request), - options: { credentials: "include", signal }, - }); + options?: { signal?: AbortSignal }, +) => Promise; + +/** The browser half of the transport. */ +export const contentApiFetchInBrowser: ContentApiFetch = + contentApiFetcher(rawFetcherClient); /** A request paired with what it was for, so a failure can say. */ export interface ContentApiRead { diff --git a/packages/vitnode/src/views/admin/views/content/form/item-query.ts b/packages/vitnode/src/views/admin/views/content/form/item-query.ts index d1031571a..b7725e3ee 100644 --- a/packages/vitnode/src/views/admin/views/content/form/item-query.ts +++ b/packages/vitnode/src/views/admin/views/content/form/item-query.ts @@ -6,7 +6,7 @@ import type { AnyContentTypeDefinition } from "@/content/types"; import { RECORD_STALE_TIME } from "@/lib/query-freshness"; import type { TranslationRow } from "../content-mutation"; -import type { ContentApiTarget } from "../content-request"; +import type { ContentApiFetch, ContentApiTarget } from "../content-request"; import { contentItemQueryKey, @@ -53,21 +53,24 @@ export type ContentTranslationsFetcher = ( request: ContentItemRequest, ) => Promise; -export const fetchContentItemInBrowser: ContentItemFetcher = async request => - await readContentApiJson( - await contentApiFetchInBrowser({ - method: "get", - path: `/${request.itemId}`, - target: request.target, - }), - { describe: describeContentItem(request), schema: zodContentItem }, - ); - -export const fetchContentTranslationsInBrowser: ContentTranslationsFetcher = +export const contentItemFetcher = + (fetchApi: ContentApiFetch): ContentItemFetcher => + async request => + await readContentApiJson( + await fetchApi({ + method: "get", + path: `/${request.itemId}`, + target: request.target, + }), + { describe: describeContentItem(request), schema: zodContentItem }, + ); + +export const contentTranslationsFetcher = + (fetchApi: ContentApiFetch): ContentTranslationsFetcher => async request => ( await readContentApiJson( - await contentApiFetchInBrowser({ + await fetchApi({ method: "get", path: `/${request.itemId}/translations`, target: request.target, @@ -79,6 +82,13 @@ export const fetchContentTranslationsInBrowser: ContentTranslationsFetcher = ) ).edges as unknown as TranslationRow[]; +export const fetchContentItemInBrowser: ContentItemFetcher = contentItemFetcher( + contentApiFetchInBrowser, +); + +export const fetchContentTranslationsInBrowser: ContentTranslationsFetcher = + contentTranslationsFetcher(contentApiFetchInBrowser); + /** * The record, as the one query definition a loader warms and a screen reads. * diff --git a/packages/vitnode/src/views/admin/views/content/table/list-query.ts b/packages/vitnode/src/views/admin/views/content/table/list-query.ts index f6ee8e4a6..cd06b3ac1 100644 --- a/packages/vitnode/src/views/admin/views/content/table/list-query.ts +++ b/packages/vitnode/src/views/admin/views/content/table/list-query.ts @@ -8,7 +8,11 @@ import type { import { RECORD_STALE_TIME } from "@/lib/query-freshness"; -import type { ContentApiRequest, ContentApiTarget } from "../content-request"; +import type { + ContentApiFetch, + ContentApiRequest, + ContentApiTarget, +} from "../content-request"; import type { ContentRowData } from "./cells"; import { contentListQueryKey } from "../content-query"; @@ -103,24 +107,27 @@ export const describeContentList = (request: ContentListRequest): string => `the ${request.contentTypeId} list`; /** - * One page, fetched from the browser against the same origin. + * One page, over whichever transport carries the content request. * * `readContentApiJson` throws on a refusal and on a schema mismatch, and an * abort throws earlier still - `fetch` rejects before there is a response to * parse. So a cancelled sort cannot reach the table as a content type with no * records in it, which is the one thing a list must never look like. */ -export const fetchContentListPageInBrowser: ContentListPageFetcher = async ( - request, - { signal } = {}, -) => - await readContentApiJson( - await contentApiFetchInBrowser(contentListApiRequest(request), { signal }), - { - describe: describeContentList(request), - schema: zodContentListPage, - }, - ); +export const contentListPageFetcher = + (fetchApi: ContentApiFetch): ContentListPageFetcher => + async (request, { signal } = {}) => + await readContentApiJson( + await fetchApi(contentListApiRequest(request), { signal }), + { + describe: describeContentList(request), + schema: zodContentListPage, + }, + ); + +/** One page, fetched from the browser against the same origin. */ +export const fetchContentListPageInBrowser: ContentListPageFetcher = + contentListPageFetcher(contentApiFetchInBrowser); /** * The list, as the query definition a loader warms and a component reads back. diff --git a/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-query.ts b/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-query.ts index ee13c8aaf..c2ccc4beb 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-query.ts +++ b/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-query.ts @@ -1,6 +1,7 @@ import { queryOptions } from "@tanstack/react-query"; import type { cronAdminModule } from "@/api/modules/admin/advanced/cron/cron.admin.module"; +import type { UniversalFetcher } from "@/lib/fetcher-client"; import type { AdminTableContract, AdminTablePage, @@ -49,25 +50,30 @@ export type CronPage = AdminTablePage; /** How a page is actually fetched. See {@link cronQueryOptions}. */ export type CronPageFetcher = (params: CronParams) => Promise; -export const fetchCronPageInBrowser: CronPageFetcher = async params => { - const response = await fetcherClient(cronAdminModuleRef, { - args: { query: params }, - method: "get", - module: "cron", - path: "/", - prefixPath: CRON_PREFIX_PATH, - }); - - if (!response.ok) { - throw new AdminRequestError( - response.status, - "the cron list", - describeAdminParams(params), - ); - } - - return await response.json(); -}; +export const cronPageFetcher = + (transport: UniversalFetcher): CronPageFetcher => + async params => { + const response = await transport(cronAdminModuleRef, { + args: { query: params }, + method: "get", + module: "cron", + path: "/", + prefixPath: CRON_PREFIX_PATH, + }); + + if (!response.ok) { + throw new AdminRequestError( + response.status, + "the cron list", + describeAdminParams(params), + ); + } + + return await response.json(); + }; + +export const fetchCronPageInBrowser: CronPageFetcher = + cronPageFetcher(fetcherClient); /** The root every cached page of the cron list hangs off. */ export const cronQueryRoot = adminQueryRoot("cron"); diff --git a/packages/vitnode/src/views/admin/views/core/advanced/queue/queue-query.ts b/packages/vitnode/src/views/admin/views/core/advanced/queue/queue-query.ts index dc48629c5..e420a5003 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/queue/queue-query.ts +++ b/packages/vitnode/src/views/admin/views/core/advanced/queue/queue-query.ts @@ -1,6 +1,7 @@ import { queryOptions } from "@tanstack/react-query"; import type { queueAdminModule } from "@/api/modules/admin/advanced/queue/queue.admin.module"; +import type { UniversalFetcher } from "@/lib/fetcher-client"; import type { AdminTableContract, AdminTablePage, @@ -63,26 +64,32 @@ export type QueuePage = AdminTablePage; /** How a page is actually fetched. See {@link queueQueryOptions}. */ export type QueuePageFetcher = (params: QueueParams) => Promise; -/** One page, fetched from the browser. */ -export const fetchQueuePageInBrowser: QueuePageFetcher = async params => { - const response = await fetcherClient(queueAdminModuleRef, { - args: { query: params }, - method: "get", - module: "queue", - path: "/", - prefixPath: QUEUE_PREFIX_PATH, - }); - - if (!response.ok) { - throw new AdminRequestError( - response.status, - "the queue list", - describeAdminParams(params), - ); - } +/** One page, over whichever transport the host hands in. */ +export const queuePageFetcher = + (transport: UniversalFetcher): QueuePageFetcher => + async params => { + const response = await transport(queueAdminModuleRef, { + args: { query: params }, + method: "get", + module: "queue", + path: "/", + prefixPath: QUEUE_PREFIX_PATH, + }); + + if (!response.ok) { + throw new AdminRequestError( + response.status, + "the queue list", + describeAdminParams(params), + ); + } + + return await response.json(); + }; - return await response.json(); -}; +/** One page, fetched from the browser. */ +export const fetchQueuePageInBrowser: QueuePageFetcher = + queuePageFetcher(fetcherClient); /** The root every cached page of the queue list hangs off. */ export const queueQueryRoot = adminQueryRoot("queue"); diff --git a/packages/vitnode/src/views/admin/views/core/advanced/search/search-index-query.ts b/packages/vitnode/src/views/admin/views/core/advanced/search/search-index-query.ts index b52b7ffbd..1c40ca91b 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/search/search-index-query.ts +++ b/packages/vitnode/src/views/admin/views/core/advanced/search/search-index-query.ts @@ -1,6 +1,7 @@ import { queryOptions } from "@tanstack/react-query"; import type { debugAdminModule } from "@/api/modules/admin/debug/debug.admin.module"; +import type { UniversalFetcher } from "@/lib/fetcher-client"; import { fetcherClient } from "@/lib/fetcher-client"; import { OPERATIONAL_STALE_TIME } from "@/lib/query-freshness"; @@ -29,10 +30,11 @@ export interface SearchIndexStatus { /** How the status is actually fetched. */ export type SearchIndexStatusFetcher = () => Promise; -/** The status, fetched from the browser. */ -export const fetchSearchIndexStatusInBrowser: SearchIndexStatusFetcher = +/** The status, over whichever transport the host hands in. */ +export const searchIndexStatusFetcher = + (transport: UniversalFetcher): SearchIndexStatusFetcher => async () => { - const response = await fetcherClient(searchDebugAdminModuleRef, { + const response = await transport(searchDebugAdminModuleRef, { method: "get", module: "debug", path: "/search/status", @@ -46,6 +48,10 @@ export const fetchSearchIndexStatusInBrowser: SearchIndexStatusFetcher = return await response.json(); }; +/** The status, fetched from the browser. */ +export const fetchSearchIndexStatusInBrowser: SearchIndexStatusFetcher = + searchIndexStatusFetcher(fetcherClient); + /** The cache entry this screen reads and writes. */ export const searchIndexQueryKey = adminQueryRoot("search-index"); diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/layout-query.ts b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/layout-query.ts index 9a7a01dfa..1ffe5f311 100644 --- a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/layout-query.ts +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/layout-query.ts @@ -2,6 +2,7 @@ import { queryOptions } from "@tanstack/react-query"; import type { adminModule } from "@/api/modules/admin/admin.module"; import type { AdminDashboardWidgetLayoutItem } from "@/database/dashboard"; +import type { UniversalFetcher } from "@/lib/fetcher-client"; import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; import { fetcherClient } from "@/lib/fetcher-client"; @@ -23,10 +24,11 @@ export type DashboardStoredLayout = AdminDashboardWidgetLayoutItem[]; export type DashboardLayoutFetcher = () => Promise; -/** The stored layout, fetched from the browser. */ -export const fetchDashboardLayoutInBrowser: DashboardLayoutFetcher = +/** The stored layout, over whichever transport the host hands in. */ +export const dashboardLayoutFetcher = + (transport: UniversalFetcher): DashboardLayoutFetcher => async () => { - const response = await fetcherClient(adminModuleClientRef, { + const response = await transport(adminModuleClientRef, { method: "get", module: "admin/dashboard", path: "/", @@ -37,6 +39,10 @@ export const fetchDashboardLayoutInBrowser: DashboardLayoutFetcher = return (await response.json()).widgets; }; +/** The stored layout, fetched from the browser. */ +export const fetchDashboardLayoutInBrowser: DashboardLayoutFetcher = + dashboardLayoutFetcher(fetcherClient); + export const dashboardLayoutQueryKey = (adminUserId: AdminIdentity) => adminScopedQueryRoot(ADMIN_DASHBOARD_SCREEN, adminUserId); diff --git a/packages/vitnode/src/views/admin/views/core/debug/debug-query.ts b/packages/vitnode/src/views/admin/views/core/debug/debug-query.ts index a25e3809a..95b47d001 100644 --- a/packages/vitnode/src/views/admin/views/core/debug/debug-query.ts +++ b/packages/vitnode/src/views/admin/views/core/debug/debug-query.ts @@ -1,6 +1,7 @@ import { queryOptions } from "@tanstack/react-query"; import type { debugAdminModule } from "@/api/modules/admin/debug/debug.admin.module"; +import type { UniversalFetcher } from "@/lib/fetcher-client"; import type { AdminTableContract, AdminTablePage, @@ -56,10 +57,11 @@ export type DebugLogsPageFetcher = ( params: DebugLogsParams, ) => Promise; -/** One page of the log, fetched from the browser. */ -export const fetchDebugLogsPageInBrowser: DebugLogsPageFetcher = +/** One page of the log, over whichever transport the host hands in. */ +export const debugLogsPageFetcher = + (transport: UniversalFetcher): DebugLogsPageFetcher => async params => { - const response = await fetcherClient(debugAdminModuleRef, { + const response = await transport(debugAdminModuleRef, { args: { query: params }, method: "get", module: "debug", @@ -78,6 +80,10 @@ export const fetchDebugLogsPageInBrowser: DebugLogsPageFetcher = return await response.json(); }; +/** One page of the log, fetched from the browser. */ +export const fetchDebugLogsPageInBrowser: DebugLogsPageFetcher = + debugLogsPageFetcher(fetcherClient); + /** The root every cached page of the system log hangs off. */ export const debugLogsQueryRoot = adminQueryRoot("debug-logs"); @@ -129,21 +135,27 @@ export interface DebugQueueSnapshot { /** The read, as arguments to whichever fetcher is carrying it. */ export type DebugQueueFetcher = () => Promise; -/** The snapshot, fetched from the browser. */ -export const fetchDebugQueueInBrowser: DebugQueueFetcher = async () => { - const response = await fetcherClient(debugAdminModuleRef, { - method: "get", - module: "debug", - path: "/queue", - prefixPath: DEBUG_PREFIX_PATH, - }); +/** The snapshot, over whichever transport the host hands in. */ +export const debugQueueFetcher = + (transport: UniversalFetcher): DebugQueueFetcher => + async () => { + const response = await transport(debugAdminModuleRef, { + method: "get", + module: "debug", + path: "/queue", + prefixPath: DEBUG_PREFIX_PATH, + }); - if (!response.ok) { - throw new AdminRequestError(response.status, "the queue snapshot"); - } + if (!response.ok) { + throw new AdminRequestError(response.status, "the queue snapshot"); + } - return await response.json(); -}; + return await response.json(); + }; + +/** The snapshot, fetched from the browser. */ +export const fetchDebugQueueInBrowser: DebugQueueFetcher = + debugQueueFetcher(fetcherClient); /** The cache entry the queue snapshot reads and writes. */ export const debugQueueQueryKey = adminQueryRoot("debug-queue"); diff --git a/packages/vitnode/src/views/admin/views/core/staff/staff-query.ts b/packages/vitnode/src/views/admin/views/core/staff/staff-query.ts index 41e85a6e5..906a97996 100644 --- a/packages/vitnode/src/views/admin/views/core/staff/staff-query.ts +++ b/packages/vitnode/src/views/admin/views/core/staff/staff-query.ts @@ -4,6 +4,7 @@ import type { PermissionsStaffArgs, PermissionStaffType, } from "@/api/lib/permission-staff"; +import type { UniversalFetcher } from "@/lib/fetcher-client"; import type { AdminTableContract, AdminTablePage, @@ -90,29 +91,30 @@ export type AdminStaffPageFetcher = ( options?: { signal?: AbortSignal }, ) => Promise; -export const fetchAdminStaffPageInBrowser: AdminStaffPageFetcher = async ( - type, - params, - { signal } = {}, -) => { - const response = await fetcherClient(adminModuleRef, { - args: { query: params }, - method: "get", - module: "admin/staff", - path: type === "admin" ? "/admins" : "/moderators", - options: { signal }, - }); +export const adminStaffPageFetcher = + (transport: UniversalFetcher): AdminStaffPageFetcher => + async (type, params, { signal } = {}) => { + const response = await transport(adminModuleRef, { + args: { query: params }, + method: "get", + module: "admin/staff", + options: { signal }, + path: type === "admin" ? "/admins" : "/moderators", + }); + + if (!response.ok) { + throw new AdminRequestError( + response.status, + `the ${STAFF_TYPE_SEGMENT[type]} staff list`, + describeAdminParams(params), + ); + } - if (!response.ok) { - throw new AdminRequestError( - response.status, - `the ${STAFF_TYPE_SEGMENT[type]} staff list`, - describeAdminParams(params), - ); - } + return await response.json(); + }; - return await response.json(); -}; +export const fetchAdminStaffPageInBrowser: AdminStaffPageFetcher = + adminStaffPageFetcher(fetcherClient); export const adminStaffQueryRoot = (adminUserId: AdminIdentity) => adminScopedQueryRoot(ADMIN_STAFF_SCREEN, adminUserId); @@ -156,9 +158,10 @@ export const adminStaffQueryOptions = ({ export type AdminStaffCatalogFetcher = () => Promise; -export const fetchAdminStaffCatalogInBrowser: AdminStaffCatalogFetcher = +export const adminStaffCatalogFetcher = + (transport: UniversalFetcher): AdminStaffCatalogFetcher => async () => { - const response = await fetcherClient(adminModuleRef, { + const response = await transport(adminModuleRef, { method: "get", module: "admin/staff", path: "/permission-catalog", @@ -174,6 +177,9 @@ export const fetchAdminStaffCatalogInBrowser: AdminStaffCatalogFetcher = return await response.json(); }; +export const fetchAdminStaffCatalogInBrowser: AdminStaffCatalogFetcher = + adminStaffCatalogFetcher(fetcherClient); + /** * The catalog is what the *installation* declares, not what one administrator * may do - but it is still read with an admin session, and it names every @@ -211,27 +217,29 @@ export type AdminStaffEntryFetcher = ( id: string, ) => Promise; -export const fetchAdminStaffEntryInBrowser: AdminStaffEntryFetcher = async ( - type, - id, -) => { - const response = await fetcherClient(adminModuleRef, { - args: { params: { id, type } }, - method: "get", - module: "admin/staff", - path: "/entry/{type}/{id}", - }); +export const adminStaffEntryFetcher = + (transport: UniversalFetcher): AdminStaffEntryFetcher => + async (type, id) => { + const response = await transport(adminModuleRef, { + args: { params: { id, type } }, + method: "get", + module: "admin/staff", + path: "/entry/{type}/{id}", + }); - if (!response.ok) { - throw new AdminRequestError( - response.status, - "a staff entry", - `type=${type}, id=${id}`, - ); - } + if (!response.ok) { + throw new AdminRequestError( + response.status, + "a staff entry", + `type=${type}, id=${id}`, + ); + } + + return await response.json(); + }; - return await response.json(); -}; +export const fetchAdminStaffEntryInBrowser: AdminStaffEntryFetcher = + adminStaffEntryFetcher(fetcherClient); export const adminStaffEntryQueryKey = ({ adminUserId, diff --git a/packages/vitnode/src/views/admin/views/core/system/files/files-query.ts b/packages/vitnode/src/views/admin/views/core/system/files/files-query.ts index b006c4c15..b4af4de37 100644 --- a/packages/vitnode/src/views/admin/views/core/system/files/files-query.ts +++ b/packages/vitnode/src/views/admin/views/core/system/files/files-query.ts @@ -1,6 +1,7 @@ import { queryOptions } from "@tanstack/react-query"; import type { filesAdminModule } from "@/api/modules/admin/files/files.admin.module"; +import type { UniversalFetcher } from "@/lib/fetcher-client"; import type { AdminTableContract, AdminTablePage, @@ -69,10 +70,11 @@ export type AdminFilesPageFetcher = ( params: AdminFilesParams, ) => Promise; -/** One page, fetched from the browser. */ -export const fetchAdminFilesPageInBrowser: AdminFilesPageFetcher = +/** One page, over whichever transport the host hands in. */ +export const adminFilesPageFetcher = + (transport: UniversalFetcher): AdminFilesPageFetcher => async params => { - const response = await fetcherClient(filesAdminModuleRef, { + const response = await transport(filesAdminModuleRef, { args: { query: params }, method: "get", module: "files", @@ -91,6 +93,10 @@ export const fetchAdminFilesPageInBrowser: AdminFilesPageFetcher = return await response.json(); }; +/** One page, fetched from the browser. */ +export const fetchAdminFilesPageInBrowser: AdminFilesPageFetcher = + adminFilesPageFetcher(fetcherClient); + /** The root every cached page of the admin file list hangs off. */ export const adminFilesQueryRoot = adminQueryRoot("files"); diff --git a/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-query.ts b/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-query.ts index 0fcfb6eea..7fc97f659 100644 --- a/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-query.ts +++ b/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-query.ts @@ -1,6 +1,7 @@ import { queryOptions } from "@tanstack/react-query"; import type { debugAdminModule } from "@/api/modules/admin/debug/debug.admin.module"; +import type { UniversalFetcher } from "@/lib/fetcher-client"; import { fetcherClient } from "@/lib/fetcher-client"; import { RECORD_STALE_TIME } from "@/lib/query-freshness"; @@ -49,21 +50,27 @@ export interface AdminIntegrations { /** How the board's data is actually fetched. */ export type IntegrationsFetcher = () => Promise; -/** The board's data, fetched from the browser. */ -export const fetchIntegrationsInBrowser: IntegrationsFetcher = async () => { - const response = await fetcherClient(debugAdminModuleRef, { - method: "get", - module: "debug", - path: "/integrations", - prefixPath: ADMIN_DEBUG_PREFIX_PATH, - }); +/** The board's data, over whichever transport the host hands in. */ +export const integrationsFetcher = + (transport: UniversalFetcher): IntegrationsFetcher => + async () => { + const response = await transport(debugAdminModuleRef, { + method: "get", + module: "debug", + path: "/integrations", + prefixPath: ADMIN_DEBUG_PREFIX_PATH, + }); - if (!response.ok) { - throw new AdminRequestError(response.status, "the integrations board"); - } + if (!response.ok) { + throw new AdminRequestError(response.status, "the integrations board"); + } - return await response.json(); -}; + return await response.json(); + }; + +/** The board's data, fetched from the browser. */ +export const fetchIntegrationsInBrowser: IntegrationsFetcher = + integrationsFetcher(fetcherClient); /** The cache entry the board reads and writes. */ export const integrationsQueryKey = adminQueryRoot("integrations"); diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-query.ts b/packages/vitnode/src/views/admin/views/core/users/detail/user-query.ts index ebe0b2699..ba3dbe6fe 100644 --- a/packages/vitnode/src/views/admin/views/core/users/detail/user-query.ts +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-query.ts @@ -1,6 +1,7 @@ import { queryOptions } from "@tanstack/react-query"; import type { StaffPermissionSet } from "@/api/lib/permission-staff"; +import type { UniversalFetcher } from "@/lib/fetcher-client"; import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; import type { AdminUserRole } from "@/views/admin/views/core/users/list/users-query"; @@ -48,21 +49,25 @@ export interface AdminUserDetail { export type AdminUserFetcher = (id: string) => Promise; -export const fetchAdminUserInBrowser: AdminUserFetcher = async id => { - const response = await fetcherClient(adminModuleRef, { - args: { params: { id } }, - method: "get", - module: "admin/users", - options: { credentials: "include" }, - path: "/{id}", - }); +export const adminUserFetcher = + (transport: UniversalFetcher): AdminUserFetcher => + async id => { + const response = await transport(adminModuleRef, { + args: { params: { id } }, + method: "get", + module: "admin/users", + path: "/{id}", + }); - if (!response.ok) { - throw new AdminRequestError(response.status, "a user", `id=${id}`); - } + if (!response.ok) { + throw new AdminRequestError(response.status, "a user", `id=${id}`); + } - return await response.json(); -}; + return await response.json(); + }; + +export const fetchAdminUserInBrowser: AdminUserFetcher = + adminUserFetcher(fetcherClient); export const adminUserQueryKey = ({ adminUserId, diff --git a/packages/vitnode/src/views/admin/views/core/users/list/users-query.ts b/packages/vitnode/src/views/admin/views/core/users/list/users-query.ts index 01e46842d..364b0f8ca 100644 --- a/packages/vitnode/src/views/admin/views/core/users/list/users-query.ts +++ b/packages/vitnode/src/views/admin/views/core/users/list/users-query.ts @@ -1,6 +1,7 @@ import { queryOptions } from "@tanstack/react-query"; import type { adminModule } from "@/api/modules/admin/admin.module"; +import type { UniversalFetcher } from "@/lib/fetcher-client"; import type { AdminTableContract, AdminTablePage, @@ -115,28 +116,30 @@ export type AdminUsersPageFetcher = ( options?: { signal?: AbortSignal }, ) => Promise; -export const fetchAdminUsersPageInBrowser: AdminUsersPageFetcher = async ( - params, - { signal } = {}, -) => { - const response = await fetcherClient(adminModuleRef, { - args: { query: params }, - method: "get", - module: "admin/users", - options: { credentials: "include", signal }, - path: "/list", - }); +export const adminUsersPageFetcher = + (transport: UniversalFetcher): AdminUsersPageFetcher => + async (params, { signal } = {}) => { + const response = await transport(adminModuleRef, { + args: { query: params }, + method: "get", + module: "admin/users", + options: { signal }, + path: "/list", + }); + + if (!response.ok) { + throw new AdminRequestError( + response.status, + "the users list", + describeAdminParams(params), + ); + } - if (!response.ok) { - throw new AdminRequestError( - response.status, - "the users list", - describeAdminParams(params), - ); - } + return await response.json(); + }; - return await response.json(); -}; +export const fetchAdminUsersPageInBrowser: AdminUsersPageFetcher = + adminUsersPageFetcher(fetcherClient); export const adminUsersQueryRoot = (adminUserId: AdminIdentity) => adminScopedQueryRoot(ADMIN_USERS_SCREEN, adminUserId); diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/roles-query.ts b/packages/vitnode/src/views/admin/views/core/users/roles/roles-query.ts index 1613289f2..b565713b0 100644 --- a/packages/vitnode/src/views/admin/views/core/users/roles/roles-query.ts +++ b/packages/vitnode/src/views/admin/views/core/users/roles/roles-query.ts @@ -1,5 +1,6 @@ import { queryOptions } from "@tanstack/react-query"; +import type { UniversalFetcher } from "@/lib/fetcher-client"; import type { AdminTableContract, AdminTablePage, @@ -72,14 +73,14 @@ export type AdminRolesPageFetcher = ( params: AdminRolesParams, ) => Promise; -export const fetchAdminRolesPageInBrowser: AdminRolesPageFetcher = +export const adminRolesPageFetcher = + (transport: UniversalFetcher): AdminRolesPageFetcher => async params => { - const response = await fetcherClient(adminModuleRef, { + const response = await transport(adminModuleRef, { args: { query: params }, method: "get", module: "admin/roles", path: "/list", - options: { credentials: "include" }, }); if (!response.ok) { @@ -93,6 +94,9 @@ export const fetchAdminRolesPageInBrowser: AdminRolesPageFetcher = return await response.json(); }; +export const fetchAdminRolesPageInBrowser: AdminRolesPageFetcher = + adminRolesPageFetcher(fetcherClient); + export const adminRolesQueryRoot = (adminUserId: AdminIdentity) => adminScopedQueryRoot(ADMIN_ROLES_SCREEN, adminUserId); diff --git a/packages/vitnode/src/views/auth/settings/devices/devices-query.ts b/packages/vitnode/src/views/auth/settings/devices/devices-query.ts index 00647dcd1..191fc581a 100644 --- a/packages/vitnode/src/views/auth/settings/devices/devices-query.ts +++ b/packages/vitnode/src/views/auth/settings/devices/devices-query.ts @@ -1,6 +1,7 @@ import { queryOptions } from "@tanstack/react-query"; import type { usersModule } from "@/api/modules/users/users.module"; +import type { UniversalFetcher } from "@/lib/fetcher-client"; import { CONFIG_PLUGIN } from "@/config"; import { clientModule, fetcherClient } from "@/lib/fetcher-client"; @@ -52,17 +53,22 @@ export const isDevicesRequestError = ( ): error is DevicesRequestError => error instanceof Error && error.name === DEVICES_REQUEST_ERROR; -export const fetchDevicesInBrowser: DevicesFetcher = async () => { - const response = await fetcherClient(usersModuleRef, { - method: "get", - module: "users", - path: "/devices", - }); +export const devicesFetcher = + (transport: UniversalFetcher): DevicesFetcher => + async () => { + const response = await transport(usersModuleRef, { + method: "get", + module: "users", + path: "/devices", + }); + + if (!response.ok) throw new DevicesRequestError(response.status); - if (!response.ok) throw new DevicesRequestError(response.status); + return await response.json(); + }; - return await response.json(); -}; +export const fetchDevicesInBrowser: DevicesFetcher = + devicesFetcher(fetcherClient); export const DEVICES_IDENTITY_ROOT = ["devices", "user"] as const; diff --git a/packages/vitnode/src/views/files/my-files-query.ts b/packages/vitnode/src/views/files/my-files-query.ts index 8ad9b131d..a77ad4b98 100644 --- a/packages/vitnode/src/views/files/my-files-query.ts +++ b/packages/vitnode/src/views/files/my-files-query.ts @@ -1,6 +1,7 @@ import { queryOptions } from "@tanstack/react-query"; import type { userFilesModule } from "@/api/modules/users/files/files.module"; +import type { UniversalFetcher } from "@/lib/fetcher-client"; import { DEFAULT_TABLE_PAGE_SIZE } from "@/components/table/url-state"; import { CONFIG_PLUGIN } from "@/config"; @@ -166,23 +167,25 @@ export const isMyFilesRequestError = ( ): error is MyFilesRequestError => error instanceof Error && error.name === MY_FILES_REQUEST_ERROR; -export const fetchMyFilesPageInBrowser: MyFilesPageFetcher = async ( - params, - { signal } = {}, -) => { - const response = await fetcherClient(userFilesModuleRef, { - args: { query: params }, - method: "get", - module: "files", - options: { signal }, - path: "/", - prefixPath: FILES_PREFIX_PATH, - }); - - if (!response.ok) throw new MyFilesRequestError(response.status, params); +export const myFilesPageFetcher = + (transport: UniversalFetcher): MyFilesPageFetcher => + async (params, { signal } = {}) => { + const response = await transport(userFilesModuleRef, { + args: { query: params }, + method: "get", + module: "files", + options: { signal }, + path: "/", + prefixPath: FILES_PREFIX_PATH, + }); + + if (!response.ok) throw new MyFilesRequestError(response.status, params); + + return await response.json(); + }; - return await response.json(); -}; +export const fetchMyFilesPageInBrowser: MyFilesPageFetcher = + myFilesPageFetcher(fetcherClient); export const MY_FILES_IDENTITY_ROOT = ["files", "user"] as const; diff --git a/packages/vitnode/src/views/search/search-feed-query.ts b/packages/vitnode/src/views/search/search-feed-query.ts index 17af123b8..06f499304 100644 --- a/packages/vitnode/src/views/search/search-feed-query.ts +++ b/packages/vitnode/src/views/search/search-feed-query.ts @@ -1,6 +1,7 @@ import { infiniteQueryOptions } from "@tanstack/react-query"; import type { searchModule } from "@/api/modules/search/search.module"; +import type { UniversalFetcher } from "@/lib/fetcher-client"; import { CONFIG_PLUGIN } from "@/config"; import { clientModule, fetcherClient } from "@/lib/fetcher-client"; @@ -121,6 +122,28 @@ export type SearchFeedPageFetcher = ( options?: { signal?: AbortSignal }, ) => Promise; +/** + * One page, over whichever transport the host hands in. + * + * The request, the cursor and the refusal check are all here, so SSR and the + * browser cannot drift: only the transport differs, and it is an argument. + */ +export const searchFeedPageFetcher = + (transport: UniversalFetcher): SearchFeedPageFetcher => + async (args, { signal } = {}) => { + const response = await transport(searchModuleRef, { + args: { query: searchFeedQuery(args) }, + method: "get", + module: "search", + options: { signal }, + path: "/", + }); + + assertSearchFeedResponse(response, args); + + return await response.json(); + }; + /** * One page, fetched from the browser. * @@ -128,22 +151,8 @@ export type SearchFeedPageFetcher = ( * VitNode client call uses - same-origin, cookies attached by the browser * itself, and a 429 routed to the global rate-limit notice. */ -export const fetchSearchFeedPageInBrowser: SearchFeedPageFetcher = async ( - args, - { signal } = {}, -) => { - const response = await fetcherClient(searchModuleRef, { - args: { query: searchFeedQuery(args) }, - method: "get", - module: "search", - options: { signal }, - path: "/", - }); - - assertSearchFeedResponse(response, args); - - return await response.json(); -}; +export const fetchSearchFeedPageInBrowser: SearchFeedPageFetcher = + searchFeedPageFetcher(fetcherClient); /** * The feed, as the one query definition every caller shares. From 334e74e35e57d320a54f721852ff3b0b1152941e Mon Sep 17 00:00:00 2001 From: Maciej Piotr Balcerzak Date: Thu, 3 Sep 2026 21:00:27 +0200 Subject: [PATCH 2/7] docs: simplify fetcher guide --- apps/web/content/docs/dev/fetcher.mdx | 278 ++++++++------------------ 1 file changed, 84 insertions(+), 194 deletions(-) diff --git a/apps/web/content/docs/dev/fetcher.mdx b/apps/web/content/docs/dev/fetcher.mdx index 7cf8b92b9..72fdbd609 100644 --- a/apps/web/content/docs/dev/fetcher.mdx +++ b/apps/web/content/docs/dev/fetcher.mdx @@ -1,248 +1,138 @@ --- title: Fetcher -description: End-to-end type-safe RPC client for calling your Hono API from SSR server renders or the browser. +description: Call your Hono API with end-to-end type safety. icon: ArrowRightLeft --- -VitNode's fetchers are type-safe RPC clients wired straight to your Hono API -modules: the route, the method, the arguments and the response body all come -from the module's own Zod schemas. +import { Tab, Tabs } from "fumadocs-ui/components/tabs" -There are three of them, and the first one is almost always the right answer. - -| Import | Use it for | -| :--------------------------------------------------- | :------------------------------------------------------------------------------ | -| `@vitnode/core/tanstack/fetcher` → `fetcher` | Normal TanStack Start reads and mutations. Works in SSR and browser navigation. | -| `@vitnode/core/tanstack/fetcher/server` → `fetcher` | Explicit server-only work: cookie relay, server functions, cron, upstream keys. | -| `@vitnode/core/lib/fetcher-client` → `fetcherClient` | Framework-neutral browser components, or a host that is not TanStack Start. | - -## 1. The universal fetcher - -One function, one call site, both runtimes: - -```ts title="plugins/devices/src/lib/devices-query.ts" -import type { usersModule } from '@vitnode/core/api/modules/users/users.module' - -import { clientModule } from '@vitnode/core/lib/fetcher-client' -import { fetcher } from '@vitnode/core/tanstack/fetcher' + + In a TanStack Start app, import `fetcher` from + `@vitnode/core/tanstack/fetcher`. The same call works during SSR and browser + navigation. + -// A lightweight typed reference: the module's *type* for inference, its -// plugin id at runtime. No API code reaches the browser bundle. -const users = clientModule('@vitnode/core') +During SSR, it forwards the visitor's request to the API. In the browser, it +calls `/api/*` directly. You do not need to write `createIsomorphicFn()`. -// [!code ++:9] -export const fetchDevices = async ({ - signal, -}: { signal?: AbortSignal } = {}) => { - const response = await fetcher(users, { - method: 'get', - module: 'users', - options: { signal }, - path: '/devices', - }) +## Use the universal fetcher - if (!response.ok) - throw new Error(`The devices API answered ${response.status}.`) + - return await response.json() // Typed from the route's Zod schema -} -``` + -Drop it into a query definition and hand that one definition to both the route -loader and the component: +### Create a typed module reference -```ts title="plugins/devices/src/lib/devices-query.ts" -import { queryOptions } from '@tanstack/react-query' +Import the API module as a type, then give VitNode its plugin id at runtime. -export const devicesQueryOptions = () => - queryOptions({ - queryFn: async ({ signal }) => await fetchDevices({ signal }), - queryKey: ['devices'], - }) -``` +```ts +import type { usersModule } from "@vitnode/core/api/modules/users/users.module" -```tsx title="plugins/devices/src/pages/devices-page.tsx" -import { definePluginRoute } from '@vitnode/core/routing' -import { devicesQueryOptions } from '../lib/devices-query' +import { clientModule } from "@vitnode/core/tanstack/fetcher" -export const route = definePluginRoute({ - // [!code ++] - load: async ({ context }) => - await context.queryClient.ensureQueryData(devicesQueryOptions()), -}) +const users = clientModule("@vitnode/core") ``` -### Which transport it picks, and when - -You never choose. TanStack Start's compiler resolves the branch per bundle: - -- **During SSR** the call uses the request-aware server transport. It forwards - the visitor's `Cookie`, `user-agent` and client IP, and resolves the API - origin from the request being served (or `NEXT_PUBLIC_API_URL` when a separate - API server is configured). -- **After hydration** the call is a plain same-origin `fetch` to `/api/…`, - straight from the browser to Hono. Cookies ride along through normal browser - behaviour. There is **no** server-function hop and no extra round trip. + -Because it is one function, the browser build never contains the server -transport - the compiler drops that branch, along with -`@tanstack/react-start/server-only` and everything behind it. + - - The universal `fetcher` deliberately does not accept `allowSaveCookies`, - `additionalHeaders` or `origin`. A browser cannot honour any of them, so - TypeScript rejects them here and points you at the server fetcher. - +### Make the request ---- +```ts +import { fetcher } from "@vitnode/core/tanstack/fetcher" -## 2. The server fetcher - -`@vitnode/core/tanstack/fetcher/server` is the same client with the server-only -options still on it. Reach for it when the request itself is the point: - -- a `createServerFn` in your host application; -- login, registration and other flows that must relay the API's `Set-Cookie` - onto the response (`allowSaveCookies: true`); -- cron jobs and queue workers, which serve no visitor request; -- calls that need a server secret or a different upstream `origin`. - -```ts title="apps/web/src/lib/auth.ts" -import { createServerFn } from '@tanstack/react-start' -import { usersModule } from '@vitnode/core/api/modules/users/users.module' -import { fetcher } from '@vitnode/core/tanstack/fetcher/server' - -export const signIn = createServerFn({ method: 'POST' }) - .validator((body: { email: string; password: string }) => body) - .handler(async ({ data }) => { - const response = await fetcher(usersModule, { - allowSaveCookies: true, // [!code ++] - args: { body: data }, - method: 'post', - module: 'users', - path: '/sign_in', - }) - - return { ok: response.ok } +export const fetchDevices = async ({ signal }: { signal?: AbortSignal } = {}) => { + const response = await fetcher(users, { + method: "get", + module: "users", + options: { signal }, + path: "/devices", }) -``` -Note it takes the **real** API module (`usersModule`), not a `clientModule` -reference - it only ever runs on the server, so importing the module is free. + if (!response.ok) { + throw new Error(`The devices API answered ${response.status}.`) + } - - A module that imports the server fetcher must never be reachable from a client - component. Keep it in `*.server.ts`, or behind the universal `fetcher`. - + return await response.json() +} +``` ---- +Use this function in your TanStack Query options. See +[Data Loading](/docs/dev/data-loading) for loading it in a route and component. -## 3. The framework-neutral browser fetcher + -`fetcherClient` is the direct Hono transport. Use it in components and query -modules that must stay usable outside TanStack Start - the shared views inside -`@vitnode/core` do exactly that, and default to it. + -```ts title="plugins/blog/src/views/comments-query.ts" -import type { commentsModule } from '@vitnode/blog/api/modules/comments/comments.module' +## Exceptions -import { clientModule, fetcherClient } from '@vitnode/core/lib/fetcher-client' + + -const comments = clientModule('@vitnode/blog') +Use the explicit server fetcher only when the server itself needs to handle the +request: a server function, cookie relay, cron/job, secret, or custom API +origin. -// [!code ++:8] -export const postComment = async (body: { text: string }) => - await fetcherClient(comments, { - args: { body }, - method: 'post', - module: 'comments', - path: '/', - }) +```ts +import { usersModule } from "@vitnode/core/api/modules/users/users.module" +import { fetcher } from "@vitnode/core/tanstack/fetcher/server" + +const response = await fetcher(usersModule, { + allowSaveCookies: true, + args: { body: { email, password } }, + method: "post", + module: "users", + path: "/sign_in", +}) ``` -It sends `credentials: "include"`, so the session cookie reaches the API even -when `NEXT_PUBLIC_API_URL` points at a separate host, and it routes a `429` to -the global rate-limit notice. - - - A browser response's `Set-Cookie` is applied by the browser itself. There is - no `allowSaveCookies` on `fetcherClient` - that option exists only so a - *server* render can copy the API's cookies onto the response it is building. + + Put code that imports this fetcher in a `*.server.ts` file, or call it only + from a server function. -### Taking the transport as an argument + -A shared query module that wants to serve both hosts should take the transport -in and keep `fetcherClient` as its default. `UniversalFetcher` is the signature -both fetchers satisfy: + -```ts -import type { UniversalFetcher } from '@vitnode/core/lib/fetcher-client' +For framework-neutral shared browser code, or a host that is not TanStack Start, +use `fetcherClient`. -import { fetcherClient } from '@vitnode/core/lib/fetcher-client' +```ts +import { clientModule, fetcherClient } from "@vitnode/core/lib/fetcher-client" -export const commentsFetcher = - (transport: UniversalFetcher) => async (postId: number) => - await transport(comments, { - args: { params: { postId: String(postId) } }, - method: 'get', - module: 'comments', - path: '/{postId}', - }) +const comments = clientModule("@vitnode/blog") -/** The neutral default, for a non-TanStack host. */ -export const fetchCommentsInBrowser = commentsFetcher(fetcherClient) +const response = await fetcherClient(comments, { + args: { body: { text } }, + method: "post", + module: "comments", + path: "/", +}) ``` -A TanStack Start adapter then binds the universal one instead: + + -```ts -import { fetcher } from '@vitnode/core/tanstack/fetcher' - -const fetchComments = commentsFetcher(fetcher) -``` +## What the types do ---- - -## Fetcher Options - -| Option | Required when | Description | -| :------------------ | :---------------------------- | :------------------------------------------------------------- | -| `module` | Always | Target module key declared on the API | -| `method` | Always | HTTP method (`get`, `post`, `put`, `delete`) | -| `path` | Always | Route path pattern, e.g. `/` or `/{id}` | -| `args` | The route declares one | Strongly typed payload: `{ body?, params?, query? }` | -| `options` | Optional | Extra `fetch` init - an `AbortSignal`, `credentials` | -| `prefixPath` | The module is not at the root | Mount prefix, e.g. `/admin` or `/users` | -| `captchaToken` | The route uses `withCaptcha` | Solved token; an empty string sends no header at all | -| `allowSaveCookies` | **Server fetcher only** | Copies the API's `Set-Cookie` onto the response being rendered | -| `additionalHeaders` | **Server fetcher only** | Extra request headers, on top of the forwarded ones | -| `origin` | **Server fetcher only** | Call a different upstream instead of the resolved API origin | - - - Generated Content Engine routes have no static TypeScript module to infer - from, so they use `rawFetcher` instead of `fetcher`. It is universal in the - same way - `@vitnode/core/tanstack/fetcher` exports one, and - `@vitnode/core/tanstack/fetcher/server` the explicit server-only one - and - takes `{ pluginId, module, path, method }` directly. - +- `method`, `module`, and `path` are always required. +- `args` is required when the route declares a body, params, or query. +- TypeScript infers the valid route, arguments, response status, and JSON body. -## Learn More +Generated Content Engine modules have no static module type, so use +`rawFetcher` for them instead. - From 79c12b932cfd480bfabf9c94137bbcd5457672d1 Mon Sep 17 00:00:00 2001 From: Maciej Piotr Balcerzak Date: Thu, 3 Sep 2026 21:00:45 +0200 Subject: [PATCH 3/7] docs: fix browser fetcher example --- apps/web/content/docs/dev/fetcher.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/content/docs/dev/fetcher.mdx b/apps/web/content/docs/dev/fetcher.mdx index 72fdbd609..4ba6018ad 100644 --- a/apps/web/content/docs/dev/fetcher.mdx +++ b/apps/web/content/docs/dev/fetcher.mdx @@ -100,6 +100,8 @@ For framework-neutral shared browser code, or a host that is not TanStack Start, use `fetcherClient`. ```ts +import type { commentsModule } from "@vitnode/blog/api/modules/comments/comments.module" + import { clientModule, fetcherClient } from "@vitnode/core/lib/fetcher-client" const comments = clientModule("@vitnode/blog") From a1c4eb5c035239eae1c03d076792b1832d449be5 Mon Sep 17 00:00:00 2001 From: Maciej Piotr Balcerzak Date: Thu, 3 Sep 2026 21:38:31 +0200 Subject: [PATCH 4/7] docs: show plugin API client fetcher usage --- apps/web/content/docs/dev/fetcher.mdx | 141 ++++++++++++++++---------- 1 file changed, 90 insertions(+), 51 deletions(-) diff --git a/apps/web/content/docs/dev/fetcher.mdx b/apps/web/content/docs/dev/fetcher.mdx index 4ba6018ad..9276c27fb 100644 --- a/apps/web/content/docs/dev/fetcher.mdx +++ b/apps/web/content/docs/dev/fetcher.mdx @@ -7,71 +7,125 @@ icon: ArrowRightLeft import { Tab, Tabs } from "fumadocs-ui/components/tabs" - In a TanStack Start app, import `fetcher` from - `@vitnode/core/tanstack/fetcher`. The same call works during SSR and browser - navigation. + In a TanStack Start app, use `fetcher` from + `@vitnode/core/tanstack/fetcher`. The same request works during SSR and + browser navigation. During SSR, it forwards the visitor's request to the API. In the browser, it calls `/api/*` directly. You do not need to write `createIsomorphicFn()`. -## Use the universal fetcher +## Set up your plugin API client once - +Keep the module reference in one plugin file. Features import your API client; +they do not create references themselves. - +```ts title="plugins/site-notes/src/api/client.ts" +import type { notesModule } from "../api/notes.module" -### Create a typed module reference +import { clientModule } from "@vitnode/core/lib/fetcher-client" -Import the API module as a type, then give VitNode its plugin id at runtime. - -```ts -import type { usersModule } from "@vitnode/core/api/modules/users/users.module" +export const notesApi = { + module: clientModule("@acme/site-notes"), +} +``` -import { clientModule } from "@vitnode/core/tanstack/fetcher" +## Use it in a query -const users = clientModule("@vitnode/core") -``` +```ts title="plugins/site-notes/src/features/notes/notes-query.ts" +import { queryOptions } from "@tanstack/react-query" +import { fetcher } from "@vitnode/core/tanstack/fetcher" - +import { notesApi } from "../../api/client" - +export const notesQueryKey = ["@acme/site-notes", "notes"] as const -### Make the request +export const notesQuery = () => + queryOptions({ + queryKey: notesQueryKey, + queryFn: async ({ signal }) => { + const response = await fetcher(notesApi.module, { + method: "get", + module: "notes", + options: { signal }, + path: "/", + }) -```ts -import { fetcher } from "@vitnode/core/tanstack/fetcher" + if (!response.ok) { + throw new Error(`The notes API answered ${response.status}.`) + } -export const fetchDevices = async ({ signal }: { signal?: AbortSignal } = {}) => { - const response = await fetcher(users, { - method: "get", - module: "users", - options: { signal }, - path: "/devices", + return await response.json() + }, }) +``` - if (!response.ok) { - throw new Error(`The devices API answered ${response.status}.`) - } +## Load the initial page or make a mutation - return await response.json() -} + + + +Warm the same query in the route loader. The component then reads that cache +entry with `useQuery(notesQuery())`. + +```ts title="plugins/site-notes/src/routes/notes.tsx" +import { definePluginRoute } from "@vitnode/core/routing" + +import { notesQuery } from "../features/notes/notes-query" + +export const route = definePluginRoute({ + load: async ({ context }) => + await context.queryClient.ensureQueryData(notesQuery()), +}) ``` -Use this function in your TanStack Query options. See -[Data Loading](/docs/dev/data-loading) for loading it in a route and component. + - + - +Use the same `fetcher` in a mutation, then invalidate the affected query. + +```tsx title="plugins/site-notes/src/features/notes/create-note.tsx" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { fetcher } from "@vitnode/core/tanstack/fetcher" + +import { notesApi } from "../../api/client" +import { notesQueryKey } from "./notes-query" + +export const useCreateNote = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (title: string) => { + const response = await fetcher(notesApi.module, { + args: { body: { title } }, + method: "post", + module: "notes", + path: "/", + }) + + if (!response.ok) { + throw new Error(`Could not create the note (${response.status}).`) + } + + return await response.json() + }, + onSuccess: async () => + await queryClient.invalidateQueries({ queryKey: notesQueryKey }), + }) +} +``` + + + ## Exceptions -Use the explicit server fetcher only when the server itself needs to handle the -request: a server function, cookie relay, cron/job, secret, or custom API +Use `@vitnode/core/tanstack/fetcher/server` only when the server must handle +the request: cookie relay, a server function, cron/job, secret, or custom API origin. ```ts @@ -99,21 +153,6 @@ const response = await fetcher(usersModule, { For framework-neutral shared browser code, or a host that is not TanStack Start, use `fetcherClient`. -```ts -import type { commentsModule } from "@vitnode/blog/api/modules/comments/comments.module" - -import { clientModule, fetcherClient } from "@vitnode/core/lib/fetcher-client" - -const comments = clientModule("@vitnode/blog") - -const response = await fetcherClient(comments, { - args: { body: { text } }, - method: "post", - module: "comments", - path: "/", -}) -``` - From 583179e6a2175eb0b85ef3331685151642d2bd02 Mon Sep 17 00:00:00 2001 From: Maciej Piotr Balcerzak Date: Thu, 3 Sep 2026 22:01:10 +0200 Subject: [PATCH 5/7] feat: add TanStack API client factory --- .../vitnode/src/tanstack/fetcher/index.ts | 99 +++++++++++++++---- 1 file changed, 79 insertions(+), 20 deletions(-) diff --git a/packages/vitnode/src/tanstack/fetcher/index.ts b/packages/vitnode/src/tanstack/fetcher/index.ts index bdd918525..6911bad21 100644 --- a/packages/vitnode/src/tanstack/fetcher/index.ts +++ b/packages/vitnode/src/tanstack/fetcher/index.ts @@ -1,11 +1,28 @@ import { createIsomorphicFn } from "@tanstack/react-start"; +import type { + BaseBuildModuleReturn, + BuildModuleReturn, +} from "@/api/lib/module"; +import type { Route } from "@/api/lib/route"; +import type { + FetcherParams, + FetcherRequestOptions, + GetModulePaths, + GetValidMethodForPath, + GetValidPathsForModule, + InferResponseType, +} from "@/lib/fetcher/types"; import type { UniversalFetcher, UniversalRawFetcher, } from "@/lib/fetcher-client"; -import { fetcherClient, rawFetcherClient } from "@/lib/fetcher-client"; +import { + clientModule, + fetcherClient, + rawFetcherClient, +} from "@/lib/fetcher-client"; import { fetcher as serverFetcher, @@ -15,29 +32,71 @@ import { export type { UniversalFetcher, UniversalRawFetcher }; export { clientModule } from "@/lib/fetcher-client"; -/** - * One typed API call, wherever a TanStack Start route runs it. - * - * The chain below has to stay written out here: Start's compiler rewrites - * `createIsomorphicFn().server(x).client(y)` to `x` or `y` per environment and - * only recognises the literal chain, so a wrapper around it would ship both - * branches - and the server branch reaches `@tanstack/react-start/server-only`. - * - * The assertion is the price of that: `IsomorphicFn` collapses to - * `(...args) => TServer | TClient`, which drops the route-literal generics that - * are the whole point of the fetcher. Sound, because the two implementations it - * chooses between are the two functions {@link UniversalFetcher} describes. - */ export const fetcher = createIsomorphicFn() .server(serverFetcher) .client(fetcherClient) as UniversalFetcher; -/** - * {@link fetcher} for a route with no TypeScript module to infer from - which - * means the Content Engine's generated ones, and nothing else. - * - * Same chain, same reason it is written out here. - */ export const rawFetcher = createIsomorphicFn() .server(serverRawFetcher) .client(rawFetcherClient) as UniversalRawFetcher; + +type ApiClient = + T extends BuildModuleReturn< + string, + infer MainModule extends string, + infer Routes extends Route[], + infer Modules extends BaseBuildModuleReturn[] + > + ? { + fetch: < + ModuleName extends GetModulePaths, + SelectedPath extends GetValidPathsForModule< + ModuleName, + MainModule, + Routes, + Modules + >, + Method extends GetValidMethodForPath< + ModuleName, + SelectedPath, + MainModule, + Routes, + Modules + > = GetValidMethodForPath< + ModuleName, + SelectedPath, + MainModule, + Routes, + Modules + >, + >( + args: FetcherParams< + MainModule, + Routes, + Modules, + ModuleName, + SelectedPath, + Method + > & + Omit & { + captchaToken?: string; + }, + ) => Promise< + InferResponseType< + MainModule, + Routes, + Modules, + ModuleName, + SelectedPath, + Method + > + >; + } + : never; + +export const createApiClient = ( + pluginId: T["pluginId"], +): ApiClient => + ({ + fetch: fetcher.bind(null, clientModule(pluginId)), + }) as ApiClient; From af326387591ee26d767e5f9d13802999d97e959f Mon Sep 17 00:00:00 2001 From: Maciej Piotr Balcerzak Date: Thu, 3 Sep 2026 22:01:22 +0200 Subject: [PATCH 6/7] test: cover TanStack API client types --- .../src/tanstack/fetcher/fetcher.test-d.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts b/packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts index 7af88a545..31b39bea4 100644 --- a/packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts +++ b/packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts @@ -4,7 +4,7 @@ import type { usersModule } from "@/api/modules/users/users.module"; import { clientModule } from "@/lib/fetcher-client"; -import { fetcher } from "./index"; +import { createApiClient, fetcher } from "./index"; const users = clientModule("@vitnode/core"); @@ -110,3 +110,28 @@ describe("the universal fetcher offers only what both runtimes can honour", () = }); }); }); + + +describe("a plugin API client", () => { + const usersApi = createApiClient("@vitnode/core"); + + it("keeps route and response inference without a module reference at the call site", async () => { + const response = await usersApi.fetch({ + method: "get", + module: "users", + path: "/session", + }); + + expectTypeOf(response.status).toEqualTypeOf<200>(); + expectTypeOf((await response.json()).user).not.toBeAny(); + }); + + it("rejects an invalid route", async () => { + await usersApi.fetch({ + method: "get", + module: "users", + // @ts-expect-error -- not a route on users + path: "/not-a-route", + }); + }); +}); From 391fb5f69072632389f7f472f43196f4dac02745 Mon Sep 17 00:00:00 2001 From: Maciej Piotr Balcerzak Date: Thu, 3 Sep 2026 22:01:53 +0200 Subject: [PATCH 7/7] docs: simplify API client fetcher guide --- apps/web/content/docs/dev/fetcher.mdx | 89 +++++++++++---------------- 1 file changed, 35 insertions(+), 54 deletions(-) diff --git a/apps/web/content/docs/dev/fetcher.mdx b/apps/web/content/docs/dev/fetcher.mdx index 9276c27fb..b555ab837 100644 --- a/apps/web/content/docs/dev/fetcher.mdx +++ b/apps/web/content/docs/dev/fetcher.mdx @@ -7,34 +7,41 @@ icon: ArrowRightLeft import { Tab, Tabs } from "fumadocs-ui/components/tabs" - In a TanStack Start app, use `fetcher` from - `@vitnode/core/tanstack/fetcher`. The same request works during SSR and - browser navigation. + In a TanStack Start app, use `fetcher` through a plugin API client. The same + request works during SSR and browser navigation. -During SSR, it forwards the visitor's request to the API. In the browser, it -calls `/api/*` directly. You do not need to write `createIsomorphicFn()`. +During SSR, VitNode forwards the visitor’s request to the API. In the browser, +it calls `/api/*` directly. You do not need to write `createIsomorphicFn()` or +choose a transport. -## Set up your plugin API client once +## Create your API client once -Keep the module reference in one plugin file. Features import your API client; -they do not create references themselves. + + + + +### Define it in your plugin + +Keep this in one plugin file. Features import `notesApi`; they never set up a +module reference themselves. ```ts title="plugins/site-notes/src/api/client.ts" import type { notesModule } from "../api/notes.module" -import { clientModule } from "@vitnode/core/lib/fetcher-client" +import { createApiClient } from "@vitnode/core/tanstack/fetcher" -export const notesApi = { - module: clientModule("@acme/site-notes"), -} +export const notesApi = createApiClient("@acme/site-notes") ``` -## Use it in a query + + + + +### Fetch data ```ts title="plugins/site-notes/src/features/notes/notes-query.ts" import { queryOptions } from "@tanstack/react-query" -import { fetcher } from "@vitnode/core/tanstack/fetcher" import { notesApi } from "../../api/client" @@ -44,7 +51,7 @@ export const notesQuery = () => queryOptions({ queryKey: notesQueryKey, queryFn: async ({ signal }) => { - const response = await fetcher(notesApi.module, { + const response = await notesApi.fetch({ method: "get", module: "notes", options: { signal }, @@ -60,13 +67,17 @@ export const notesQuery = () => }) ``` -## Load the initial page or make a mutation + + + + +## Use it on a page or in a mutation -Warm the same query in the route loader. The component then reads that cache -entry with `useQuery(notesQuery())`. +Warm the query in the route loader. The component reads that same cache entry +with `useQuery(notesQuery())`. ```ts title="plugins/site-notes/src/routes/notes.tsx" import { definePluginRoute } from "@vitnode/core/routing" @@ -83,11 +94,10 @@ export const route = definePluginRoute({ -Use the same `fetcher` in a mutation, then invalidate the affected query. +Use the same API client, then invalidate the data that changed. ```tsx title="plugins/site-notes/src/features/notes/create-note.tsx" import { useMutation, useQueryClient } from "@tanstack/react-query" -import { fetcher } from "@vitnode/core/tanstack/fetcher" import { notesApi } from "../../api/client" import { notesQueryKey } from "./notes-query" @@ -97,16 +107,14 @@ export const useCreateNote = () => { return useMutation({ mutationFn: async (title: string) => { - const response = await fetcher(notesApi.module, { + const response = await notesApi.fetch({ args: { body: { title } }, method: "post", module: "notes", path: "/", }) - if (!response.ok) { - throw new Error(`Could not create the note (${response.status}).`) - } + if (!response.ok) throw new Error("Could not create the note.") return await response.json() }, @@ -119,43 +127,16 @@ export const useCreateNote = () => { -## Exceptions - - - +## Server-only work -Use `@vitnode/core/tanstack/fetcher/server` only when the server must handle -the request: cookie relay, a server function, cron/job, secret, or custom API -origin. - -```ts -import { usersModule } from "@vitnode/core/api/modules/users/users.module" -import { fetcher } from "@vitnode/core/tanstack/fetcher/server" - -const response = await fetcher(usersModule, { - allowSaveCookies: true, - args: { body: { email, password } }, - method: "post", - module: "users", - path: "/sign_in", -}) -``` +Use `@vitnode/core/tanstack/fetcher/server` only for a server function, cookie +relay, cron/job, secret, or a custom API origin. Put code that imports this fetcher in a `*.server.ts` file, or call it only from a server function. - - - - -For framework-neutral shared browser code, or a host that is not TanStack Start, -use `fetcherClient`. - - - - ## What the types do - `method`, `module`, and `path` are always required.