Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,15 @@ import { Activity } from "react";

### Fetching APIs

- `fetcher(module, {...})` on the server (takes the real API module), `fetcherClient(clientModule<typeof x>(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<typeof x>(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

Expand Down
46 changes: 25 additions & 21 deletions apps/web/content/docs/dev/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,25 @@ 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.

{/* Image prompt: VitNode architectural flow diagram displaying TanStack Start (SSR, route loaders, TanStack Query) communicating across an HTTP/RPC boundary with Hono API (middleware, sessions, Drizzle ORM) and PostgreSQL/Redis storage. Dark theme, 1600x900. */}

## 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** |

<Callout type="warn" title="Security Boundary">
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.
</Callout>

---
Expand All @@ -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 |

---

Expand All @@ -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
Expand All @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions apps/web/content/docs/dev/data-loading.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
207 changes: 113 additions & 94 deletions apps/web/content/docs/dev/fetcher.mdx
Original file line number Diff line number Diff line change
@@ -1,141 +1,160 @@
---
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
---

import { TypeTable } from 'fumadocs-ui/components/type-table'
import { Tab, Tabs } from "fumadocs-ui/components/tabs"

VitNode provides type-safe RPC fetchers directly linked to your Hono API modules:
<Callout type="info" title="Use this by default">
In a TanStack Start app, use `fetcher` through a plugin API client. The same
request works during SSR and browser navigation.
</Callout>

- `fetcher` on the **server** (SSR / server functions) using the real API module.
- `fetcherClient` in the **browser** using a lightweight module type reference.
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.

## 1. Server-Side Fetching (`fetcher`)
## Create your API client once

Use `fetcher` in SSR renders and `.server()` branches of `createIsomorphicFn`:
<Steps>

```ts
import { usersModule } from '@vitnode/core/api/modules/users/users.module'
import { fetcher } from '@vitnode/core/tanstack/fetcher/server'
<Step>

// [!code ++:8]
const response = await fetcher(usersModule, {
method: 'get',
module: 'users',
path: '/session',
})
### Define it in your plugin

if (response.ok) {
const session = await response.json() // Automatically typed from Zod schema
}
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 { createApiClient } from "@vitnode/core/tanstack/fetcher"

export const notesApi = createApiClient<typeof notesModule>("@acme/site-notes")
```

`fetcher()` automatically forwards incoming cookies, user-agent, and client IP headers.
</Step>

---
<Step>

### Fetch data

```ts title="plugins/site-notes/src/features/notes/notes-query.ts"
import { queryOptions } from "@tanstack/react-query"

## 2. Browser-Side Fetching (`fetcherClient`)
import { notesApi } from "../../api/client"

In client components and browser query functions, use `fetcherClient`:
export const notesQueryKey = ["@acme/site-notes", "notes"] as const

```ts
import { clientModule, fetcherClient } from '@vitnode/core/lib/fetcher-client'
import type { usersModule } from '@vitnode/core/api/modules/users/users.module'
export const notesQuery = () =>
queryOptions({
queryKey: notesQueryKey,
queryFn: async ({ signal }) => {
const response = await notesApi.fetch({
method: "get",
module: "notes",
options: { signal },
path: "/",
})

// Create lightweight module reference (no runtime backend imports bundled)
const moduleRef = clientModule<typeof usersModule>('@vitnode/core')
if (!response.ok) {
throw new Error(`The notes API answered ${response.status}.`)
}

// [!code ++:13]
const response = await fetcherClient(moduleRef, {
method: 'post',
module: 'users',
path: '/sign_in',
args: {
body: {
email: 'user@example.com',
password: 'password123',
return await response.json()
},
},
allowSaveCookies: true, // Necessary when the route mints a session
})
})
```

---
</Step>

## 3. Use it on a plugin page
</Steps>

Plugin route loaders run for SSR and client navigation. Wrap both transports
once, then call that function from the page that owns the feature:
## Use it on a page or in a mutation

```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'
<Tabs items={["Initial page", "Mutation"]}>
<Tab value="Initial page">

const usersModuleRef = clientModule<typeof usersModule>('@vitnode/core')
Warm the query in the route loader. The component reads that same cache entry
with `useQuery(notesQuery())`.

export const fetchDevices = createIsomorphicFn()
.server(async () => {
const response = await fetcher(usersModule, {
method: 'get',
module: 'users',
path: '/devices',
})
return await response.json()
})
.client(async () => {
const response = await fetcherClient(usersModuleRef, {
method: 'get',
module: 'users',
path: '/devices',
})
return await response.json()
})
```
```ts title="plugins/site-notes/src/routes/notes.tsx"
import { definePluginRoute } from "@vitnode/core/routing"

```tsx title="plugins/devices/src/pages/devices-page.tsx"
import { definePluginRoute } from '@vitnode/core/routing'
import { fetchDevices } from '../lib/fetch-devices'
import { notesQuery } from "../features/notes/notes-query"

// [!code ++:3]
export const route = definePluginRoute({
load: async () => await fetchDevices(),
load: async ({ context }) =>
await context.queryClient.ensureQueryData(notesQuery()),
})
```

That is the page-level usage: the initial render uses `fetcher`; later
navigations use `fetcherClient`. Same result, no extra host route file.
</Tab>

---
<Tab value="Mutation">

Use the same API client, then invalidate the data that changed.

## Fetcher Options
```tsx title="plugins/site-notes/src/features/notes/create-note.tsx"
import { useMutation, useQueryClient } from "@tanstack/react-query"

| 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` |
import { notesApi } from "../../api/client"
import { notesQueryKey } from "./notes-query"

<Callout type="info" title="Dynamic Content Engine Modules (rawFetcher)">
For generated Content Engine routes that have no static TypeScript module definitions, use `rawFetcher({ pluginId, path, method })`.
export const useCreateNote = () => {
const queryClient = useQueryClient()

return useMutation({
mutationFn: async (title: string) => {
const response = await notesApi.fetch({
args: { body: { title } },
method: "post",
module: "notes",
path: "/",
})

if (!response.ok) throw new Error("Could not create the note.")

return await response.json()
},
onSuccess: async () =>
await queryClient.invalidateQueries({ queryKey: notesQueryKey }),
})
}
```

</Tab>
</Tabs>

## Server-only work

Use `@vitnode/core/tanstack/fetcher/server` only for a server function, cookie
relay, cron/job, secret, or a custom API origin.

<Callout type="warn" title="Keep it server-only">
Put code that imports this fetcher in a `*.server.ts` file, or call it only
from a server function.
</Callout>

## Learn More
## What the types do

- `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.

Generated Content Engine modules have no static module type, so use
`rawFetcher` for them instead.

<Cards>
<Card
title="Server Functions"
description="Isomorphic fetching with createIsomorphicFn"
href="/docs/dev/server-functions"
title="Data Loading"
description="Load and cache API data with TanStack Query"
href="/docs/dev/data-loading"
/>
<Card
title="API Modules"
description="Declare typed Hono API modules and routes"
href="/docs/dev/plugins/api/modules"
title="Server Functions"
description="Use server-only code when a request needs it"
href="/docs/dev/server-functions"
/>
</Cards>
Loading
Loading