Skip to content
Merged
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
82 changes: 0 additions & 82 deletions .github/copilot-instructions.md

This file was deleted.

9 changes: 8 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
}
],
"search.exclude": {
"**/(plugins)/*": true
"**/(plugins)/*": true,
"**/routeTree.gen.ts": true
},
"files.watcherExclude": {
"**/routeTree.gen.ts": true
},
"files.readonlyInclude": {
"**/routeTree.gen.ts": true
}
}
9 changes: 5 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,15 @@ import { Activity } from "react";
### Fetching APIs

- `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.
- A call names the plugin, never an API module: `fetcher({ plugin: "@vitnode/core", method: "get", module: "users", path: "/devices" })`. `plugin`, `module` (any nesting depth, `admin/advanced/cron`), `path`, `method`, `args` and the response are all inferred from the plugin API registry; an invalid combination is a compile error at the offending property.
- The registry is `ApiPluginRegistry` in `@vitnode/core/lib/fetcher/registry`. Core registers itself; an app's `src/api-registry.gen.ts` registers every configured plugin that exports `config.api`, with type-only imports; a plugin registers its own API in its `global.d.ts` so its pages can call it. Never import a plugin API factory as a value outside `vitnode.api.config.ts`.
- `@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`. Same call shape, more options.
- Shared `views/*` query modules call `fetcher` directly - no injected transport, no browser-only twin. `@vitnode/core/lib/fetcher-client` is the framework-neutral browser primitive the universal fetcher is built on, for code that never renders on a server.
- 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 feature's own `*Fetcher` type instead.
- `args` is required exactly when the route declares a body, params or a query.
- `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.
- `rawFetcher` only for generated Content Engine admin modules, which have no type to infer from. It is universal too, with the same server-only twin. A content type's *public* routes are typed: `module: "content/<publicApi.path>"`.

### Caching APIs

Expand Down
1 change: 0 additions & 1 deletion apps/api/.next/trace

This file was deleted.

1 change: 0 additions & 1 deletion apps/api/.next/trace-build

This file was deleted.

11 changes: 0 additions & 11 deletions apps/web/.vscode/settings.json

This file was deleted.

2 changes: 1 addition & 1 deletion apps/web/content/docs/dev/ai/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ const embedModel = c.get('ai').embeddingModel()
Provider instances and API keys stay on the server. What the browser gets is the
public half of each entry - `id`, `name`, and the provider's model id string -
published on the middleware route and read with
[`useMiddlewareConfigQuery()`](/docs/dev/data-loading#deployment-configuration):
[`useMiddlewareConfigQuery()`](/docs/dev/fetcher#deployment-configuration):

```tsx title="plugins/writer/src/pages/model-picker.tsx"
import { useMiddlewareConfigQuery } from '@vitnode/core/tanstack/auth'
Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/docs/dev/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ When a user visits a page (e.g. `/blog`):
| **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.query` executes isomorphic fetcher |
| **4. RPC Call** | Server / Browser | `fetcher` (server) or `fetcherClient` (browser) calls Hono endpoint |
| **4. RPC Call** | Server / Browser | The universal `fetcher` calls the Hono endpoint by plugin id |
| **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 Down
6 changes: 3 additions & 3 deletions apps/web/content/docs/dev/cache.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const { data } = useSuspenseQuery(announcementsQueryOptions())

Keep the fetcher and any `queryOptions` helper inside the plugin too. The plugin
route is the SSR boundary; its screen can reuse the same query key for client
updates. See [Data loading](/docs/dev/data-loading) for the isomorphic fetcher.
updates. See [Data fetching](/docs/dev/fetcher) for the universal fetcher.

### Pick a lifetime that matches the data

Expand Down Expand Up @@ -174,8 +174,8 @@ should reach the database.

<Cards>
<Card
href="/docs/dev/data-loading"
title="Data loading"
href="/docs/dev/fetcher"
title="Data fetching"
description="Query options, route loaders and the invalidation that keeps the app cache honest."
/>
<Card
Expand Down
13 changes: 5 additions & 8 deletions apps/web/content/docs/dev/captcha/custom-adapter.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -168,16 +168,12 @@ change.

### Send the token

`captchaToken` is a first-class fetcher option, on both the browser fetcher and
the server one, and it spells the `x-vitnode-captcha-token` header for you. An
`captchaToken` is a first-class fetcher option, on both the universal fetcher
and the server one, and it spells the `x-vitnode-captcha-token` header for you. An
empty string sends no header at all.

```ts title="src/site/contact/send-message.ts"
import type { contactModule } from '@vitnode/my-plugin/api/modules/contact/contact.module'

import { clientModule, fetcherClient } from '@vitnode/core/lib/fetcher-client'

const contact = clientModule<typeof contactModule>('@vitnode/my-plugin')
import { fetcher } from '@vitnode/core/tanstack/fetcher'

export const sendMessage = async ({
captchaToken,
Expand All @@ -186,7 +182,8 @@ export const sendMessage = async ({
captchaToken: string
message: string
}) =>
await fetcherClient(contact, {
await fetcher({
plugin: '@vitnode/my-plugin',
method: 'post',
module: 'contact',
path: '/',
Expand Down
10 changes: 6 additions & 4 deletions apps/web/content/docs/dev/captcha/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,21 +65,23 @@ export const createCommentRoute = buildRoute({

### 3. Render the Frontend Widget

Include `<Captcha />` in your form and supply the received token to `fetcherClient`:
Include `<Captcha />` in your form and supply the received token to the fetcher:

```tsx title="plugins/blog/src/views/comment-form.tsx"
import { Captcha } from "@vitnode/core/components/captcha"
import { fetcherClient } from "@vitnode/core/lib/fetcher-client"
import { fetcher } from "@vitnode/core/tanstack/fetcher"

export const CommentForm = () => {
const [captchaToken, setCaptchaToken] = React.useState<string>()

const handleSubmit = async () => {
await fetcherClient(commentsModule, {
await fetcher({
plugin: "@acme/blog",
method: "post",
module: "comments",
path: "/",
captchaToken, // [!code ++]
body: { text: "Nice article!" },
args: { body: { text: "Nice article!" } },
})
}

Expand Down
1 change: 1 addition & 0 deletions apps/web/content/docs/dev/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ of those modules, and that is the part worth knowing:
| `src/admin-nav.gen.ts` | the plugin's `admin/nav` | with the AdminCP shell |
| `src/content-registry.gen.ts` | the plugin's `admin/content` | behind a dynamic `import()` in `src/router.tsx` |
| `src/package-messages.gen.ts` | the factory's `localeFiles` | per request, one locale at a time |
| `src/api-registry.gen.ts` | the plugin's `config.api` | never - type-only, it teaches the fetcher which plugins exist |

One literal import per configured plugin, written at build time. So a content
type's editing screen - a Tiptap field, a form layout, a table cell - arrives
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,21 +60,62 @@ import { articleContent } from "./content/articles"

export const blogApiPlugin = () =>
buildApiPlugin({
pluginId: "blog",
pluginId: "@acme/blog",
modules: [
// [!code ++:4]
buildContentPublicModule({
contentTypes: [articleContent],
pluginId: "blog",
pluginId: "@acme/blog",
}),
],
})
```

The module exposes two public endpoints:

- `GET /api/{pluginId}/{path}`: Lists published records with cursor pagination, optional `search`, equality `filters`, and `orderBy`.
- `GET /api/{pluginId}/{path}/{slug}`: Retrieves a single published record resolved by its public slug.
- `GET /api/{pluginId}/content/{path}`: Lists published records with cursor pagination, optional `search`, equality `filters`, and `orderBy`.
- `GET /api/{pluginId}/content/{path}/{slug}`: Retrieves a single published record resolved by its public slug.

</Step>
<Step>

### Read it with the fetcher

`buildContentPublicModule` keeps every generated route in its type, so the
universal fetcher infers them like any hand-written module. The module path is
`content/` followed by the content type's `publicApi.path`:

```ts title="plugins/blog/src/features/articles/article-query.ts"
import { fetcher } from "@vitnode/core/tanstack/fetcher"

// [!code ++:8]
export const fetchArticle = async (slug: string) => {
const response = await fetcher({
plugin: "@acme/blog",
args: { params: { slug } },
method: "get",
module: "content/articles",
path: "/{slug}",
})

if (response.status === 404) return null

return await response.json()
}

export const fetchArticles = async (search?: string) =>
await fetcher({
plugin: "@acme/blog",
args: { query: { first: "20", orderBy: "title", search } },
method: "get",
module: "content/articles",
path: "/",
})
```

A content type without `publicApi` contributes no module, so
`module: "content/categories"` for a private type is a compile error rather
than a 404 at runtime.

</Step>
<Step>
Expand Down
Loading