From c2c74832b0fa3262fe8a75ea02f470f70b5a6364 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Mon, 14 Sep 2026 19:06:57 +0200 Subject: [PATCH 1/2] refactor: Fetcher in tanstack start --- AGENTS.md | 9 +- apps/web/content/docs/dev/architecture.mdx | 2 +- .../docs/dev/captcha/custom-adapter.mdx | 13 +- apps/web/content/docs/dev/captcha/index.mdx | 10 +- apps/web/content/docs/dev/configuration.mdx | 1 + .../content-engine/public-api-and-caching.mdx | 49 ++- apps/web/content/docs/dev/fetcher.mdx | 245 ++++++++----- .../content/docs/dev/plugins/api/modules.mdx | 58 ++- .../web/content/docs/dev/server-functions.mdx | 33 +- apps/web/content/docs/ui/combobox.mdx | 5 +- apps/web/content/docs/ui/data-table.mdx | 22 +- apps/web/eslint.config.mjs | 1 + apps/web/src/api-registry.gen.ts | 15 + .../copy-of-vitnode-plugin/root/global.d.ts | 21 -- .../src/plugin/create/route-templates.test.ts | 56 ++- .../src/plugin/create/route-templates.ts | 46 ++- .../plugin/create/scaffold-typecheck.test.ts | 177 +++++++++ packages/vitnode/package.json | 5 + packages/vitnode/src/api/lib/module.ts | 2 +- packages/vitnode/src/api/lib/plugin.ts | 26 +- packages/vitnode/src/api/modules.client.ts | 59 --- packages/vitnode/src/content/define.ts | 16 +- packages/vitnode/src/content/server/index.ts | 5 + packages/vitnode/src/content/server/module.ts | 2 +- .../src/content/server/public-module.ts | 37 +- packages/vitnode/src/content/types.ts | 17 +- .../framework/api-registry/generate.test.ts | 92 +++++ .../src/framework/api-registry/generate.ts | 46 +++ .../src/framework/api-registry/index.ts | 5 + .../src/framework/api-registry/types.ts | 4 + .../vite/no-materialized-routes.test.ts | 3 +- .../src/framework/vite/plugin-routes.ts | 18 +- .../src/framework/vite/projections.test.ts | 20 +- .../vitnode/src/lib/fetcher-client.test.ts | 33 +- packages/vitnode/src/lib/fetcher-client.ts | 87 ++--- packages/vitnode/src/lib/fetcher/core.ts | 103 ++---- .../vitnode/src/lib/fetcher/fetcher.test-d.ts | 21 +- packages/vitnode/src/lib/fetcher/registry.ts | 5 + packages/vitnode/src/lib/fetcher/types.ts | 332 +++++++++-------- .../tanstack/admin/content/form/transport.ts | 15 +- .../src/tanstack/admin/content/index.ts | 2 +- .../src/tanstack/admin/content/query.ts | 11 +- .../src/tanstack/admin/content/transport.ts | 6 - .../vitnode/src/tanstack/admin/cron/query.ts | 10 +- .../src/tanstack/admin/dashboard/query.tsx | 7 +- .../vitnode/src/tanstack/admin/debug/query.ts | 15 +- .../src/tanstack/admin/default-transport.ts | 8 +- .../vitnode/src/tanstack/admin/files/query.ts | 11 +- .../src/tanstack/admin/integrations/query.ts | 9 +- .../vitnode/src/tanstack/admin/queue/query.ts | 10 +- .../vitnode/src/tanstack/admin/roles/query.ts | 16 +- .../src/tanstack/admin/search-index/query.ts | 7 +- packages/vitnode/src/tanstack/admin/server.ts | 5 +- .../vitnode/src/tanstack/admin/staff/query.ts | 36 +- .../vitnode/src/tanstack/admin/user-search.ts | 7 +- .../vitnode/src/tanstack/admin/users/query.ts | 23 +- .../src/tanstack/auth/default-transport.ts | 32 +- .../src/tanstack/auth/middleware-config.ts | 8 +- packages/vitnode/src/tanstack/auth/server.ts | 29 +- .../auth/transport-operations.test.ts | 5 +- .../vitnode/src/tanstack/devices/query.ts | 8 +- .../src/tanstack/fetcher/fetcher.test-d.ts | 342 +++++++++++++++--- .../src/tanstack/fetcher/fetcher.test.ts | 58 ++- .../vitnode/src/tanstack/fetcher/index.ts | 97 +---- .../src/tanstack/fetcher/server.test.ts | 44 ++- .../vitnode/src/tanstack/fetcher/server.ts | 79 ++-- packages/vitnode/src/tanstack/files/query.ts | 10 +- .../vitnode/src/tanstack/profile/index.ts | 2 +- .../vitnode/src/tanstack/profile/query.ts | 9 +- packages/vitnode/src/tanstack/search/feed.ts | 12 +- .../src/tanstack/settings/personal-policy.ts | 10 +- .../vitnode/src/views/admin/admin-request.ts | 8 - .../admin/views/content/content-request.ts | 22 +- .../admin/views/content/form/item-query.ts | 41 +-- .../views/content/form/mutations-api.test.ts | 2 +- .../admin/views/content/lib/api-result.ts | 4 +- .../views/content/table/list-mutations.ts | 6 +- .../admin/views/content/table/list-query.ts | 36 +- .../views/core/advanced/cron/cron-query.ts | 52 +-- .../core/advanced/cron/run-action/run-cron.ts | 10 +- .../views/core/advanced/queue/queue-query.ts | 53 +-- .../advanced/search/search-index-mutations.ts | 15 +- .../advanced/search/search-index-query.ts | 44 +-- .../core/dashboard/widgets/layout-query.ts | 37 +- .../dashboard/widgets/widget-mutations.ts | 11 +- .../admin/views/core/debug/debug-query.ts | 89 ++--- .../admin/views/core/staff/staff-mutations.ts | 11 +- .../admin/views/core/staff/staff-query.ts | 136 ++++--- .../system/files/actions/file-row-actions.tsx | 9 +- .../views/core/system/files/files-delete.ts | 10 +- .../views/core/system/files/files-query.ts | 53 +-- .../system/integrations/integrations-query.ts | 49 +-- .../send-test-email-mutation.ts | 8 +- .../system/integrations/test-ai/content.tsx | 23 +- .../integrations/test-storage/content.tsx | 23 +- .../views/core/users/detail/user-query.ts | 37 +- .../views/core/users/list/users-query.ts | 55 ++- .../views/core/users/roles/roles-mutations.ts | 11 +- .../views/core/users/roles/roles-query.ts | 47 ++- .../admin/views/core/users/users-mutations.ts | 18 +- .../settings/devices/devices-query.test.ts | 7 +- .../auth/settings/devices/devices-query.ts | 41 +-- .../auth/settings/devices/devices-revoke.ts | 6 +- .../auth/settings/overview/personal-update.ts | 40 +- .../views/files/actions/file-row-actions.tsx | 22 +- .../src/views/files/my-files-delete.ts | 10 +- .../vitnode/src/views/files/my-files-query.ts | 48 +-- .../views/profile/images/self-images-query.ts | 49 ++- .../src/views/profile/profile-query.ts | 37 +- .../src/views/search/search-feed-query.ts | 46 +-- plugins/blog/global.d.ts | 10 + plugins/blog/src/config.test-d.ts | 54 --- plugins/example/global.d.ts | 10 + 113 files changed, 2172 insertions(+), 1772 deletions(-) create mode 100644 apps/web/src/api-registry.gen.ts delete mode 100644 packages/create-vitnode-app/copy-of-vitnode-plugin/root/global.d.ts create mode 100644 packages/create-vitnode-app/src/plugin/create/scaffold-typecheck.test.ts delete mode 100644 packages/vitnode/src/api/modules.client.ts create mode 100644 packages/vitnode/src/framework/api-registry/generate.test.ts create mode 100644 packages/vitnode/src/framework/api-registry/generate.ts create mode 100644 packages/vitnode/src/framework/api-registry/index.ts create mode 100644 packages/vitnode/src/framework/api-registry/types.ts create mode 100644 packages/vitnode/src/lib/fetcher/registry.ts delete mode 100644 packages/vitnode/src/tanstack/admin/content/transport.ts delete mode 100644 plugins/blog/src/config.test-d.ts diff --git a/AGENTS.md b/AGENTS.md index 9f5736c17..da67afaca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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(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/"`. ### Caching APIs diff --git a/apps/web/content/docs/dev/architecture.mdx b/apps/web/content/docs/dev/architecture.mdx index 8147f78b8..fd8e50d29 100644 --- a/apps/web/content/docs/dev/architecture.mdx +++ b/apps/web/content/docs/dev/architecture.mdx @@ -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 | diff --git a/apps/web/content/docs/dev/captcha/custom-adapter.mdx b/apps/web/content/docs/dev/captcha/custom-adapter.mdx index 54d216a3f..4f5d97a9a 100644 --- a/apps/web/content/docs/dev/captcha/custom-adapter.mdx +++ b/apps/web/content/docs/dev/captcha/custom-adapter.mdx @@ -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('@vitnode/my-plugin') +import { fetcher } from '@vitnode/core/tanstack/fetcher' export const sendMessage = async ({ captchaToken, @@ -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: '/', diff --git a/apps/web/content/docs/dev/captcha/index.mdx b/apps/web/content/docs/dev/captcha/index.mdx index 1fb55e6b0..4c5009828 100644 --- a/apps/web/content/docs/dev/captcha/index.mdx +++ b/apps/web/content/docs/dev/captcha/index.mdx @@ -65,21 +65,23 @@ export const createCommentRoute = buildRoute({ ### 3. Render the Frontend Widget -Include `` in your form and supply the received token to `fetcherClient`: +Include `` 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() 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!" } }, }) } diff --git a/apps/web/content/docs/dev/configuration.mdx b/apps/web/content/docs/dev/configuration.mdx index 5b24d7a55..35efa0b0c 100644 --- a/apps/web/content/docs/dev/configuration.mdx +++ b/apps/web/content/docs/dev/configuration.mdx @@ -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 diff --git a/apps/web/content/docs/dev/content-engine/public-api-and-caching.mdx b/apps/web/content/docs/dev/content-engine/public-api-and-caching.mdx index 20fdca0e3..ae361067e 100644 --- a/apps/web/content/docs/dev/content-engine/public-api-and-caching.mdx +++ b/apps/web/content/docs/dev/content-engine/public-api-and-caching.mdx @@ -60,12 +60,12 @@ 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", }), ], }) @@ -73,8 +73,49 @@ export const blogApiPlugin = () => 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. + + + + +### 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. diff --git a/apps/web/content/docs/dev/fetcher.mdx b/apps/web/content/docs/dev/fetcher.mdx index ae520bf85..ecaa58a32 100644 --- a/apps/web/content/docs/dev/fetcher.mdx +++ b/apps/web/content/docs/dev/fetcher.mdx @@ -1,80 +1,120 @@ --- -title: Fetcher -description: Call your Hono API with end-to-end type safety. +title: API Fetcher +description: Call Hono endpoints with end-to-end type safety using a universal client that works seamlessly across SSR and the browser. icon: ArrowRightLeft --- -import { Tab, Tabs } from "fumadocs-ui/components/tabs" +import { BoxesIcon, DatabaseIcon, MapIcon, ServerIcon } from 'lucide-react' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' - - In a TanStack Start app, use `fetcher` through a plugin API client. The same - request works during SSR and browser navigation. - +In TanStack Start, network calls must bridge two execution environments: server rendering during SSR and browser navigation afterwards. -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. +VitNode's universal `fetcher` from `@vitnode/core/tanstack/fetcher` eliminates duplicate transport logic. During SSR, it relays visitor cookies, IP addresses, and headers to the API; in the browser, it calls `/api/*` directly with `credentials: "include"`. One call, end-to-end type inference, zero drama. -## Create your API client once +## The universal call - +Call `fetcher` inline at the call site. The router automatically infers available modules, HTTP methods, arguments, and return types from your plugin's API registry: - +```ts title="plugins/site-notes/src/features/notes/notes-query.ts" +import { fetcher } from '@vitnode/core/tanstack/fetcher' -### Define it in your plugin +const response = await fetcher({ + plugin: '@acme/site-notes', + method: 'get', + module: 'notes', + path: '/', +}) + +if (response.ok) { + const { notes } = await response.json() +} +``` -Keep this in one plugin file. Features import `notesApi`; they never set up a -module reference themselves. `create-vitnode-app --plugin` writes this file for -you—the shape below is what it generates. +Four fields identify an endpoint: -```ts title="plugins/site-notes/src/api/client.ts" -import type { ApiClient } from "@vitnode/core/tanstack/fetcher" +| Field | Description | Inferred from | +| :------- | :------------------------------------------------------------- | :----------------------------------- | +| `plugin` | Package name of the target plugin | App-configured plugin registry | +| `module` | Module path under the plugin (e.g. `'notes'`, `'admin/notes'`) | Plugin's `buildApiPlugin` definition | +| `path` | Route path declared inside the module (`'/'`, `'/{id}'`) | Module's route declarations | +| `method` | HTTP method declared for that path (`'get'`, `'post'`, etc.) | Declared routes for that path | -import { createApiClient } from "@vitnode/core/tanstack/fetcher" +## Type inference & compile checks -import type { notesModule } from "./modules/notes/notes.module" +The fetcher checks your endpoint definition through `ApiPluginRegistry`. An invalid method, typo in the path, or missing parameter is caught at compile time: -export const notesApi: ApiClient = - createApiClient("@acme/site-notes") +```ts +await fetcher({ + plugin: '@acme/site-notes', + // @ts-expect-error Type '"delete"' is not assignable to type '"get"'. + method: 'delete', + module: 'notes', + path: '/', +}) ``` - - `import type` is what keeps Hono and your handlers out of the browser bundle—a - value import would ship the whole API to every visitor, and nothing would fail - to compile. +The response is status-aware: `response.status` is typed as a union of declared HTTP codes (e.g. `200 | 404`). Once you narrow on `response.status`, `response.json()` automatically narrows to that status code's specific response schema. - The `ApiClient` annotation is what keeps your plugin's `.d.ts` small. Without - it, declaration emit resolves the client's type in full and writes every route - the module serves into your build output: 200KB for a single route, and every - app that installs the plugin type-checks it. - +## Arguments: body, params, and query - +The `args` object is required whenever a route declares Zod schemas for parameters, queries, or request bodies: - +```ts title="plugins/site-notes/src/features/notes/pin-note.ts" +import { fetcher } from '@vitnode/core/tanstack/fetcher' -### Fetch data +export const pinNote = async (id: string, pinned: boolean) => { + const response = await fetcher({ + plugin: '@acme/site-notes', + // [!code ++:4] + args: { + body: { pinned }, + params: { id }, + }, + method: 'post', + module: 'notes', + path: '/{id}/pin', + }) -```ts title="plugins/site-notes/src/features/notes/notes-query.ts" -import { queryOptions } from "@tanstack/react-query" + if (response.status === 404) return null + + // Narrowed to 200: typed as the pinned Note object + return await response.json() +} +``` + +- `params` — Interpolated into path parameters like `/{id}`. +- `query` — Serialized into the URL query string (e.g. `?search=term&page=1`). +- `body` — Serialized as JSON. +- `formData` — Pass `formData` instead of `args.body` for file uploads. +- `captchaToken` — Pass `captchaToken` for captcha-protected endpoints. -import { notesApi } from "../../api/client" +## Using fetcher in queries & mutations + + + + +Warm queries in your route's `load` function and read from the cache with `useQuery`: + +```ts title="plugins/site-notes/src/features/notes/notes-query.ts" +import { queryOptions } from '@tanstack/react-query' +import { fetcher } from '@vitnode/core/tanstack/fetcher' -export const notesQueryKey = ["@acme/site-notes", "notes"] as const +export const notesQueryKey = ['@acme/site-notes', 'notes'] as const export const notesQuery = () => queryOptions({ queryKey: notesQueryKey, queryFn: async ({ signal }) => { - const response = await notesApi.fetch({ - method: "get", - module: "notes", + const response = await fetcher({ + plugin: '@acme/site-notes', + method: 'get', + module: 'notes', options: { signal }, - path: "/", + path: '/', }) if (!response.ok) { - throw new Error(`The notes API answered ${response.status}.`) + throw new Error(`Failed to load notes: ${response.status}`) } return await response.json() @@ -82,59 +122,55 @@ export const notesQuery = () => }) ``` - - - - -## Use it on a page or in a mutation - - - - -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" - -import { notesQuery } from "../features/notes/notes-query" +```ts title="plugins/site-notes/src/pages/notes-page.tsx" +import { definePluginRoute } from '@vitnode/core/routing' +import { notesQuery } from '../features/notes/notes-query' export const route = definePluginRoute({ - load: async ({ context }) => - await context.queryClient.query({ ...notesQuery(), staleTime: "static" }), + load: async ({ context }) => { + return await context.queryClient.query({ + ...notesQuery(), + staleTime: 'static', + }) + }, }) ``` - -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" +Execute mutations and invalidate affected cache tags: -import { notesApi } from "../../api/client" -import { notesQueryKey } from "./notes-query" +```tsx title="plugins/site-notes/src/features/notes/use-create-note.ts" +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { fetcher } from '@vitnode/core/tanstack/fetcher' +import { toast } from 'sonner' +import { notesQueryKey } from './notes-query' export const useCreateNote = () => { const queryClient = useQueryClient() return useMutation({ mutationFn: async (title: string) => { - const response = await notesApi.fetch({ + const response = await fetcher({ + plugin: '@acme/site-notes', args: { body: { title } }, - method: "post", - module: "notes", - path: "/", + method: 'post', + module: 'notes', + path: '/', }) - if (!response.ok) throw new Error("Could not create the note.") + if (!response.ok) { + throw new Error('Failed to create note.') + } return await response.json() }, - onSuccess: async () => - await queryClient.invalidateQueries({ queryKey: notesQueryKey }), + onSuccess: async () => { + // Invalidate cache and trigger sonner toast + await queryClient.invalidateQueries({ queryKey: notesQueryKey }) + toast.success('Note created successfully!') + }, }) } ``` @@ -142,34 +178,63 @@ export const useCreateNote = () => { -## Server-only work +## Universal vs. server-only fetcher + +`fetcher` from `@vitnode/core/tanstack/fetcher` is the universal client. For operations that run exclusively on the server (server functions, cron tasks, or external secrets), use `@vitnode/core/tanstack/fetcher/server`: -Use `@vitnode/core/tanstack/fetcher/server` only for a server function, cookie -relay, cron/job, secret, or a custom API origin. +| Feature | Universal (`fetcher`) | Server-only (`fetcher/server`) | Description | +| :--------------------------------- | :-------------------: | :----------------------------: | :--------------------------------------------------- | +| Standard queries & mutations | ✓ | ✓ | End-to-end typed requests | +| `args`, `formData`, `captchaToken` | ✓ | ✓ | Request parameter serialization | +| `allowSaveCookies` | | ✓ | Relays API `Set-Cookie` headers back to the browser | +| `additionalHeaders` | | ✓ | Forwards internal credentials or custom headers | +| `origin` | | ✓ | Directs requests to an alternative upstream API host | - - Put code that imports this fetcher in a `*.server.ts` file, or call it only - from a server function. + + Import from `@vitnode/core/tanstack/fetcher/server` only inside `*.server.ts` + files or `createServerFn` handlers. Value imports of backend factories or + server fetchers should never reach client bundles. -## What the types do +## Content Engine routes + +Public routes created via [Content Engine](/docs/dev/content-engine) (`publicApi`) are automatically typed under `module: "content/"`: -- `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. +```ts +const response = await fetcher({ + plugin: '@vitnode/blog', + args: { params: { slug: 'welcome' } }, + method: 'get', + module: 'content/posts', + path: '/{slug}', +}) +``` -Generated Content Engine modules have no static module type, so use -`rawFetcher` for them instead. +For generated AdminCP content modules where schemas are dynamic, use `rawFetcher` from `@vitnode/core/tanstack/fetcher` instead. See [Content Engine](/docs/dev/content-engine) for defining content types, schema modeling, and generating CRUD APIs. } + title="Content Engine" + description="Define content types, schemas, and typed public endpoints." + href="/docs/dev/content-engine" + /> + } title="Data Loading" - description="Load and cache API data with TanStack Query" + description="Learn caching, query prefetching, and TanStack Query integration." href="/docs/dev/data-loading" /> } + title="Plugin Routes" + description="Define route trees, lazy pages, loaders, and breadcrumbs." + href="/docs/dev/plugins/routes" + /> + } title="Server Functions" - description="Use server-only code when a request needs it" + description="Implement server-only workflows, cookie relays, and background jobs." href="/docs/dev/server-functions" /> diff --git a/apps/web/content/docs/dev/plugins/api/modules.mdx b/apps/web/content/docs/dev/plugins/api/modules.mdx index 3d3329966..27d5d51f0 100644 --- a/apps/web/content/docs/dev/plugins/api/modules.mdx +++ b/apps/web/content/docs/dev/plugins/api/modules.mdx @@ -81,6 +81,39 @@ This file, and not `config.tsx`: the API config reaches your handlers, database and secrets, while `config.tsx` is read by the browser build. A module registered in the wrong one is shipped to every visitor. +`buildApiPlugin` keeps everything it was given as a literal type - the plugin +id, the tuple of modules, and every module nested inside them. That type is +what the [fetcher](/docs/dev/fetcher) infers routes from, so a route you add +here is callable, and checked, the moment you save. + + + + +### Register the API type for your own pages + +Your pages call the endpoint through the universal fetcher, and the fetcher +looks the plugin up in the API plugin registry. Register yours once, in the +plugin's `global.d.ts`, as a **type** - `create-vitnode-app --plugin` writes +this file for you: + +```ts title="plugins/site-notes/global.d.ts" +import type { siteNotesApiPlugin } from './src/config.api' + +declare module '@vitnode/core/lib/fetcher/registry' { + interface ApiPluginRegistry { + '@acme/site-notes': typeof siteNotesApiPlugin // [!code ++] + } +} + +export type { ApiPluginRegistry } from '@vitnode/core/lib/fetcher/registry' +``` + +`import type` is the whole point: the factory is never executed here, so none +of the Hono, database or secret code behind it is ever reached by a browser +build. The app that installs your plugin writes its own entry into its +`src/api-registry.gen.ts`, so this file is for developing the plugin on its +own. + @@ -99,7 +132,30 @@ export const vitNodeApiConfig = buildApiConfig({ ``` The endpoint is now `GET /api/@acme/site-notes/notes`. OpenAPI picks it up too; -one less hand-written map to maintain. +one less hand-written map to maintain. This is the one place `siteNotesApiPlugin()` +is ever called - everywhere else it is a type. + + + + +### Call it + +```ts title="plugins/site-notes/src/features/notes/notes-query.ts" +import { fetcher } from '@vitnode/core/tanstack/fetcher' + +const response = await fetcher({ + plugin: '@acme/site-notes', // [!code ++] + method: 'get', + module: 'notes', + path: '/', +}) + +const { notes } = await response.json() +``` + +Nothing but the plugin id crosses into the browser bundle. `module`, `path`, +`method` and the response are inferred from the module tree registered above, +and a typo in any of them is a compile error. diff --git a/apps/web/content/docs/dev/server-functions.mdx b/apps/web/content/docs/dev/server-functions.mdx index d15f996c3..7875cc970 100644 --- a/apps/web/content/docs/dev/server-functions.mdx +++ b/apps/web/content/docs/dev/server-functions.mdx @@ -37,19 +37,15 @@ export const fetchDevices = createIsomorphicFn() ``` The universal `fetcher` already is that boundary, so a feature writes the fetch -once: +once - naming the plugin, and letting the route infer: ```ts title="plugins/devices/src/lib/fetch-devices.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' -const users = clientModule('@vitnode/core') - -// [!code ++:8] +// [!code ++:10] export const fetchDevices = async () => { - const response = await fetcher(users, { + const response = await fetcher({ + plugin: '@vitnode/core', method: 'get', module: 'users', path: '/devices', @@ -82,15 +78,15 @@ genuinely differ_ - reading a cookie from the request on the server and from Use `createServerFn` when your host application has to touch the response itself - which in practice means cookies: -```ts title="apps/web/src/lib/auth.ts" +```ts title="apps/web/src/lib/auth.server.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, { + const response = await fetcher({ + plugin: '@vitnode/core', // [!code ++] allowSaveCookies: true, args: { body: data }, @@ -103,12 +99,27 @@ export const signIn = createServerFn({ method: 'POST' }) }) ``` +The call is the same shape as the universal one - `plugin`, `module`, `path`, +`method`, `args` - and it is typed from the same registry. What the server +fetcher adds is exactly what a browser cannot do: + +| Option | What it does | +| :------------------ | :------------------------------------------------------------ | +| `allowSaveCookies` | relays the API's `Set-Cookie` headers onto the host response | +| `additionalHeaders` | adds headers a browser may not forge, `Cookie` included | +| `origin` | calls a different API origin than the configured one | + 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. +Keep code that imports `@vitnode/core/tanstack/fetcher/server` in a +`*.server.ts` file or inside a server function handler. The universal fetcher +rejects these three options at compile time, so a call that needs one cannot +end up in a shared module by accident. + ## Learn More diff --git a/apps/web/content/docs/ui/combobox.mdx b/apps/web/content/docs/ui/combobox.mdx index 6e7aca45b..fe6c208ec 100644 --- a/apps/web/content/docs/ui/combobox.mdx +++ b/apps/web/content/docs/ui/combobox.mdx @@ -62,7 +62,7 @@ const formSchema = z.object({ import { z } from "zod"; import { AutoForm } from "@vitnode/core/components/form/auto-form"; import { AutoFormCombobox } from "@vitnode/core/components/form/fields/combobox"; -import { fetcherClient } from "@vitnode/core/lib/fetcher-client"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; ``` ```ts @@ -81,7 +81,8 @@ const formSchema = z.object({ { - const res = await fetcherClient(categoriesModule, { + const res = await fetcher({ + plugin: "@acme/blog", path: "/", method: "get", module: "categories", diff --git a/apps/web/content/docs/ui/data-table.mdx b/apps/web/content/docs/ui/data-table.mdx index effd19ed9..72ef38bc2 100644 --- a/apps/web/content/docs/ui/data-table.mdx +++ b/apps/web/content/docs/ui/data-table.mdx @@ -330,19 +330,17 @@ When the set of options is small and known ahead of time, pass them directly via ### Async filters -When the options come from the API (for example a large or searchable list), provide an `onSearch` callback instead of `options`. It runs - debounced - as the user types and should return results already filtered and capped by the server. Use a [Server Action](https://react.dev/reference/rsc/server-functions) so the lookup runs server-side: - -```tsx title="search-roles.action.tsx" -"use server"; +When the options come from the API (for example a large or searchable list), provide an `onSearch` callback instead of `options`. It runs - debounced - as the user types and should return results already filtered and capped by the server. The universal fetcher answers from the browser here, with the visitor's own cookies: +```tsx title="search-roles.tsx" import type { FilterOption } from "@vitnode/core/components/table/filters"; -import { adminModule } from "@/api/modules/admin/admin.module"; import { RoleFormat } from "@vitnode/core/components/role-format"; -import { fetcher } from "@vitnode/core/lib/fetcher"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; export const searchRoles = async (search: string): Promise => { - const res = await fetcher(adminModule, { + const res = await fetcher({ + plugin: "@vitnode/core", path: "/list", method: "get", module: "admin/roles", @@ -509,8 +507,7 @@ import { DataTable, SearchParamsDataTable, } from "@vitnode/core/components/table/data-table"; -import { userModule } from "@/api/modules/user/user.module"; -import { fetcher } from "@vitnode/core/lib/fetcher"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; export const UsersView = async ({ searchParams, @@ -518,10 +515,11 @@ export const UsersView = async ({ searchParams: Promise; }) => { const query = await searchParams; - const res = await fetcher(userModule, { - path: "/users", + const res = await fetcher({ + plugin: "@vitnode/core", + path: "/list", method: "get", - module: "user", + module: "admin/users", args: { query, }, diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index 24f27a17a..e22e029fe 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -19,6 +19,7 @@ export default [ "src/routeTree.gen.ts", "src/plugin-routes.gen.ts", "src/admin-nav.gen.ts", + "src/api-registry.gen.ts", "src/content-registry.gen.ts", "src/package-messages.gen.ts", "scripts/**", diff --git a/apps/web/src/api-registry.gen.ts b/apps/web/src/api-registry.gen.ts new file mode 100644 index 000000000..a0d5aa52e --- /dev/null +++ b/apps/web/src/api-registry.gen.ts @@ -0,0 +1,15 @@ +/* eslint-disable */ + +// This file is generated by VitNode. Do not edit it, and do not format it. + +import type * as apiPlugin0 from '@vitnode/blog/config.api' +import type * as apiPlugin1 from '@vitnode/example/config.api' + +declare module '@vitnode/core/lib/fetcher/registry' { + interface ApiPluginRegistry { + '@vitnode/blog': typeof apiPlugin0 + '@vitnode/example': typeof apiPlugin1 + } +} + +export type { ApiPluginRegistry } from '@vitnode/core/lib/fetcher/registry' diff --git a/packages/create-vitnode-app/copy-of-vitnode-plugin/root/global.d.ts b/packages/create-vitnode-app/copy-of-vitnode-plugin/root/global.d.ts deleted file mode 100644 index 2661cefc7..000000000 --- a/packages/create-vitnode-app/copy-of-vitnode-plugin/root/global.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -/// - -import coreApi from "@vitnode/core/locales/api/en.json" with { type: "json" }; -import core from "@vitnode/core/locales/en.json" with { type: "json" }; -import plugin from "./src/locales/en.json" with { type: "json" }; - -// `AppConfig` is declared by `use-intl`, which is what VitNode renders every -// string through on every host, and a plugin is compiled into its own `dist` -// and imported by whichever app installed it - so a type-level dependency on -// one host's framework is one every installing app inherits. `use-intl` must be -// a direct dependency of this package for the reference above to resolve under -// pnpm's strict `node_modules`. -// -// A plugin can render on both sides (UI components and, e.g., emails), so it -// types keys against both of core's trees. Add your own server tree here too -// (`./src/locales/api/en.json`) once your plugin sends email. -declare module "use-intl" { - interface AppConfig { - Messages: typeof plugin & typeof core & typeof coreApi; - } -} diff --git a/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts b/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts index 8ea3fb5b5..2231e00e7 100644 --- a/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts +++ b/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "vitest"; import { - pluginApiClientTemplate, pluginApiConfigTemplate, pluginApiModuleTemplate, pluginApiRouteTemplate, pluginApiVariableName, pluginConfigTemplate, pluginConstTemplate, + pluginGlobalTypesTemplate, pluginMessagesTemplate, pluginPackageExports, pluginRouteModuleTemplate, @@ -147,8 +147,9 @@ describe("the generated route module", () => { expect(imports).toEqual([ "@vitnode/core/routing", "@vitnode/core/routing", + "@vitnode/core/tanstack/fetcher", "use-intl", - "@/api/client", + "@/const", ]); expect(imports).not.toContain("@tanstack/react-router"); }); @@ -236,6 +237,7 @@ describe("the generated constant", () => { Object.entries(files) .filter( ([file]) => + file !== "global.d.ts" && file !== "src/const.ts" && file !== "src/locales/en.json" && file !== "src/pages/home-page.tsx" && @@ -279,21 +281,45 @@ describe("the generated API module", () => { }); }); -describe("the generated API client", () => { - it("names the module as a type, which is what keeps Hono out of the browser", () => { - const client = pluginApiClientTemplate(); +describe("the generated type registrations", () => { + it("imports the API factory as a type, which is what keeps Hono out of the browser", () => { + const types = pluginGlobalTypesTemplate("@acme/blog"); - expect(client).toContain( - 'import type { helloModule } from "@/api/modules/hello/hello.module";', + expect(types).toContain( + 'import type { blogApiPlugin } from "./src/config.api";', ); - expect(client).not.toMatch(/^import \{[^}]*helloModule/m); + expect(types).not.toMatch(/^import \{[^}]*ApiPlugin/m); }); - it("annotates the client, which keeps the plugin's declarations small", () => { - expect(pluginApiClientTemplate()).toContain( - "export const helloApi: ApiClient =", + it("registers the plugin's API under its own id, so its pages can call it", () => { + const types = pluginGlobalTypesTemplate("@acme/blog"); + + expect(types).toContain( + 'declare module "@vitnode/core/lib/fetcher/registry" {', + ); + expect(types).toContain("interface ApiPluginRegistry {"); + expect(types).toContain('"@acme/blog": typeof blogApiPlugin;'); + }); + + it("keeps the message tree registration a generated plugin always had", () => { + const types = pluginGlobalTypesTemplate("blog"); + + expect(types).toContain('declare module "use-intl" {'); + expect(types).toContain( + "Messages: typeof plugin & typeof core & typeof coreApi;", ); }); + + it("loads the registry module it augments, or the augmentation would merge into nothing", () => { + expect(pluginGlobalTypesTemplate("blog")).toContain( + 'export type { ApiPluginRegistry } from "@vitnode/core/lib/fetcher/registry";', + ); + }); + + it("evaluates nothing", () => { + // A `.d.ts` that ran the factory would build a Hono app at type-check time. + expect(pluginGlobalTypesTemplate("blog")).not.toContain("()"); + }); }); describe("the generated API config", () => { @@ -380,7 +406,7 @@ describe("the scaffold as a whole", () => { }); }); - it("writes a file for every module the API config and client name", () => { + it("writes a file for every module the API config names", () => { const files = pluginRouteScaffold("@acme/blog"); expect(Object.keys(files)).toContain( @@ -389,9 +415,10 @@ describe("the scaffold as a whole", () => { expect(Object.keys(files)).toContain( "src/api/modules/hello/hello.route.ts", ); - expect(Object.keys(files)).toContain("src/api/client.ts"); expect(Object.keys(files)).toContain("src/config.api.ts"); expect(Object.keys(files)).toContain("src/const.ts"); + expect(Object.keys(files)).toContain("global.d.ts"); + expect(Object.keys(files)).not.toContain("src/api/client.ts"); }); it("writes the messages barrel the config registers", () => { @@ -409,7 +436,8 @@ describe("the scaffold as a whole", () => { // reaches an app through its package exports, and the app's own generated // registry is rewritten from the plugin list on every build. Object.keys(pluginRouteScaffold("blog")).forEach(file => { - expect(file.startsWith("src/")).toBe(true); + expect(file.startsWith("src/") || file === "global.d.ts").toBe(true); + expect(file).not.toContain(".."); }); }); diff --git a/packages/create-vitnode-app/src/plugin/create/route-templates.ts b/packages/create-vitnode-app/src/plugin/create/route-templates.ts index 1453d61f9..3a37319a8 100644 --- a/packages/create-vitnode-app/src/plugin/create/route-templates.ts +++ b/packages/create-vitnode-app/src/plugin/create/route-templates.ts @@ -36,9 +36,10 @@ export const pluginRouteModuleTemplate = (pluginName: string): string => `import type { PluginRoutePageProps } from "@vitnode/core/routing"; import { definePluginRoute } from "@vitnode/core/routing"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; import { useTranslations } from "use-intl"; -import { helloApi } from "@/api/client"; +import { CONFIG_PLUGIN } from "@/const"; interface HelloMessage { message: string; @@ -46,7 +47,8 @@ interface HelloMessage { export const route = definePluginRoute({ load: async () => { - const response = await helloApi.fetch({ + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, method: "get", module: "hello", path: "/", @@ -221,20 +223,6 @@ export const helloModule = buildModule({ }); `; -export const pluginApiClientTemplate = (): string => - `import type { ApiClient } from "@vitnode/core/tanstack/fetcher"; - -import { createApiClient } from "@vitnode/core/tanstack/fetcher"; - -import type { helloModule } from "@/api/modules/hello/hello.module"; - -import { CONFIG_PLUGIN } from "@/const"; - -export const helloApi: ApiClient = createApiClient< - typeof helloModule ->(CONFIG_PLUGIN.pluginId); -`; - export const pluginApiConfigTemplate = (pluginName: string): string => `import { buildApiPlugin } from "@vitnode/core/api/lib/plugin"; @@ -249,6 +237,30 @@ export const ${pluginApiVariableName(pluginName)} = () => }); `; +export const pluginGlobalTypesTemplate = (pluginName: string): string => + `/// + +import coreApi from "@vitnode/core/locales/api/en.json" with { type: "json" }; +import core from "@vitnode/core/locales/en.json" with { type: "json" }; +import plugin from "./src/locales/en.json" with { type: "json" }; + +import type { ${pluginApiVariableName(pluginName)} } from "./src/config.api"; + +declare module "use-intl" { + interface AppConfig { + Messages: typeof plugin & typeof core & typeof coreApi; + } +} + +declare module "@vitnode/core/lib/fetcher/registry" { + interface ApiPluginRegistry { + "${pluginName}": typeof ${pluginApiVariableName(pluginName)}; + } +} + +export type { ApiPluginRegistry } from "@vitnode/core/lib/fetcher/registry"; +`; + /** * What an app may import from this plugin. * @@ -280,7 +292,7 @@ export const pluginPackageExports = (): Record< export const pluginRouteScaffold = ( pluginName: string, ): Record => ({ - "src/api/client.ts": pluginApiClientTemplate(), + "global.d.ts": pluginGlobalTypesTemplate(pluginName), "src/api/modules/hello/hello.module.ts": pluginApiModuleTemplate(), "src/api/modules/hello/hello.route.ts": pluginApiRouteTemplate(), "src/config.api.ts": pluginApiConfigTemplate(pluginName), diff --git a/packages/create-vitnode-app/src/plugin/create/scaffold-typecheck.test.ts b/packages/create-vitnode-app/src/plugin/create/scaffold-typecheck.test.ts new file mode 100644 index 000000000..4d0e21b16 --- /dev/null +++ b/packages/create-vitnode-app/src/plugin/create/scaffold-typecheck.test.ts @@ -0,0 +1,177 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync } from "node:fs"; +import { + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { pluginRouteScaffold } from "./route-templates.js"; + +const PLUGIN_NAME = "@acme/notes"; + +const repoRoot = resolve(import.meta.dirname, "../../../../.."); +const corePackage = join(repoRoot, "packages", "vitnode"); +const configPackage = join(repoRoot, "packages", "config"); +const templateRoot = join( + repoRoot, + "packages", + "create-vitnode-app", + "copy-of-vitnode-plugin", + "root", +); + +const coreIsBuilt = existsSync( + join(corePackage, "dist", "src", "tanstack", "fetcher", "index.d.ts"), +); + +const PROBE = `import { fetcher } from "@vitnode/core/tanstack/fetcher"; + +import { CONFIG_PLUGIN } from "@/const"; + +export const hello = async () => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "hello", + path: "/", + }); + + const status: 200 = response.status; + const message: string = (await response.json()).message; + + return { message, status }; +}; + +export const literal = async () => + await fetcher({ + plugin: "${PLUGIN_NAME}", + method: "get", + module: "hello", + path: "/", + }); + +export const wrongModule = async () => + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + // @ts-expect-error -- the scaffold mounts only \`hello\` + module: "notes", + path: "/", + }); + +export const wrongMethod = async () => + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + // @ts-expect-error -- \`/\` is a \`get\` + method: "post", + module: "hello", + path: "/", + }); + +export const wrongPlugin = async () => + await fetcher({ + // @ts-expect-error -- only registered plugins are callable + plugin: "@acme/other", + method: "get", + module: "hello", + path: "/", + }); +`; + +const linkEntries = async (from: string, into: string): Promise => { + for (const entry of readdirSync(from, { withFileTypes: true })) { + if (entry.name === ".bin") continue; + + const target = join(from, entry.name); + const link = join(into, entry.name); + + if (entry.name.startsWith("@")) { + await mkdir(link, { recursive: true }); + await linkEntries(target, link); + continue; + } + + if (!existsSync(link)) await symlink(target, link); + } +}; + +const writeScaffold = async (pluginPath: string): Promise => { + const files = { + ...pluginRouteScaffold(PLUGIN_NAME), + "src/probe.ts": PROBE, + "tsconfig.check.json": JSON.stringify({ + compilerOptions: { + declaration: false, + declarationMap: false, + emitDeclarationOnly: false, + incremental: false, + noEmit: true, + }, + extends: "./tsconfig.json", + }), + "tsconfig.json": await readFile( + join(templateRoot, "tsconfig.json"), + "utf8", + ), + }; + + for (const [file, contents] of Object.entries(files)) { + await mkdir(dirname(join(pluginPath, file)), { recursive: true }); + await writeFile(join(pluginPath, file), contents, "utf8"); + } + + const modules = join(pluginPath, "node_modules"); + await mkdir(join(modules, "@vitnode"), { recursive: true }); + await symlink(corePackage, join(modules, "@vitnode", "core")); + await symlink(configPackage, join(modules, "@vitnode", "config")); + await linkEntries(join(corePackage, "node_modules"), modules); +}; + +const typecheck = (pluginPath: string) => + spawnSync( + process.execPath, + [ + join(pluginPath, "node_modules", "typescript", "bin", "tsc"), + "-p", + join(pluginPath, "tsconfig.check.json"), + "--pretty", + "false", + ], + { cwd: pluginPath, encoding: "utf8" }, + ); + +describe.skipIf(!coreIsBuilt)( + "a generated plugin type-checks against the fetcher it ships with", + () => { + let pluginPath = ""; + let result: ReturnType; + + beforeAll(async () => { + pluginPath = await mkdtemp(join(tmpdir(), "vitnode-plugin-scaffold-")); + await writeScaffold(pluginPath); + result = typecheck(pluginPath); + }, 240_000); + + afterAll(async () => { + if (pluginPath) await rm(pluginPath, { force: true, recursive: true }); + }); + + it("compiles the scaffold and a typed call to its own endpoint", () => { + expect(result.stdout + result.stderr).toBe(""); + expect(result.status).toBe(0); + }); + + it("rejects a module, method or plugin the scaffold does not register", () => { + // The `@ts-expect-error` lines in the probe are consumed only when the + // call really is an error; an unused one fails the compile above. + expect(PROBE.match(/@ts-expect-error/g)).toHaveLength(3); + }); + }, +); diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 7a09c3ed2..cfe0bb713 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -125,6 +125,11 @@ "types": "./dist/src/framework/admin-nav/index.d.ts", "default": "./dist/src/framework/admin-nav/index.js" }, + "./framework/api-registry": { + "import": "./dist/src/framework/api-registry/index.js", + "types": "./dist/src/framework/api-registry/index.d.ts", + "default": "./dist/src/framework/api-registry/index.js" + }, "./framework/content-registry": { "import": "./dist/src/framework/content-registry/index.js", "types": "./dist/src/framework/content-registry/index.d.ts", diff --git a/packages/vitnode/src/api/lib/module.ts b/packages/vitnode/src/api/lib/module.ts index 9e8048d11..3e252e284 100644 --- a/packages/vitnode/src/api/lib/module.ts +++ b/packages/vitnode/src/api/lib/module.ts @@ -73,7 +73,7 @@ export function buildModule< routes: Routes; searchIndexers?: SearchIndexer[]; webSockets?: BuildWebSocketReturn[]; -}): BuildModuleReturn { +}): BuildModuleReturn, M, Routes, Modules> { const hono = new OpenAPIHono(); if (routes) { diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts index 10bc52709..b306a3757 100644 --- a/packages/vitnode/src/api/lib/plugin.ts +++ b/packages/vitnode/src/api/lib/plugin.ts @@ -22,34 +22,47 @@ import { validateSearchIndexers } from "../models/search"; import { checkPluginId } from "./check-plugin-id"; import { applyModuleTags } from "./openapi-tags"; -export interface BuildPluginApiReturn { +export interface BuildPluginApiReturn< + P extends string = string, + Modules extends readonly BaseBuildModuleReturn

[] = + readonly BaseBuildModuleReturn

[], +> { contentModels?: AnyContentModel[]; contentTypes?: AnyContentTypeDefinition[]; cronJobs?: Omit[]; events?: Omit[]; hono: OpenAPIHono; messages?: LocaleMessagesMap; + modules: Modules; openApiTags?: string[]; permissionStaff?: PermissionStaffConfig; - pluginId: string; + pluginId: P; queueTasks?: Omit[]; searchIndexers?: SearchIndexer[]; webSockets?: Omit[]; } -export function buildApiPlugin

({ +export type AnyBuildPluginApiReturn = BuildPluginApiReturn< + string, + readonly BaseBuildModuleReturn[] +>; + +export function buildApiPlugin< + const P extends string, + const Modules extends readonly BuildModuleReturn[] = readonly [], +>({ pluginId, messages, - modules = [], + modules = [] as unknown as Modules, permissionStaff, searchIndexers, }: { messages?: LocaleMessagesMap; - modules?: BuildModuleReturn[]; + modules?: Modules; permissionStaff?: PermissionStaffConfig; pluginId: P; searchIndexers?: SearchIndexer[]; -}): BuildPluginApiReturn { +}): BuildPluginApiReturn { // Run for checking if the plugin is valid checkPluginId(pluginId); @@ -97,6 +110,7 @@ export function buildApiPlugin

({ return { pluginId, messages, + modules, hono, openApiTags: [...new Set(openApiTags)], contentModels, diff --git a/packages/vitnode/src/api/modules.client.ts b/packages/vitnode/src/api/modules.client.ts deleted file mode 100644 index 48d920a61..000000000 --- a/packages/vitnode/src/api/modules.client.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { BaseBuildModuleReturn } from "@/api/lib/module"; -import type { adminModule as adminModuleType } from "@/api/modules/admin/admin.module"; -import type { cronAdminModule as cronAdminModuleType } from "@/api/modules/admin/advanced/cron/cron.admin.module"; -import type { queueAdminModule as queueAdminModuleType } from "@/api/modules/admin/advanced/queue/queue.admin.module"; -import type { debugAdminModule as debugAdminModuleType } from "@/api/modules/admin/debug/debug.admin.module"; -import type { filesAdminModule as filesAdminModuleType } from "@/api/modules/admin/files/files.admin.module"; -import type { middlewareModule as middlewareModuleType } from "@/api/modules/middleware/middleware.module"; -import type { searchModule as searchModuleType } from "@/api/modules/search/search.module"; -import type { userFilesModule as userFilesModuleType } from "@/api/modules/users/files/files.module"; -import type { userImagesModule as userImagesModuleType } from "@/api/modules/users/images/images.module"; -import type { usersModule as usersModuleType } from "@/api/modules/users/users.module"; - -import { CONFIG_PLUGIN } from "@/config"; - -const moduleFor = ( - pluginId: T["pluginId"], -): T => ({ pluginId }) as unknown as T; - -export const adminModule = moduleFor( - CONFIG_PLUGIN.pluginId, -); - -export const cronAdminModule = moduleFor( - CONFIG_PLUGIN.pluginId, -); - -export const debugAdminModule = moduleFor( - CONFIG_PLUGIN.pluginId, -); - -export const filesAdminModule = moduleFor( - CONFIG_PLUGIN.pluginId, -); - -export const middlewareModule = moduleFor( - CONFIG_PLUGIN.pluginId, -); - -export const queueAdminModule = moduleFor( - CONFIG_PLUGIN.pluginId, -); - -export const searchModule = moduleFor( - CONFIG_PLUGIN.pluginId, -); - -export const userFilesModule = moduleFor( - CONFIG_PLUGIN.pluginId, -); - -export const userImagesModule = moduleFor( - CONFIG_PLUGIN.pluginId, -); - -export const usersModule = moduleFor( - CONFIG_PLUGIN.pluginId, -); - -export const pluginModule = moduleFor; diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index 4148fa915..5f963bb4c 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -182,6 +182,7 @@ export const defineContentType = < ContentDeliveryNoIndexField > | { enabled: false } = { enabled: false }, + TPublicPath extends string = string, >({ admin = {}, delivery, @@ -218,7 +219,8 @@ export const defineContentType = < * one exposed slug field. Omit it and nothing public is generated. */ publicApi?: - ContentPublicApiConfig | { enabled: TPublicEnabled }; + | ContentPublicApiConfig + | { enabled: TPublicEnabled }; /** Opts into the draft/published lifecycle. Omit to stay on Stage 1 behaviour. */ publication?: ContentPublicationConfig | { enabled: TPublication }; @@ -235,7 +237,8 @@ export const defineContentType = < ContentPreviewEnabled, ContentSchedulingEnabled, ContentLocalizationEnabled, - ContentDeliveryEnabled + ContentDeliveryEnabled, + TPublicPath > => { if (!CONTENT_ID_PATTERN.test(id)) { throw new ContentEngineError( @@ -442,7 +445,8 @@ export const defineContentType = < ContentPreviewEnabled, ContentSchedulingEnabled, ContentLocalizationEnabled, - ContentDeliveryEnabled + ContentDeliveryEnabled, + TPublicPath > = { admin: resolvedAdmin, advanced: resolvedAdvanced, @@ -467,7 +471,8 @@ export const defineContentType = < }, publicApi: resolvedPublicApi as ResolvedContentPublicApiConfig< TPublicField, - TPublicEnabled + TPublicEnabled, + TPublicPath >, schemas: buildContentSchemas< ContentTypeDefinition< @@ -481,7 +486,8 @@ export const defineContentType = < ContentPreviewEnabled, ContentSchedulingEnabled, ContentLocalizationEnabled, - ContentDeliveryEnabled + ContentDeliveryEnabled, + TPublicPath > >({ admin: resolvedAdmin, diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts index 5d875b013..4a16e3318 100644 --- a/packages/vitnode/src/content/server/index.ts +++ b/packages/vitnode/src/content/server/index.ts @@ -139,6 +139,11 @@ export type { } from "./preview-token"; export { contentPublicLocaleStates } from "./public-locales"; export { buildContentPublicModule } from "./public-module"; +export type { + ContentPublicModule, + ContentPublicModuleOf, + ContentPublicRoutes, +} from "./public-module"; export { buildContentPublicRoutes } from "./public-routes"; export { contentPublicSelection, diff --git a/packages/vitnode/src/content/server/module.ts b/packages/vitnode/src/content/server/module.ts index ade100a96..d99b67c1a 100644 --- a/packages/vitnode/src/content/server/module.ts +++ b/packages/vitnode/src/content/server/module.ts @@ -15,7 +15,7 @@ export const buildContentAdminModule =

({ }: { contentTypes: AnyContentModel[]; pluginId: P; -}): BuildModuleReturn => { +}): BuildModuleReturn, "content"> => { const modules = contentTypes.map(model => { // Every `src/database/*.ts` has loaded by the time this module is built, so // it is the first safe moment to check that each relation points at the diff --git a/packages/vitnode/src/content/server/public-module.ts b/packages/vitnode/src/content/server/public-module.ts index a96fa5c14..f040c73be 100644 --- a/packages/vitnode/src/content/server/public-module.ts +++ b/packages/vitnode/src/content/server/public-module.ts @@ -1,16 +1,45 @@ import type { BuildModuleReturn } from "../../api/lib/module"; +import type { AnyContentTypeDefinition } from "../types"; import type { AnyContentModel } from "./model"; import { buildModule } from "../../api/lib/module"; import { buildContentPublicRoutes } from "./public-routes"; -export const buildContentPublicModule =

({ +export type ContentPublicRoutes< + P extends string, + TDefinition extends AnyContentTypeDefinition, +> = ReturnType>; + +export type ContentPublicModuleOf

= Model extends { + definition: infer TDefinition extends AnyContentTypeDefinition; +} + ? TDefinition extends { + publicApi: { enabled: true; path: infer Path extends string }; + } + ? BuildModuleReturn> + : never + : never; + +export type ContentPublicModule< + P extends string, + Models extends readonly AnyContentModel[], +> = BuildModuleReturn< + P, + "content", + [], + ContentPublicModuleOf[] +>; + +export const buildContentPublicModule = < + const P extends string, + const Models extends readonly AnyContentModel[], +>({ contentTypes, pluginId, }: { - contentTypes: AnyContentModel[]; + contentTypes: Models; pluginId: P; -}): BuildModuleReturn => { +}): ContentPublicModule, Models> => { const modules = contentTypes .filter(model => model.definition.publicApi.enabled) .map(model => @@ -27,5 +56,5 @@ export const buildContentPublicModule =

({ routes: [], modules, // No `contentTypes` - see the warning above. - }); + }) as ContentPublicModule, Models>; }; diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 916813969..0d3f2f361 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -923,7 +923,10 @@ type ExposableFlatFieldKeys = Exclude< * conditional keys off it, and a widened `boolean` would silently resolve to * "no public API". */ -export interface ContentPublicApiConfig { +export interface ContentPublicApiConfig< + TField extends string = string, + TPath extends string = string, +> { defaultOrder?: "asc" | "desc"; /** Defaults to `publishedAt`. Must be orderable. */ defaultOrderBy?: "publishedAt" | TField; @@ -935,7 +938,7 @@ export interface ContentPublicApiConfig { /** Columns `orderBy` accepts, besides `publishedAt`. Defaults to none. */ orderableFields?: readonly TField[]; /** One lowercase URL segment, e.g. `articles`. Never `admin`. */ - path: string; + path: TPath; /** Columns `search` scans. Defaults to none. */ searchableFields?: readonly TField[]; } @@ -952,6 +955,7 @@ export interface ContentPublicApiConfig { export interface ResolvedContentPublicApiConfig< TField extends string = string, TEnabled extends boolean = boolean, + TPath extends string = string, > { defaultOrder: "asc" | "desc"; defaultOrderBy: string; @@ -959,7 +963,7 @@ export interface ResolvedContentPublicApiConfig< fields: TField[]; filterableFields: string[]; orderableFields: string[]; - path: string; + path: TPath; searchableFields: string[]; /** The exposed slug field the detail route resolves by. */ slugField: string; @@ -1621,6 +1625,7 @@ export interface ContentTypeDefinition< TSchedulingEnabled extends boolean = boolean, TLocalizationEnabled extends boolean = boolean, TDeliveryEnabled extends boolean = boolean, + TPublicPath extends string = string, > { admin: ResolvedContentAdminConfig; /** Generated junction tables, child tables and the leaf-path mapping. */ @@ -1644,7 +1649,11 @@ export interface ContentTypeDefinition< localization: ResolvedContentLocalizationConfig; /** Derived from `admin.permissionModule`, or the id without its plugin segment. */ permissionModule: string; - publicApi: ResolvedContentPublicApiConfig; + publicApi: ResolvedContentPublicApiConfig< + TPublicField, + TPublicEnabled, + TPublicPath + >; publication: ResolvedContentPublicationConfig; /** Zod schemas generated from `fields`. */ schemas: ContentSchemas< diff --git a/packages/vitnode/src/framework/api-registry/generate.test.ts b/packages/vitnode/src/framework/api-registry/generate.test.ts new file mode 100644 index 000000000..967683f26 --- /dev/null +++ b/packages/vitnode/src/framework/api-registry/generate.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; + +import type { ResolvedApiPluginModule } from "./types.js"; + +import { + API_REGISTRY_SPECIFIER, + generateApiRegistrySource, +} from "./generate.js"; + +const BLOG: ResolvedApiPluginModule = { + pluginId: "@acme/blog", + specifier: "@acme/blog/config.api", +}; + +const SHOP: ResolvedApiPluginModule = { + pluginId: "@acme/shop", + specifier: "@acme/shop/config.api", +}; + +describe("generateApiRegistrySource", () => { + it("imports every plugin API module as a type and nothing else", () => { + const source = generateApiRegistrySource([BLOG, SHOP]); + + expect(source).toContain( + "import type * as apiPlugin0 from '@acme/blog/config.api'", + ); + expect(source).toContain( + "import type * as apiPlugin1 from '@acme/shop/config.api'", + ); + expect(source).not.toMatch(/^import (?!type )/m); + expect(source).not.toContain("()"); + }); + + it("augments the registry the fetcher reads, keyed by plugin id", () => { + const source = generateApiRegistrySource([BLOG]); + + expect(source).toContain(`declare module '${API_REGISTRY_SPECIFIER}'`); + expect(source).toContain("interface ApiPluginRegistry {"); + expect(source).toContain("'@acme/blog': typeof apiPlugin0"); + }); + + it("sorts by plugin id, whatever order it was handed", () => { + expect(generateApiRegistrySource([SHOP, BLOG])).toBe( + generateApiRegistrySource([BLOG, SHOP]), + ); + expect( + generateApiRegistrySource([SHOP, BLOG]).indexOf("@acme/blog"), + ).toBeLessThan( + generateApiRegistrySource([SHOP, BLOG]).indexOf("@acme/shop"), + ); + }); + + it("stays a module with an empty registry, so the declaration augments rather than replaces", () => { + const source = generateApiRegistrySource([]); + + expect(source).toContain("interface ApiPluginRegistry {\n }"); + expect(source).not.toContain("import "); + expect( + source + .trimEnd() + .endsWith( + `export type { ApiPluginRegistry } from '${API_REGISTRY_SPECIFIER}'`, + ), + ).toBe(true); + }); + + it("brings the registry module into the program, which an augmentation alone does not", () => { + // TypeScript merges `declare module 'x'` only into a module the program has + // loaded through an import; a type-only re-export is that import. + const source = generateApiRegistrySource([BLOG]); + + expect(source).toContain( + `export type { ApiPluginRegistry } from '${API_REGISTRY_SPECIFIER}'`, + ); + expect(source).not.toContain("export {}"); + }); + + it("escapes a specifier rather than trusting a package name", () => { + const source = generateApiRegistrySource([ + { pluginId: "@acme/it's", specifier: "@acme/it's/config.api" }, + ]); + + expect(source).toContain("'@acme/it\\'s/config.api'"); + expect(source).toContain("'@acme/it\\'s': typeof apiPlugin0"); + }); + + it("is a pure function of its input", () => { + expect(generateApiRegistrySource([BLOG, SHOP])).toBe( + generateApiRegistrySource([BLOG, SHOP]), + ); + }); +}); diff --git a/packages/vitnode/src/framework/api-registry/generate.ts b/packages/vitnode/src/framework/api-registry/generate.ts new file mode 100644 index 000000000..6a595ad1f --- /dev/null +++ b/packages/vitnode/src/framework/api-registry/generate.ts @@ -0,0 +1,46 @@ +import type { ResolvedApiPluginModule } from "./types.js"; + +import { toSingleQuotedLiteral } from "../plugin-routes/resolve.js"; + +export const API_REGISTRY_SPECIFIER = "@vitnode/core/lib/fetcher/registry"; + +const HEADER = `/* eslint-disable */ + +// This file is generated by VitNode. Do not edit it, and do not format it. +`; + +const importLines = (modules: readonly ResolvedApiPluginModule[]): string => + modules + .map( + (module, index) => + `import type * as apiPlugin${index} from ${toSingleQuotedLiteral(module.specifier)}`, + ) + .join("\n"); + +const entryLines = (modules: readonly ResolvedApiPluginModule[]): string => + modules + .map( + (module, index) => + ` ${toSingleQuotedLiteral(module.pluginId)}: typeof apiPlugin${index}`, + ) + .join("\n"); + +export const generateApiRegistrySource = ( + modules: readonly ResolvedApiPluginModule[], +): string => { + const sorted = [...modules].sort((a, b) => + a.pluginId === b.pluginId ? 0 : a.pluginId < b.pluginId ? -1 : 1, + ); + + const imports = sorted.length === 0 ? "" : `${importLines(sorted)}\n\n`; + const entries = sorted.length === 0 ? "" : `${entryLines(sorted)}\n`; + + return `${HEADER} +${imports}declare module ${toSingleQuotedLiteral(API_REGISTRY_SPECIFIER)} { + interface ApiPluginRegistry { +${entries} } +} + +export type { ApiPluginRegistry } from ${toSingleQuotedLiteral(API_REGISTRY_SPECIFIER)} +`; +}; diff --git a/packages/vitnode/src/framework/api-registry/index.ts b/packages/vitnode/src/framework/api-registry/index.ts new file mode 100644 index 000000000..7bf16fd77 --- /dev/null +++ b/packages/vitnode/src/framework/api-registry/index.ts @@ -0,0 +1,5 @@ +export { + API_REGISTRY_SPECIFIER, + generateApiRegistrySource, +} from "./generate.js"; +export type { ResolvedApiPluginModule } from "./types.js"; diff --git a/packages/vitnode/src/framework/api-registry/types.ts b/packages/vitnode/src/framework/api-registry/types.ts new file mode 100644 index 000000000..36a009fc4 --- /dev/null +++ b/packages/vitnode/src/framework/api-registry/types.ts @@ -0,0 +1,4 @@ +export interface ResolvedApiPluginModule { + pluginId: string; + specifier: string; +} diff --git a/packages/vitnode/src/framework/vite/no-materialized-routes.test.ts b/packages/vitnode/src/framework/vite/no-materialized-routes.test.ts index 93a7b56ea..bb819b90e 100644 --- a/packages/vitnode/src/framework/vite/no-materialized-routes.test.ts +++ b/packages/vitnode/src/framework/vite/no-materialized-routes.test.ts @@ -175,7 +175,7 @@ describe("where the generation pass is allowed to write", () => { * And each of those is a generated data file at the top of `src/` - never a * page, and never inside a directory a router reads as routes. */ - it("writes four generated data files and no source file", () => { + it("writes five generated data files and no source file", () => { // `?? destination` rather than a non-null assertion: an unresolved key is a // real possible failure - a `writeIfChanged(paths.somethingNew, …)` whose // key `pathsFor` does not declare - and it should fail the assertion below @@ -187,6 +187,7 @@ describe("where the generation pass is allowed to write", () => { expect([...files].sort()).toEqual([ "admin-nav.gen.ts", + "api-registry.gen.ts", "content-registry.gen.ts", "package-messages.gen.ts", "plugin-routes.gen.ts", diff --git a/packages/vitnode/src/framework/vite/plugin-routes.ts b/packages/vitnode/src/framework/vite/plugin-routes.ts index e6208c5e5..295efbeb3 100644 --- a/packages/vitnode/src/framework/vite/plugin-routes.ts +++ b/packages/vitnode/src/framework/vite/plugin-routes.ts @@ -14,6 +14,7 @@ import { import { pathToFileURL } from "node:url"; import type { ResolvedAdminNavModule } from "../admin-nav"; +import type { ResolvedApiPluginModule } from "../api-registry"; import type { ResolvedContentRegistryModule } from "../content-registry"; import type { PackageMessagesSource } from "../package-messages"; import type { @@ -23,6 +24,7 @@ import type { } from "../plugin-routes"; import { generateAdminNavSource } from "../admin-nav"; +import { generateApiRegistrySource } from "../api-registry"; import { generateContentRegistrySource } from "../content-registry"; import { generatePackageMessagesSource, @@ -46,6 +48,8 @@ const ADMIN_NAV_SUBPATH = "admin/nav"; const ADMIN_CONTENT_SUBPATH = "admin/content"; +const API_CONFIG_SUBPATH = "config.api"; + const ERROR_PREFIX = "[VitNode plugin routes]"; /** Where a file-based router keeps an app's own route files, by convention. */ @@ -64,6 +68,8 @@ const pathsFor = (appRoot: string) => ({ adminNav: join(appRoot, "src", "admin-nav.gen.ts"), + apiRegistry: join(appRoot, "src", "api-registry.gen.ts"), + contentRegistry: join(appRoot, "src", "content-registry.gen.ts"), packageMessages: join(appRoot, "src", "package-messages.gen.ts"), @@ -403,11 +409,17 @@ const discover = async ( ADMIN_CONTENT_SUBPATH, resolvePackageFile, ); + const apiRegistry = readOptionalPluginModules( + pluginIds, + API_CONFIG_SUBPATH, + resolvePackageFile, + ); const watch = [ ...loaded.flatMap(({ watch: file }) => file ?? []), ...adminNav.watch, ...contentRegistry.watch, + ...apiRegistry.watch, ]; onLoaded?.(watch); @@ -431,6 +443,7 @@ const discover = async ( return { adminNav: adminNav.modules, + apiRegistry: apiRegistry.modules, compiled, contentRegistry: contentRegistry.modules, packageMessages, @@ -450,19 +463,20 @@ const removeIfPresent = async (path: string): Promise => { await unlink(path); }; -/** All four generated files, from one discovery pass. */ +/** All five generated files, from one discovery pass. */ const writeGenerated = async ( appRoot: string, options: VitNodePluginRoutesOptions, onLoaded?: (watch: string[]) => void, ): Promise => { const paths = pathsFor(appRoot); - const { adminNav, compiled, contentRegistry, packageMessages } = + const { adminNav, apiRegistry, compiled, contentRegistry, packageMessages } = await discover(appRoot, options, onLoaded); await Promise.all([ writeIfChanged(paths.registry, compiled.source), writeIfChanged(paths.adminNav, generateAdminNavSource(adminNav)), + writeIfChanged(paths.apiRegistry, generateApiRegistrySource(apiRegistry)), writeIfChanged( paths.contentRegistry, generateContentRegistrySource(contentRegistry), diff --git a/packages/vitnode/src/framework/vite/projections.test.ts b/packages/vitnode/src/framework/vite/projections.test.ts index c5fc46529..a7d5ca73d 100644 --- a/packages/vitnode/src/framework/vite/projections.test.ts +++ b/packages/vitnode/src/framework/vite/projections.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from "vitest"; import type { ResolvedAdminNavModule } from "../admin-nav"; +import type { ResolvedApiPluginModule } from "../api-registry"; import type { ResolvedContentRegistryModule } from "../content-registry"; import type { PluginRouteCompilerSource } from "../plugin-routes"; import { definePluginRoutes, lazy, page } from "../../routing/tree"; import { generateAdminNavSource } from "../admin-nav"; +import { generateApiRegistrySource } from "../api-registry"; import { generateContentRegistrySource } from "../content-registry"; import { generatePackageMessagesSource, @@ -27,9 +29,11 @@ const resolverFor = const WORKSPACE = resolverFor({ "@acme/blog/admin/content": "/pkg/blog/dist/admin/content.js", "@acme/blog/admin/nav": "/pkg/blog/dist/admin/nav.js", + "@acme/blog/config.api": "/pkg/blog/dist/config.api.js", "@acme/blog/routes": "/pkg/blog/dist/routes.js", "@acme/shop/admin/content": "/pkg/shop/dist/admin/content.js", "@acme/shop/admin/nav": "/pkg/shop/dist/admin/nav.js", + "@acme/shop/config.api": "/pkg/shop/dist/config.api.js", "@acme/shop/routes": "/pkg/shop/dist/routes.js", }); @@ -75,6 +79,13 @@ const projectionsFor = (pluginIds: readonly string[]) => { WORKSPACE, ).modules, ), + apiRegistry: generateApiRegistrySource( + readOptionalPluginModules( + pluginIds, + "config.api", + WORKSPACE, + ).modules, + ), contentRegistry: generateContentRegistrySource( readOptionalPluginModules( pluginIds, @@ -97,6 +108,7 @@ const projectionsFor = (pluginIds: readonly string[]) => { const FILES = [ "adminNav", + "apiRegistry", "contentRegistry", "packageMessages", "registry", @@ -138,11 +150,14 @@ describe("determinism, across every projection at once", () => { expect(shuffled.packageMessages.indexOf("@acme/blog")).toBeLessThan( shuffled.packageMessages.indexOf("@acme/shop"), ); + expect(shuffled.apiRegistry.indexOf("@acme/blog")).toBeLessThan( + shuffled.apiRegistry.indexOf("@acme/shop"), + ); }); }); describe("a plugin is enabled, or it is not - never half of each", () => { - it("puts an enabled plugin in all four projections", () => { + it("puts an enabled plugin in all five projections", () => { const enabled = projectionsFor(BOTH); FILES.forEach(file => { @@ -150,7 +165,7 @@ describe("a plugin is enabled, or it is not - never half of each", () => { }); }); - it("removes a disabled plugin from all four, in one step", () => { + it("removes a disabled plugin from all five, in one step", () => { const disabled = projectionsFor(["@acme/blog"]); FILES.forEach(file => { @@ -209,6 +224,7 @@ describe("a plugin is enabled, or it is not - never half of each", () => { }); expect(none.adminNav).toContain("[]"); expect(none.contentRegistry).toContain("[]"); + expect(none.apiRegistry).toContain("interface ApiPluginRegistry {\n }"); // Except this one, which still registers core: an app with no plugins has // no plugin translations and every string core renders. expect(none.packageMessages).toContain("'@vitnode/core'"); diff --git a/packages/vitnode/src/lib/fetcher-client.test.ts b/packages/vitnode/src/lib/fetcher-client.test.ts index a14430da0..8b0f41c03 100644 --- a/packages/vitnode/src/lib/fetcher-client.test.ts +++ b/packages/vitnode/src/lib/fetcher-client.test.ts @@ -1,11 +1,7 @@ // @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"); +import { fetcherClient } from "./fetcher-client"; const lastInit = (fetchMock: ReturnType): RequestInit => fetchMock.mock.calls.at(-1)?.[1] as RequestInit; @@ -27,7 +23,8 @@ describe("fetcherClient", () => { }); it("sends cookies by default, so a cross-origin API still knows who is asking", async () => { - await fetcherClient(middleware, { + await fetcherClient({ + plugin: "@vitnode/core", method: "get", module: "middleware", path: "/", @@ -36,8 +33,27 @@ describe("fetcherClient", () => { expect(lastInit(fetchMock).credentials).toBe("include"); }); + it("builds the URL from the plugin id and the module path", async () => { + await fetcherClient({ + plugin: "@vitnode/core", + method: "get", + module: "users/sso", + path: "/{providerId}/callback", + args: { + params: { providerId: "google" }, + query: { code: "c", state: "s" }, + }, + }); + + const url = new URL(String(fetchMock.mock.calls.at(-1)?.[0])); + + expect(url.pathname).toBe("/api/@vitnode/core/users/sso/google/callback"); + expect(url.searchParams.get("code")).toBe("c"); + }); + it("lets a caller opt out", async () => { - await fetcherClient(middleware, { + await fetcherClient({ + plugin: "@vitnode/core", method: "get", module: "middleware", options: { credentials: "omit" }, @@ -50,7 +66,8 @@ describe("fetcherClient", () => { it("keeps the caller's other request options", async () => { const controller = new AbortController(); - await fetcherClient(middleware, { + await fetcherClient({ + plugin: "@vitnode/core", method: "get", module: "middleware", options: { signal: controller.signal }, diff --git a/packages/vitnode/src/lib/fetcher-client.ts b/packages/vitnode/src/lib/fetcher-client.ts index 03825cf34..055cf352f 100644 --- a/packages/vitnode/src/lib/fetcher-client.ts +++ b/packages/vitnode/src/lib/fetcher-client.ts @@ -1,17 +1,10 @@ -import type { - BaseBuildModuleReturn, - BuildModuleReturn, -} from "@/api/lib/module"; -import type { Route } from "@/api/lib/route"; - import type { RawApiFetchArgs } from "./fetcher/raw"; import type { - FetcherParams, + FetcherRequest, FetcherRequestOptions, - GetModulePaths, - GetValidMethodForPath, - GetValidPathsForModule, - InferResponseType, + FetcherResponse, + PluginRouteMethod, + RegisteredPluginId, } from "./fetcher/types"; import { coreFetcher } from "./fetcher/core"; @@ -19,41 +12,40 @@ import { isRateLimited, notifyRateLimited } from "./fetcher/rate-limit"; import { rawApiFetch } from "./fetcher/raw"; import { CAPTCHA_TOKEN_HEADER } from "./fetcher/request-context"; -export const clientModule = ( - pluginId: T["pluginId"], -): T => ({ pluginId }) as unknown as T; +export type { ApiPluginRegistry } from "./fetcher/registry"; +export type { + FetcherRequest, + FetcherResponse, + PluginModulePath, + PluginRouteMethod, + PluginRoutePath, + RegisteredPluginId, +} from "./fetcher/types"; + +export type FetcherClientOptions = Omit< + FetcherRequestOptions, + "additionalHeaders" | "origin" +> & { + captchaToken?: string; +}; export async function fetcherClient< + P extends RegisteredPluginId, M extends string, - Routes extends Route[], - Modules extends BaseBuildModuleReturn[], - ModuleName extends GetModulePaths, - SelectedPath extends GetValidPathsForModule, - Method extends GetValidMethodForPath< - ModuleName, - SelectedPath, - M, - Routes, - Modules - > = GetValidMethodForPath, ->( - moduleReturn: BuildModuleReturn, - { - path, - method, - module, - args, - options, - withPagination = false, - prefixPath = "", - captchaToken, - formData, - }: FetcherParams & - Omit & { - captchaToken?: string; - }, -): Promise< - InferResponseType + Path extends string, + Method extends string = PluginRouteMethod, +>({ + plugin, + module, + path, + method, + args, + options, + withPagination = false, + captchaToken, + formData, +}: FetcherClientOptions & FetcherRequest): Promise< + FetcherResponse > { const additionalHeaders: Record = {}; @@ -61,18 +53,17 @@ export async function fetcherClient< additionalHeaders[CAPTCHA_TOKEN_HEADER] = captchaToken; } - const response = await coreFetcher(moduleReturn, { + const response = await coreFetcher({ + plugin, + module, path, method, - module, args, options: { credentials: "include", ...options }, withPagination, - prefixPath, additionalHeaders, formData, - } as FetcherParams & - FetcherRequestOptions); + } as FetcherRequest & FetcherRequestOptions); if (isRateLimited(response)) { notifyRateLimited(response); diff --git a/packages/vitnode/src/lib/fetcher/core.ts b/packages/vitnode/src/lib/fetcher/core.ts index b3b1f0ebe..f47ac38ba 100644 --- a/packages/vitnode/src/lib/fetcher/core.ts +++ b/packages/vitnode/src/lib/fetcher/core.ts @@ -1,93 +1,54 @@ import type { - BaseBuildModuleReturn, - BuildModuleReturn, -} from "@/api/lib/module"; -import type { Route } from "@/api/lib/route"; - -import type { - FetcherParams, + FetcherRequest, FetcherRequestOptions, - GetModulePaths, - GetValidMethodForPath, - GetValidPathsForModule, - InferResponseType, + FetcherResponse, + PluginRouteMethod, + RegisteredPluginId, } from "./types"; import { rawApiFetch } from "./raw"; -type CoreFetcherOptions< - M extends string, - Routes extends Route[], - Modules extends BaseBuildModuleReturn[], - ModuleName extends GetModulePaths, - SelectedPath extends GetValidPathsForModule, - Method extends GetValidMethodForPath< - ModuleName, - SelectedPath, - M, - Routes, - Modules - > = GetValidMethodForPath, -> = FetcherParams & - FetcherRequestOptions; +interface FetcherInput { + body?: unknown; + params?: Record; + query?: Record; +} export async function coreFetcher< + P extends RegisteredPluginId, M extends string, - Routes extends Route[], - Modules extends BaseBuildModuleReturn[], - ModuleName extends GetModulePaths, - SelectedPath extends GetValidPathsForModule, - Method extends GetValidMethodForPath< - ModuleName, - SelectedPath, - M, - Routes, - Modules - > = GetValidMethodForPath, ->( - { pluginId }: BuildModuleReturn, - { - path, - method, - module, - args, - options, - additionalHeaders = {}, - withPagination = false, - prefixPath = "", - formData, - origin, - }: CoreFetcherOptions, -): Promise< - InferResponseType + Path extends string, + Method extends string = PluginRouteMethod, +>({ + plugin, + module, + path, + method, + args, + options, + additionalHeaders = {}, + withPagination = false, + formData, + origin, +}: FetcherRequest & FetcherRequestOptions): Promise< + FetcherResponse > { + const input = args as FetcherInput | undefined; + const response = await rawApiFetch({ additionalHeaders, - body: args && "body" in args ? args.body : undefined, + body: input?.body, formData, method, module, options, origin, - params: - args && "params" in args - ? (args.params as Record) - : undefined, + params: input?.params, path, - pluginId, - prefixPath, - query: - args && "query" in args - ? (args.query as Record) - : undefined, + pluginId: plugin, + query: input?.query, withPagination, }); - return response as InferResponseType< - M, - Routes, - Modules, - ModuleName, - SelectedPath - >; + return response as FetcherResponse; } diff --git a/packages/vitnode/src/lib/fetcher/fetcher.test-d.ts b/packages/vitnode/src/lib/fetcher/fetcher.test-d.ts index 8a63ec30a..fb09641b3 100644 --- a/packages/vitnode/src/lib/fetcher/fetcher.test-d.ts +++ b/packages/vitnode/src/lib/fetcher/fetcher.test-d.ts @@ -1,12 +1,12 @@ import { describe, expectTypeOf, it } from "vitest"; -import { usersModule } from "@/api/modules/users/users.module"; import { fetcherClient } from "@/lib/fetcher-client"; import { coreFetcher } from "@/lib/fetcher/core"; describe("a route's arguments are part of its type", () => { - it("accepts the module the API exports, not just a reference to it", async () => { - const response = await coreFetcher(usersModule, { + it("resolves the route from the registered plugin, without a module reference", async () => { + const response = await coreFetcher({ + plugin: "@vitnode/core", method: "get", module: "users", path: "/session", @@ -16,7 +16,8 @@ describe("a route's arguments are part of its type", () => { }); it("rejects a path the module does not serve", async () => { - await coreFetcher(usersModule, { + await coreFetcher({ + plugin: "@vitnode/core", method: "get", module: "users", // @ts-expect-error -- not a route on `users` @@ -26,7 +27,8 @@ describe("a route's arguments are part of its type", () => { it("rejects a call that omits a required body", async () => { // @ts-expect-error -- `/sign_in` declares a body, so `args` is required - await coreFetcher(usersModule, { + await coreFetcher({ + plugin: "@vitnode/core", method: "post", module: "users", path: "/sign_in", @@ -34,7 +36,8 @@ describe("a route's arguments are part of its type", () => { }); it("rejects a body the route does not declare", async () => { - await coreFetcher(usersModule, { + await coreFetcher({ + plugin: "@vitnode/core", // @ts-expect-error -- `nickname` is not on the sign-in schema args: { body: { email: "a@b.c", nickname: "x", password: "y" } }, method: "post", @@ -45,7 +48,8 @@ describe("a route's arguments are part of its type", () => { it("rejects a call that omits a required path parameter", async () => { // @ts-expect-error -- `/devices/{publicId}` declares params - await fetcherClient(usersModule, { + await fetcherClient({ + plugin: "@vitnode/core", method: "delete", module: "users", path: "/devices/{publicId}", @@ -53,7 +57,8 @@ describe("a route's arguments are part of its type", () => { }); it("infers the response body from the route's schema", async () => { - const response = await coreFetcher(usersModule, { + const response = await coreFetcher({ + plugin: "@vitnode/core", method: "get", module: "users", path: "/session", diff --git a/packages/vitnode/src/lib/fetcher/registry.ts b/packages/vitnode/src/lib/fetcher/registry.ts new file mode 100644 index 000000000..04d292c03 --- /dev/null +++ b/packages/vitnode/src/lib/fetcher/registry.ts @@ -0,0 +1,5 @@ +import type { newBuildPluginApiCore } from "@/api/plugin"; + +export interface ApiPluginRegistry { + "@vitnode/core": typeof newBuildPluginApiCore; +} diff --git a/packages/vitnode/src/lib/fetcher/types.ts b/packages/vitnode/src/lib/fetcher/types.ts index a543fdaf3..78afe9886 100644 --- a/packages/vitnode/src/lib/fetcher/types.ts +++ b/packages/vitnode/src/lib/fetcher/types.ts @@ -1,10 +1,8 @@ -import type { RouteConfig } from "@hono/zod-openapi"; import type { ResponseFormat } from "hono/types"; import type { StatusCode, SuccessStatusCode } from "hono/utils/http-status"; import type { z } from "zod"; -import type { BaseBuildModuleReturn } from "@/api/lib/module"; -import type { Route } from "@/api/lib/route"; +import type { ApiPluginRegistry } from "./registry"; interface ClientResponse< T, @@ -53,52 +51,160 @@ export interface ModuleSpec { readonly routes: readonly RouteShape[]; } -type SplitPath = S extends `${infer First}/${infer Rest}` - ? [First, ...SplitPath] - : S extends "" - ? [] - : [S]; - -type FindModuleNested< - M extends { modules?: readonly ModuleSpec[] }, - Path extends string[], -> = Path extends [infer First extends string, ...infer Rest extends string[]] - ? Extract[number] extends infer SubModule - ? SubModule extends ModuleSpec & { name: First } - ? Rest["length"] extends 0 - ? SubModule - : FindModuleNested - : never - : never - : M; - -type GetTargetModule< - ModulePath extends string, - MainModuleName extends string, - MainRoutes extends readonly RouteShape[], - SubModules extends readonly ModuleSpec[], -> = ModulePath extends MainModuleName - ? { modules: SubModules; name: MainModuleName; routes: MainRoutes } - : ModulePath extends `${MainModuleName}/${infer Rest}` - ? SplitPath extends infer PathArray extends string[] - ? PathArray["length"] extends 0 - ? never - : FindModuleNested<{ modules: SubModules }, PathArray> - : never - : never; +interface ApiPluginSpec { + readonly modules: readonly ModuleSpec[]; + readonly pluginId: string; +} -type ExtractPaths = - M["routes"][number]["route"]["path"]; +type ApiPluginFactory = (...args: never[]) => ApiPluginSpec; -type ExtractMethodForPath< - M extends { routes: readonly RouteShape[] }, +type ApiPluginWithId = Extract; + +type ResolveApiPluginExport< + Entry, P extends string, -> = Extract["route"]["method"]; +> = Entry extends ApiPluginFactory + ? ApiPluginWithId, P> + : Entry extends ApiPluginSpec + ? ApiPluginWithId + : never; + +type ResolveApiPluginEntry = Entry extends + ApiPluginFactory | ApiPluginSpec + ? ResolveApiPluginExport + : { + [K in keyof Entry]: ResolveApiPluginExport; + }[keyof Entry]; + +export type RegisteredPluginId = Extract; + +export type RegisteredApiPlugin

= + ResolveApiPluginEntry; + +type RegisteredModules

= + RegisteredApiPlugin

extends { + modules: infer M extends readonly ModuleSpec[]; + } + ? M + : never; + +type SubModules = Extract< + M["modules"], + readonly ModuleSpec[] +>; + +type IsTypedModule = string extends M["name"] + ? false + : string extends M["routes"][number]["route"]["path"] + ? false + : true; + +type ModulePathOf = M extends ModuleSpec + ? IsTypedModule extends true + ? `${M["name"]}/${ModulePathOf[number]>}` | M["name"] + : never + : never; + +export type PluginModulePath

= ModulePathOf< + RegisteredModules

[number] +>; + +type ModuleNamed< + Modules extends readonly ModuleSpec[], + Name extends string, +> = Extract; + +type ResolveModule< + Modules extends readonly ModuleSpec[], + Path extends string, +> = Path extends `${infer Head}/${infer Rest}` + ? ResolveModule>, Rest> + : ModuleNamed; + +export type PluginModule< + P extends RegisteredPluginId, + M extends string, +> = ResolveModule, M>; + +type RoutesOf = Mod extends { + routes: infer Routes extends readonly RouteShape[]; +} + ? Routes[number] + : never; + +export type PluginRoutePath< + P extends RegisteredPluginId, + M extends string, +> = RoutesOf>["route"]["path"]; + +export type PluginRouteMethod< + P extends RegisteredPluginId, + M extends string, + Path extends string, +> = Lowercase< + Extract< + Extract< + RoutesOf>, + { route: { path: Path } } + >["route"]["method"], + string + > +>; + +export type PluginRouteConfig< + P extends RegisteredPluginId, + M extends string, + Path extends string, + Method extends string, +> = Extract< + RoutesOf>, + { route: { method: Method; path: Path } } +>["route"]; + +export interface UnknownModulePath { + readonly validModulePaths: Valid; +} + +export interface UnknownRoutePath { + readonly validRoutePaths: Valid; +} + +export interface UnknownRouteMethod { + readonly validMethods: Valid; +} + +type ValidModule

= + M extends PluginModulePath

? M : UnknownModulePath>; + +type ValidPath< + P extends RegisteredPluginId, + M extends string, + Path extends string, +> = + M extends PluginModulePath

+ ? Path extends PluginRoutePath + ? Path + : UnknownRoutePath> + : Path; + +type ValidMethod< + P extends RegisteredPluginId, + M extends string, + Path extends string, + Method extends string, +> = + M extends PluginModulePath

+ ? Path extends PluginRoutePath + ? Method extends PluginRouteMethod + ? Method + : UnknownRouteMethod> + : Method + : Method; type ExtractZodType = T extends z.ZodType ? z.infer : never; type InferInputType< - RouteCfg extends RouteConfig, + RouteCfg, Part extends "body" | "params" | "query", > = Part extends "body" ? RouteCfg extends { @@ -120,16 +226,7 @@ type InferInputType< : undefined : never; -type FindRouteConfig< - M extends { routes: readonly Route[] }, - P extends string, - Method extends string, -> = Extract< - M["routes"][number], - { route: { method: Method; path: P } } ->["route"]; - -type BuildArgsType = { +export type BuildArgsType = { [ K in "body" | "params" | "query" as InferInputType< RouteCfg, @@ -140,31 +237,44 @@ type BuildArgsType = { ]: InferInputType; }; +export type FetcherArgs = [RouteCfg] extends [never] + ? { args?: unknown } + : keyof BuildArgsType extends never + ? { args?: undefined } + : { args: BuildArgsType }; + type InferStatusCode = K extends `${infer N extends number}` ? N : K extends number ? K : never; -export interface BaseFetcherParams< +export interface FetcherRoute< + P extends RegisteredPluginId, M extends string, - Routes extends Route[], - Modules extends BaseBuildModuleReturn[], - ModuleName extends GetModulePaths, - SelectedPath extends GetValidPathsForModule, + Path extends string, Method extends string, > { - method: Method; - module: ModuleName; - path: SelectedPath; + method: Method & ValidMethod; + module: M & ValidModule; + path: Path & ValidPath; + plugin: P; } +export type FetcherRequest< + P extends RegisteredPluginId, + M extends string, + Path extends string, + Method extends string = PluginRouteMethod, +> = FetcherArgs> & + FetcherRoute; + /** * Everything a fetcher takes that is *not* the route. * * Shared so `coreFetcher`, `fetcher` and `fetcherClient` describe one transport * rather than three that drift, and so the route half of every call stays - * exactly {@link FetcherParams} - which is what makes a missing `args` an error. + * exactly {@link FetcherRequest} - which is what makes a missing `args` an error. */ export interface FetcherRequestOptions { additionalHeaders?: HeadersInit; @@ -186,101 +296,20 @@ export interface FetcherRequestOptions { * `RawApiFetchArgs["origin"]`. */ origin?: string; - prefixPath?: string; withPagination?: boolean; } -export type FetcherParams< - M extends string, - Routes extends Route[], - Modules extends BaseBuildModuleReturn[], - ModuleName extends GetModulePaths, - SelectedPath extends GetValidPathsForModule, - Method extends GetValidMethodForPath< - ModuleName, - SelectedPath, - M, - Routes, - Modules - > = GetValidMethodForPath, - RouteConfig extends FindRouteConfig< - GetTargetModule, - SelectedPath, - Method - > = FindRouteConfig< - GetTargetModule, - SelectedPath, - Method - >, - ArgsType extends BuildArgsType = BuildArgsType, -> = BaseFetcherParams & - (keyof ArgsType extends never ? { args?: undefined } : { args: ArgsType }); - -export type GetValidPathsForModule< - ModulePath extends string, - MainModuleName extends string, - MainRoutes extends readonly RouteShape[], - SubModules extends readonly ModuleSpec[], -> = ExtractPaths< - GetTargetModule ->; - -export type GetModulePaths< - MainModule extends string, - Modules extends readonly ModuleSpec[], -> = - | `${MainModule}/${Modules[number]["name"]}/${Extract< - Modules[number]["modules"], - readonly ModuleSpec[] - >[number]["name"]}` - | `${MainModule}/${Modules[number]["name"]}` - | MainModule; - -export type GetValidMethodForPath< - ModulePath extends string, - Path extends string, - MainModuleName extends string, - MainRoutes extends readonly RouteShape[], - SubModules extends readonly ModuleSpec[], -> = Lowercase< - Extract< - ExtractMethodForPath< - GetTargetModule, - Path - >, - string - > ->; - -export type InferResponseType< - M extends string, - Routes extends Route[], - Modules extends BaseBuildModuleReturn[], - ModuleName extends GetModulePaths, - SelectedPath extends GetValidPathsForModule, - Method extends GetValidMethodForPath< - ModuleName, - SelectedPath, - M, - Routes, - Modules - > = GetValidMethodForPath, - RouteConfig extends FindRouteConfig< - GetTargetModule, - SelectedPath, - Method - > = FindRouteConfig< - GetTargetModule, - SelectedPath, - Method - >, -> = RouteConfig extends { responses: infer S } +export type InferResponseType = RouteCfg extends { + responses: infer S; +} ? { [K in keyof S]: S[K] extends infer Response ? Response extends { content: infer C } ? { [Fmt in keyof C]: ClientResponse< - C[Fmt] extends { schema: infer S } ? ExtractZodType : never, + C[Fmt] extends { schema: infer Schema } + ? ExtractZodType + : never, InferStatusCode, Fmt extends string ? Fmt : string >; @@ -289,3 +318,10 @@ export type InferResponseType< : never; }[keyof S] : never; + +export type FetcherResponse< + P extends RegisteredPluginId, + M extends string, + Path extends string, + Method extends string = PluginRouteMethod, +> = InferResponseType>; diff --git a/packages/vitnode/src/tanstack/admin/content/form/transport.ts b/packages/vitnode/src/tanstack/admin/content/form/transport.ts index 5115c7433..ba84298aa 100644 --- a/packages/vitnode/src/tanstack/admin/content/form/transport.ts +++ b/packages/vitnode/src/tanstack/admin/content/form/transport.ts @@ -1,16 +1,12 @@ import type { QueryClient } from "@tanstack/react-query"; import type { ContentApiTarget } from "@/views/admin/views/content/content-request"; -import type { - ContentItemFetcher, - ContentTranslationsFetcher, -} from "@/views/admin/views/content/form/item-query"; import type { ContentFormTransport } from "@/views/admin/views/content/form/transport"; import { contentFrontendRegistry } from "@/content/index"; import { - contentItemFetcher, - contentTranslationsFetcher, + fetchContentItem, + fetchContentTranslations, } from "@/views/admin/views/content/form/item-query"; import { createContentInBrowser, @@ -24,13 +20,6 @@ import { } from "@/views/admin/views/content/form/mutations-api"; import { contentApiTarget, invalidateContentAfterWrite } from "../query"; -import { contentApiFetch } from "../transport"; - -const fetchContentItem: ContentItemFetcher = - contentItemFetcher(contentApiFetch); - -const fetchContentTranslations: ContentTranslationsFetcher = - contentTranslationsFetcher(contentApiFetch); export { fetchContentItem, fetchContentTranslations }; diff --git a/packages/vitnode/src/tanstack/admin/content/index.ts b/packages/vitnode/src/tanstack/admin/content/index.ts index c41529fa3..5dd01b173 100644 --- a/packages/vitnode/src/tanstack/admin/content/index.ts +++ b/packages/vitnode/src/tanstack/admin/content/index.ts @@ -130,8 +130,8 @@ export type { ContentApiTarget, } from "@/views/admin/views/content/content-request"; export { + contentApiFetch, contentApiFetchArgs, - contentApiFetchInBrowser, readContentApiJson, } from "@/views/admin/views/content/content-request"; diff --git a/packages/vitnode/src/tanstack/admin/content/query.ts b/packages/vitnode/src/tanstack/admin/content/query.ts index ba26b087d..c82f78d48 100644 --- a/packages/vitnode/src/tanstack/admin/content/query.ts +++ b/packages/vitnode/src/tanstack/admin/content/query.ts @@ -7,10 +7,7 @@ import type { ContentRowMutationArgs, ContentRowMutationResult, } from "@/views/admin/views/content/table/list-mutations"; -import type { - ContentListPageFetcher, - ContentListRequest, -} from "@/views/admin/views/content/table/list-query"; +import type { ContentListRequest } from "@/views/admin/views/content/table/list-query"; import { invalidateContentItem, @@ -23,17 +20,13 @@ import { setContentPublicationInBrowser, } from "@/views/admin/views/content/table/list-mutations"; import { - contentListPageFetcher, contentListQueryOptions, + fetchContentListPage, } from "@/views/admin/views/content/table/list-query"; import type { ContentListParams } from "./route-search"; import { contentListQuery } from "./route-search"; -import { contentApiFetch } from "./transport"; - -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/transport.ts b/packages/vitnode/src/tanstack/admin/content/transport.ts deleted file mode 100644 index a5e9d2b84..000000000 --- a/packages/vitnode/src/tanstack/admin/content/transport.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 f127cae77..37a8fbdb0 100644 --- a/packages/vitnode/src/tanstack/admin/cron/query.ts +++ b/packages/vitnode/src/tanstack/admin/cron/query.ts @@ -3,25 +3,19 @@ import type { QueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query"; import React from "react"; -import type { - CronPageFetcher, - CronParams, -} from "@/views/admin/views/core/advanced/cron/cron-query"; +import type { CronParams } from "@/views/admin/views/core/advanced/cron/cron-query"; import type { RunCron, RunCronResult, } from "@/views/admin/views/core/advanced/cron/run-action/run-cron"; -import { fetcher } from "@/tanstack/fetcher"; import { - cronPageFetcher, cronQueryOptions, cronQueryRoot, + fetchCronPage, } from "@/views/admin/views/core/advanced/cron/cron-query"; import { runCronInBrowser } from "@/views/admin/views/core/advanced/cron/run-action/run-cron"; -const fetchCronPage: CronPageFetcher = cronPageFetcher(fetcher); - export const cronQuery = ({ params }: { params: CronParams }) => cronQueryOptions({ fetchPage: fetchCronPage, params }); diff --git a/packages/vitnode/src/tanstack/admin/dashboard/query.tsx b/packages/vitnode/src/tanstack/admin/dashboard/query.tsx index f7a693c21..79de38e12 100644 --- a/packages/vitnode/src/tanstack/admin/dashboard/query.tsx +++ b/packages/vitnode/src/tanstack/admin/dashboard/query.tsx @@ -4,15 +4,13 @@ import { useQueryClient } from "@tanstack/react-query"; 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, + fetchDashboardLayout, saveDashboardLayoutInBrowser, } from "@/views/admin/views/core/dashboard/widgets/layout-query"; import { saveWidgetSettingsInBrowser } from "@/views/admin/views/core/dashboard/widgets/widget-mutations"; @@ -21,9 +19,6 @@ import type { AdminIdentity } from "../identity"; import { useAdminIdentity } from "../identity"; -const fetchDashboardLayout: DashboardLayoutFetcher = - dashboardLayoutFetcher(fetcher); - export const dashboardLayoutQuery = (adminUserId: AdminIdentity) => dashboardLayoutQueryOptions({ adminUserId, diff --git a/packages/vitnode/src/tanstack/admin/debug/query.ts b/packages/vitnode/src/tanstack/admin/debug/query.ts index af86d8826..81a334f91 100644 --- a/packages/vitnode/src/tanstack/admin/debug/query.ts +++ b/packages/vitnode/src/tanstack/admin/debug/query.ts @@ -5,24 +5,15 @@ import { useQueryClient } from "@tanstack/react-query"; import { useRouter } from "@tanstack/react-router"; import React from "react"; -import type { - DebugLogsPageFetcher, - DebugLogsParams, - DebugQueueFetcher, -} from "@/views/admin/views/core/debug/debug-query"; +import type { DebugLogsParams } from "@/views/admin/views/core/debug/debug-query"; -import { fetcher } from "@/tanstack/fetcher"; import { - debugLogsPageFetcher, debugLogsQueryOptions, - debugQueueFetcher, debugQueueQueryOptions, + fetchDebugLogsPage, + fetchDebugQueue, } from "@/views/admin/views/core/debug/debug-query"; -const fetchDebugLogsPage: DebugLogsPageFetcher = debugLogsPageFetcher(fetcher); - -const fetchDebugQueue: DebugQueueFetcher = debugQueueFetcher(fetcher); - export const debugLogsQuery = ({ params }: { params: DebugLogsParams }) => debugLogsQueryOptions({ fetchPage: fetchDebugLogsPage, params }); diff --git a/packages/vitnode/src/tanstack/admin/default-transport.ts b/packages/vitnode/src/tanstack/admin/default-transport.ts index 842cc35ba..a08fcd0cc 100644 --- a/packages/vitnode/src/tanstack/admin/default-transport.ts +++ b/packages/vitnode/src/tanstack/admin/default-transport.ts @@ -1,16 +1,12 @@ -import type { adminModule } from "@/api/modules/admin/admin.module"; - import { CONFIG_PLUGIN } from "@/config"; -import { clientModule } from "@/lib/fetcher-client"; import { fetcher } from "@/tanstack/fetcher"; import { readAdminSessionThrough } from "./session-read"; -const admin = clientModule(CONFIG_PLUGIN.pluginId); - export const readAdminSessionFromApi = async () => await readAdminSessionThrough(async () => { - const response = await fetcher(admin, { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, method: "get", module: "admin", path: "/session", diff --git a/packages/vitnode/src/tanstack/admin/files/query.ts b/packages/vitnode/src/tanstack/admin/files/query.ts index da47d9f35..d043d1bf6 100644 --- a/packages/vitnode/src/tanstack/admin/files/query.ts +++ b/packages/vitnode/src/tanstack/admin/files/query.ts @@ -11,26 +11,19 @@ import type { DeleteAdminFiles, DeleteAdminFilesArgs, } from "@/views/admin/views/core/system/files/files-delete"; -import type { - AdminFilesPageFetcher, - AdminFilesParams, -} from "@/views/admin/views/core/system/files/files-query"; +import type { AdminFilesParams } 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, + fetchAdminFilesPage, } from "@/views/admin/views/core/system/files/files-query"; -const fetchAdminFilesPage: AdminFilesPageFetcher = - adminFilesPageFetcher(fetcher); - export const adminFilesQuery = ({ params }: { params: AdminFilesParams }) => adminFilesQueryOptions({ fetchPage: fetchAdminFilesPage, params }); diff --git a/packages/vitnode/src/tanstack/admin/integrations/query.ts b/packages/vitnode/src/tanstack/admin/integrations/query.ts index cb1cf89dd..ac536ce6b 100644 --- a/packages/vitnode/src/tanstack/admin/integrations/query.ts +++ b/packages/vitnode/src/tanstack/admin/integrations/query.ts @@ -1,19 +1,12 @@ import type { QueryClient } from "@tanstack/react-query"; -import type { IntegrationsFetcher } from "@/views/admin/views/core/system/integrations/integrations-query"; - -import { fetcher } from "@/tanstack/fetcher"; import { - integrationsFetcher, integrationsQueryKey, integrationsQueryOptions, } from "@/views/admin/views/core/system/integrations/integrations-query"; -const fetchIntegrations: IntegrationsFetcher = integrationsFetcher(fetcher); - /** The board, as the one query definition the loader and the component share. */ -export const integrationsQuery = () => - integrationsQueryOptions({ fetchIntegrations }); +export const integrationsQuery = () => integrationsQueryOptions(); export const invalidateIntegrations = async ( queryClient: QueryClient, diff --git a/packages/vitnode/src/tanstack/admin/queue/query.ts b/packages/vitnode/src/tanstack/admin/queue/query.ts index 680c13746..79afad31f 100644 --- a/packages/vitnode/src/tanstack/admin/queue/query.ts +++ b/packages/vitnode/src/tanstack/admin/queue/query.ts @@ -1,15 +1,9 @@ -import type { - QueuePageFetcher, - QueueParams, -} from "@/views/admin/views/core/advanced/queue/queue-query"; +import type { QueueParams } from "@/views/admin/views/core/advanced/queue/queue-query"; -import { fetcher } from "@/tanstack/fetcher"; import { - queuePageFetcher, + fetchQueuePage, queueQueryOptions, } from "@/views/admin/views/core/advanced/queue/queue-query"; -const fetchQueuePage: QueuePageFetcher = queuePageFetcher(fetcher); - export const queueQuery = ({ params }: { params: QueueParams }) => queueQueryOptions({ fetchPage: fetchQueuePage, params }); diff --git a/packages/vitnode/src/tanstack/admin/roles/query.ts b/packages/vitnode/src/tanstack/admin/roles/query.ts index bad1b4cf1..40d27b321 100644 --- a/packages/vitnode/src/tanstack/admin/roles/query.ts +++ b/packages/vitnode/src/tanstack/admin/roles/query.ts @@ -5,22 +5,18 @@ import React from "react"; import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; import type { AdminRoleFormProps } from "@/views/admin/views/core/users/roles/role-form-content"; -import type { - AdminRolesPageFetcher, - AdminRolesParams, -} from "@/views/admin/views/core/users/roles/roles-query"; +import type { AdminRolesParams } 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, + fetchAdminRolesPage, } from "@/views/admin/views/core/users/roles/roles-query"; import { useAdminIdentity } from "../identity"; @@ -32,8 +28,6 @@ import { invalidateAdminUsers } from "../users/query"; * three mutations. */ -const fetchRolesPage: AdminRolesPageFetcher = adminRolesPageFetcher(fetcher); - export const adminRolesQuery = ({ adminUserId, params, @@ -41,7 +35,11 @@ export const adminRolesQuery = ({ adminUserId: AdminIdentity; params: AdminRolesParams; }) => - adminRolesQueryOptions({ adminUserId, fetchPage: fetchRolesPage, params }); + adminRolesQueryOptions({ + adminUserId, + fetchPage: fetchAdminRolesPage, + params, + }); export const invalidateAfterAdminRoleChange = async ( queryClient: QueryClient, diff --git a/packages/vitnode/src/tanstack/admin/search-index/query.ts b/packages/vitnode/src/tanstack/admin/search-index/query.ts index b2b4580f1..905f35dcd 100644 --- a/packages/vitnode/src/tanstack/admin/search-index/query.ts +++ b/packages/vitnode/src/tanstack/admin/search-index/query.ts @@ -7,22 +7,17 @@ import type { SearchIndexActions, SearchIndexMutationResult, } 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 { + fetchSearchIndexStatus, searchIndexQueryKey, searchIndexQueryOptions, - searchIndexStatusFetcher, } from "@/views/admin/views/core/advanced/search/search-index-query"; -const fetchSearchIndexStatus: SearchIndexStatusFetcher = - searchIndexStatusFetcher(fetcher); - /** The status, as the one query definition the loader and the component share. */ export const searchIndexQuery = () => searchIndexQueryOptions({ fetchStatus: fetchSearchIndexStatus }); diff --git a/packages/vitnode/src/tanstack/admin/server.ts b/packages/vitnode/src/tanstack/admin/server.ts index d316c50f5..cb8155985 100644 --- a/packages/vitnode/src/tanstack/admin/server.ts +++ b/packages/vitnode/src/tanstack/admin/server.ts @@ -1,13 +1,14 @@ import "@tanstack/react-start/server-only"; -import { adminModule } from "@/api/modules/admin/admin.module"; +import { CONFIG_PLUGIN } from "@/config"; import { fetcher } from "@/tanstack/fetcher/server"; import { readAdminSessionThrough } from "./session-read"; export const readAdminSessionOnApi = async () => await readAdminSessionThrough(async () => { - const response = await fetcher(adminModule, { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, method: "get", module: "admin", path: "/session", diff --git a/packages/vitnode/src/tanstack/admin/staff/query.ts b/packages/vitnode/src/tanstack/admin/staff/query.ts index b76c3bcea..941ede85e 100644 --- a/packages/vitnode/src/tanstack/admin/staff/query.ts +++ b/packages/vitnode/src/tanstack/admin/staff/query.ts @@ -7,28 +7,22 @@ import type { PermissionStaffType } from "@/api/lib/permission-staff"; import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; import type { CreateStaffFormProps } from "@/views/admin/views/core/staff/create/create-staff-form-content"; import type { EditStaffFormProps } from "@/views/admin/views/core/staff/edit/edit-staff-form-content"; -import type { - AdminStaffCatalogFetcher, - AdminStaffEntryFetcher, - AdminStaffPageFetcher, - AdminStaffParams, -} from "@/views/admin/views/core/staff/staff-query"; +import type { AdminStaffParams } 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, + fetchAdminStaffCatalog, + fetchAdminStaffEntry, + fetchAdminStaffPage, } from "@/views/admin/views/core/staff/staff-query"; import { useAdminIdentity } from "../identity"; @@ -39,13 +33,6 @@ import { invalidateAdminSession } from "../session-query"; * and three mutations. */ -const fetchStaffPage: AdminStaffPageFetcher = adminStaffPageFetcher(fetcher); - -const fetchCatalog: AdminStaffCatalogFetcher = - adminStaffCatalogFetcher(fetcher); - -const fetchEntry: AdminStaffEntryFetcher = adminStaffEntryFetcher(fetcher); - export const adminStaffQuery = ({ adminUserId, params, @@ -57,7 +44,7 @@ export const adminStaffQuery = ({ }) => adminStaffQueryOptions({ adminUserId, - fetchPage: fetchStaffPage, + fetchPage: fetchAdminStaffPage, params, type, }); @@ -67,7 +54,10 @@ export const adminStaffCatalogQuery = ({ }: { adminUserId: AdminIdentity; }) => - adminStaffCatalogQueryOptions({ adminUserId, fetchCatalog: fetchCatalog }); + adminStaffCatalogQueryOptions({ + adminUserId, + fetchCatalog: fetchAdminStaffCatalog, + }); export const adminStaffEntryQuery = ({ adminUserId, @@ -77,7 +67,13 @@ export const adminStaffEntryQuery = ({ adminUserId: AdminIdentity; id: string; type: PermissionStaffType; -}) => adminStaffEntryQueryOptions({ adminUserId, fetchEntry, id, type }); +}) => + adminStaffEntryQueryOptions({ + adminUserId, + fetchEntry: fetchAdminStaffEntry, + id, + type, + }); export const invalidateAfterStaffChange = async ( queryClient: QueryClient, diff --git a/packages/vitnode/src/tanstack/admin/user-search.ts b/packages/vitnode/src/tanstack/admin/user-search.ts index 0ed9d15b7..cf520603a 100644 --- a/packages/vitnode/src/tanstack/admin/user-search.ts +++ b/packages/vitnode/src/tanstack/admin/user-search.ts @@ -1,15 +1,11 @@ import { z } from "zod"; -import type { adminModule } from "@/api/modules/admin/admin.module"; import type { AdminSearchUser } from "@/views/admin/layouts/search/search-users"; import { CONFIG_PLUGIN } from "@/config"; -import { clientModule } from "@/lib/fetcher-client"; import { fetcher } from "@/tanstack/fetcher"; import { MAX_SEARCH_RESULTS } from "@/views/admin/layouts/search/constants"; -const admin = clientModule(CONFIG_PLUGIN.pluginId); - export const adminUserSearchInputSchema = z.string().trim().min(1).max(128); export const readAdminUserSearch = async ( @@ -20,7 +16,8 @@ export const readAdminUserSearch = async ( if (!parsed.success) return []; try { - const response = await fetcher(admin, { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, args: { query: { first: String(MAX_SEARCH_RESULTS), search: parsed.data }, }, diff --git a/packages/vitnode/src/tanstack/admin/users/query.ts b/packages/vitnode/src/tanstack/admin/users/query.ts index ae7284405..0882ffbf4 100644 --- a/packages/vitnode/src/tanstack/admin/users/query.ts +++ b/packages/vitnode/src/tanstack/admin/users/query.ts @@ -9,26 +9,19 @@ import type { RemoveAdminUserImage, UploadAdminUserImage, } from "@/views/admin/views/core/users/detail/user-images-content"; -import type { - AdminUserDetail, - AdminUserFetcher, -} from "@/views/admin/views/core/users/detail/user-query"; +import type { AdminUserDetail } from "@/views/admin/views/core/users/detail/user-query"; import type { UpdateAdminUserRoles } from "@/views/admin/views/core/users/detail/user-roles-content"; -import type { - AdminUsersPageFetcher, - AdminUsersParams, -} from "@/views/admin/views/core/users/list/users-query"; +import type { AdminUsersParams } 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, + fetchAdminUser, } from "@/views/admin/views/core/users/detail/user-query"; import { - adminUsersPageFetcher, adminUsersQueryOptions, adminUsersQueryRoot, + fetchAdminUsersPage, } from "@/views/admin/views/core/users/list/users-query"; import { removeAdminUserImage, @@ -41,10 +34,6 @@ import { import { useAdminIdentity } from "../identity"; import { invalidateAdminSession } from "../session-query"; -const fetchUsersPage: AdminUsersPageFetcher = adminUsersPageFetcher(fetcher); - -const fetchUser: AdminUserFetcher = adminUserFetcher(fetcher); - export const adminUsersQuery = ({ adminUserId, params, @@ -54,7 +43,7 @@ export const adminUsersQuery = ({ }) => adminUsersQueryOptions({ adminUserId, - fetchPage: fetchUsersPage, + fetchPage: fetchAdminUsersPage, params, }); @@ -65,7 +54,7 @@ export const adminUserQuery = ({ }: { adminUserId: AdminIdentity; id: string; -}) => adminUserQueryOptions({ adminUserId, fetchUser, id }); +}) => adminUserQueryOptions({ adminUserId, fetchUser: fetchAdminUser, id }); export const invalidateAdminUsers = async ( queryClient: QueryClient, diff --git a/packages/vitnode/src/tanstack/auth/default-transport.ts b/packages/vitnode/src/tanstack/auth/default-transport.ts index 5a9183d6b..dffaf3095 100644 --- a/packages/vitnode/src/tanstack/auth/default-transport.ts +++ b/packages/vitnode/src/tanstack/auth/default-transport.ts @@ -1,13 +1,8 @@ -import type { usersModule } from "@/api/modules/users/users.module"; - import { CONFIG_PLUGIN } from "@/config"; -import { clientModule } from "@/lib/fetcher-client"; import { fetcher } from "@/tanstack/fetcher"; import { createAuthOperations } from "./transport-operations"; -const users = clientModule(CONFIG_PLUGIN.pluginId); - /** * The browser's own transport: the universal fetcher, and no cookie relay. * @@ -17,7 +12,8 @@ const users = clientModule(CONFIG_PLUGIN.pluginId); */ const operations = createAuthOperations({ changePasswordFromReset: async data => - await fetcher(users, { + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, args: { body: data }, method: "post", module: "users", @@ -25,7 +21,8 @@ const operations = createAuthOperations({ }), completeSso: async data => - await fetcher(users, { + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, args: { params: { providerId: data.providerId }, query: { code: data.code, state: data.state }, @@ -36,7 +33,8 @@ const operations = createAuthOperations({ }), linkSso: async data => - await fetcher(users, { + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, args: { body: { password: data.password, token: data.token }, params: { providerId: data.providerId }, @@ -47,14 +45,16 @@ const operations = createAuthOperations({ }), readSession: async () => - await fetcher(users, { + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, method: "get", module: "users", path: "/session", }), requestPasswordReset: async ({ captchaToken, email }) => - await fetcher(users, { + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, captchaToken, args: { body: { email } }, method: "post", @@ -63,7 +63,8 @@ const operations = createAuthOperations({ }), signIn: async data => - await fetcher(users, { + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, args: { body: data }, method: "post", module: "users", @@ -71,7 +72,8 @@ const operations = createAuthOperations({ }), signOut: async data => - await fetcher(users, { + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, args: { body: { isAdmin: data.isAdmin ?? false } }, method: "delete", module: "users", @@ -79,7 +81,8 @@ const operations = createAuthOperations({ }), signUp: async ({ captchaToken, ...body }) => - await fetcher(users, { + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, captchaToken, args: { body }, method: "post", @@ -88,7 +91,8 @@ const operations = createAuthOperations({ }), startSso: async data => - await fetcher(users, { + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, args: { params: { providerId: data.providerId } }, method: "post", module: "users/sso", diff --git a/packages/vitnode/src/tanstack/auth/middleware-config.ts b/packages/vitnode/src/tanstack/auth/middleware-config.ts index b4bf19e26..427539458 100644 --- a/packages/vitnode/src/tanstack/auth/middleware-config.ts +++ b/packages/vitnode/src/tanstack/auth/middleware-config.ts @@ -2,11 +2,10 @@ import type { z } from "zod"; import { queryOptions, useSuspenseQuery } from "@tanstack/react-query"; -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 } from "@/lib/fetcher-client"; +import { CONFIG_PLUGIN } from "@/config"; import { fetcher } from "@/tanstack/fetcher"; import { normalizeSSOProviders } from "@/views/auth/sso/providers"; @@ -28,11 +27,10 @@ export const knownMiddlewareConfig = ( config: MiddlewareConfig, ): MiddlewareConfigState => ({ ...config, isKnown: true }); -const middleware = clientModule("@vitnode/core"); - const fetchMiddlewareConfig = async (): Promise => { try { - const response = await fetcher(middleware, { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, method: "get", module: "middleware", path: "/", diff --git a/packages/vitnode/src/tanstack/auth/server.ts b/packages/vitnode/src/tanstack/auth/server.ts index ee077c72f..8cddd2971 100644 --- a/packages/vitnode/src/tanstack/auth/server.ts +++ b/packages/vitnode/src/tanstack/auth/server.ts @@ -1,6 +1,6 @@ import "@tanstack/react-start/server-only"; -import { usersModule } from "@/api/modules/users/users.module"; +import { CONFIG_PLUGIN } from "@/config"; import { fetcher } from "@/tanstack/fetcher/server"; import { createAuthOperations } from "./transport-operations"; @@ -15,7 +15,8 @@ import { createAuthOperations } from "./transport-operations"; */ const operations = createAuthOperations({ changePasswordFromReset: async data => - await fetcher(usersModule, { + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, args: { body: data }, method: "post", module: "users", @@ -23,7 +24,8 @@ const operations = createAuthOperations({ }), completeSso: async data => - await fetcher(usersModule, { + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, allowSaveCookies: true, args: { params: { providerId: data.providerId }, @@ -35,7 +37,8 @@ const operations = createAuthOperations({ }), linkSso: async data => - await fetcher(usersModule, { + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, allowSaveCookies: true, args: { body: { password: data.password, token: data.token }, @@ -47,14 +50,16 @@ const operations = createAuthOperations({ }), readSession: async () => - await fetcher(usersModule, { + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, method: "get", module: "users", path: "/session", }), requestPasswordReset: async ({ captchaToken, email }) => - await fetcher(usersModule, { + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, captchaToken, args: { body: { email } }, method: "post", @@ -63,7 +68,8 @@ const operations = createAuthOperations({ }), signIn: async data => - await fetcher(usersModule, { + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, allowSaveCookies: true, args: { body: data }, method: "post", @@ -72,7 +78,8 @@ const operations = createAuthOperations({ }), signOut: async data => - await fetcher(usersModule, { + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, allowSaveCookies: true, args: { body: { isAdmin: data.isAdmin ?? false } }, method: "delete", @@ -81,7 +88,8 @@ const operations = createAuthOperations({ }), signUp: async ({ captchaToken, ...body }) => - await fetcher(usersModule, { + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, allowSaveCookies: true, captchaToken, args: { body }, @@ -91,7 +99,8 @@ const operations = createAuthOperations({ }), startSso: async data => - await fetcher(usersModule, { + await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, allowSaveCookies: true, args: { params: { providerId: data.providerId } }, method: "post", diff --git a/packages/vitnode/src/tanstack/auth/transport-operations.test.ts b/packages/vitnode/src/tanstack/auth/transport-operations.test.ts index e079b56cf..99c556657 100644 --- a/packages/vitnode/src/tanstack/auth/transport-operations.test.ts +++ b/packages/vitnode/src/tanstack/auth/transport-operations.test.ts @@ -371,10 +371,11 @@ describe("the browser adapter", () => { expect(imports).not.toContain("@/tanstack/fetcher/server"); expect(imports).not.toContain("@tanstack/react-start/server-only"); expect(imports.some(one => one.endsWith("/server"))).toBe(false); - // The universal fetcher and the plugin-id reference, which are safe in both + // The universal fetcher and the plugin id, which are safe in both // runtimes - that pair is the whole transport surface it may reach. expect(imports).toContain("@/tanstack/fetcher"); - expect(imports).toContain("@/lib/fetcher-client"); + expect(imports).toContain("@/config"); + expect(imports).not.toContain("@/lib/fetcher-client"); }); it("shares its request mapping with the server adapter", () => { diff --git a/packages/vitnode/src/tanstack/devices/query.ts b/packages/vitnode/src/tanstack/devices/query.ts index 98bc7c497..7a8d68d84 100644 --- a/packages/vitnode/src/tanstack/devices/query.ts +++ b/packages/vitnode/src/tanstack/devices/query.ts @@ -3,16 +3,13 @@ import type { QueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query"; import React from "react"; -import type { DevicesFetcher } from "@/views/auth/settings/devices/devices-query"; import type { RevokeDevice, RevokeDeviceArgs, RevokeDeviceResult, } from "@/views/auth/settings/devices/devices-revoke"; -import { fetcher } from "@/tanstack/fetcher"; import { - devicesFetcher, devicesQueryKey, devicesQueryOptions, } from "@/views/auth/settings/devices/devices-query"; @@ -21,10 +18,7 @@ import { shouldRefreshAfterRevoke, } from "@/views/auth/settings/devices/devices-revoke"; -const fetchDevices: DevicesFetcher = devicesFetcher(fetcher); - -export const devicesQuery = (userId: number) => - devicesQueryOptions({ fetchDevices, userId }); +export const devicesQuery = (userId: number) => devicesQueryOptions({ userId }); export const invalidateDevices = async ( queryClient: QueryClient, diff --git a/packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts b/packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts index 392eb5350..15868ce09 100644 --- a/packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts +++ b/packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts @@ -1,16 +1,136 @@ +import { z } from "@hono/zod-openapi"; import { describe, expectTypeOf, it } from "vitest"; -import type { usersModule } from "@/api/modules/users/users.module"; +import { buildModule } from "@/api/lib/module"; +import { buildApiPlugin } from "@/api/lib/plugin"; +import { buildRoute } from "@/api/lib/route"; +import { buildContentPublicModule, createContentModel } from "@/content/server"; +import { testPostContentType } from "@/tests/content-fixtures"; -import { clientModule } from "@/lib/fetcher-client"; +import { fetcher } from "./index"; -import { createApiClient, fetcher } from "./index"; +const PLUGIN_ID = "@acme/notes"; -const users = clientModule("@vitnode/core"); +const listNotesRoute = buildRoute({ + pluginId: PLUGIN_ID, + route: { + method: "get", + path: "/", + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ notes: z.array(z.string()) }), + }, + }, + description: "Every note", + }, + }, + }, + handler: c => c.json({ notes: [] }, 200), +}); + +const pinNoteRoute = buildRoute({ + pluginId: PLUGIN_ID, + route: { + method: "post", + path: "/{id}/pin", + request: { + body: { + content: { + "application/json": { schema: z.object({ pinned: z.boolean() }) }, + }, + }, + params: z.object({ id: z.string() }), + }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ id: z.string() }) }, + }, + description: "The pinned note", + }, + 404: { description: "No such note" }, + }, + }, + handler: c => c.json({ id: "1" }, 200), +}); + +const listColorsRoute = buildRoute({ + pluginId: PLUGIN_ID, + route: { + method: "get", + path: "/", + request: { query: z.object({ search: z.string().optional() }) }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ colors: z.array(z.string()) }), + }, + }, + description: "Tag colors", + }, + }, + }, + handler: c => c.json({ colors: [] }, 200), +}); + +const colorsModule = buildModule({ + pluginId: PLUGIN_ID, + name: "colors", + routes: [listColorsRoute], +}); + +const tagsModule = buildModule({ + pluginId: PLUGIN_ID, + name: "tags", + routes: [], + modules: [colorsModule], +}); + +const notesModule = buildModule({ + pluginId: PLUGIN_ID, + name: "notes", + routes: [listNotesRoute, pinNoteRoute], + modules: [tagsModule], +}); + +const postContent = createContentModel(testPostContentType); -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, { +const notesApiPlugin = () => + buildApiPlugin({ + pluginId: PLUGIN_ID, + modules: [ + notesModule, + buildContentPublicModule({ + pluginId: PLUGIN_ID, + contentTypes: [postContent], + }), + ], + }); + +declare module "../../lib/fetcher/registry" { + interface ApiPluginRegistry { + "@acme/notes": typeof notesApiPlugin; + } +} + +describe("the plugin API keeps its literal shape", () => { + it("retains the plugin id and the module tuple", () => { + const plugin = notesApiPlugin(); + + expectTypeOf(plugin.pluginId).toEqualTypeOf<"@acme/notes">(); + expectTypeOf(plugin.modules[0]).toEqualTypeOf(); + expectTypeOf(plugin.modules).toHaveProperty("length"); + expectTypeOf(plugin.modules.length).toEqualTypeOf<2>(); + }); +}); + +describe("the universal fetcher resolves a route from the plugin id", () => { + it("infers the status and the body of a top-level core module", async () => { + const response = await fetcher({ + plugin: "@vitnode/core", method: "get", module: "users", path: "/session", @@ -20,35 +140,162 @@ describe("the universal fetcher keeps the route in the type", () => { expectTypeOf((await response.json()).user).not.toBeAny(); }); + it("infers a registered plugin's own module", async () => { + const response = await fetcher({ + plugin: "@acme/notes", + method: "get", + module: "notes", + path: "/", + }); + + expectTypeOf(response.status).toEqualTypeOf<200>(); + expectTypeOf((await response.json()).notes).toEqualTypeOf(); + }); + + it("walks nested modules to any depth", async () => { + const core = await fetcher({ + plugin: "@vitnode/core", + args: { query: {} }, + method: "get", + module: "admin/advanced/cron", + path: "/", + }); + + expectTypeOf(core.status).toEqualTypeOf<200>(); + + const colors = await fetcher({ + plugin: "@acme/notes", + args: { query: { search: "red" } }, + method: "get", + module: "notes/tags/colors", + path: "/", + }); + + expectTypeOf(colors.status).toEqualTypeOf<200>(); + expectTypeOf((await colors.json()).colors).toEqualTypeOf(); + }); + + it("types the routes a content type's public API generates", async () => { + const list = await fetcher({ + plugin: "@acme/notes", + args: { query: { first: "10", orderBy: "title" } }, + method: "get", + module: "content/posts", + path: "/", + }); + + expectTypeOf(list.status).toEqualTypeOf<200 | 400>(); + + const detail = await fetcher({ + plugin: "@acme/notes", + args: { params: { slug: "hello" } }, + method: "get", + module: "content/posts", + path: "/{slug}", + }); + + expectTypeOf(detail.status).toEqualTypeOf<200 | 404>(); + }); + + it("keeps a status-aware union when a route declares several answers", async () => { + const response = await fetcher({ + plugin: "@acme/notes", + args: { body: { pinned: true }, params: { id: "1" } }, + method: "post", + module: "notes", + path: "/{id}/pin", + }); + + expectTypeOf(response.status).toEqualTypeOf<200 | 404>(); + + if (response.status === 200) { + expectTypeOf((await response.json()).id).toEqualTypeOf(); + } + }); +}); + +describe("the universal fetcher rejects what the registry does not describe", () => { + it("rejects a plugin that is not configured", async () => { + await fetcher({ + // @ts-expect-error -- not in the registry + plugin: "@acme/missing", + method: "get", + module: "notes", + path: "/", + }); + }); + + it("rejects a module the plugin does not mount", async () => { + await fetcher({ + plugin: "@acme/notes", + method: "get", + // @ts-expect-error -- not a module of `@acme/notes` + module: "does-not-exist", + path: "/", + }); + }); + + it("rejects a nested module path that skips a level", async () => { + await fetcher({ + plugin: "@acme/notes", + method: "get", + // @ts-expect-error -- `colors` is under `tags` + module: "notes/colors", + path: "/", + }); + }); + it("rejects a path the module does not serve", async () => { - await fetcher(users, { + await fetcher({ + plugin: "@acme/notes", method: "get", - module: "users", - // @ts-expect-error -- not a route on `users` + module: "notes", + // @ts-expect-error -- not a route on `notes` 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", + await fetcher({ + plugin: "@acme/notes", + args: { query: {} }, + // @ts-expect-error -- the public list is a `get` + method: "delete", + module: "content/posts", + path: "/", }); }); - 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, { + it("rejects a call that omits required arguments", async () => { + // @ts-expect-error -- `/{id}/pin` declares params and a body + await fetcher({ + plugin: "@acme/notes", method: "post", - module: "users", - path: "/sign_in", + module: "notes", + path: "/{id}/pin", + }); + + // @ts-expect-error -- `/{slug}` declares params + await fetcher({ + plugin: "@acme/notes", + method: "get", + module: "content/posts", + path: "/{slug}", }); }); - it("rejects a body the route does not declare", async () => { - await fetcher(users, { + it("rejects arguments outside the route's schema", async () => { + await fetcher({ + plugin: "@acme/notes", + // @ts-expect-error -- `pinned` is a boolean + args: { body: { pinned: "yes" }, params: { id: "1" } }, + method: "post", + module: "notes", + path: "/{id}/pin", + }); + + await fetcher({ + plugin: "@vitnode/core", // @ts-expect-error -- `nickname` is not on the sign-in schema args: { body: { email: "a@b.c", nickname: "x", password: "y" } }, method: "post", @@ -57,19 +304,22 @@ describe("the universal fetcher keeps the route in the type", () => { }); }); - 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}", + it("rejects arguments on a route that declares none", async () => { + await fetcher({ + plugin: "@acme/notes", + // @ts-expect-error -- `/` takes no body, params or query + args: { query: { search: "x" } }, + method: "get", + module: "notes", + path: "/", }); }); }); 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, { + await fetcher({ + plugin: "@vitnode/core", // @ts-expect-error -- `allowSaveCookies` is on `tanstack/fetcher/server` allowSaveCookies: true, args: { body: { email: "a@b.c", password: "secret" } }, @@ -80,7 +330,8 @@ describe("the universal fetcher offers only what both runtimes can honour", () = }); it("rejects forwarded headers", async () => { - await fetcher(users, { + await fetcher({ + plugin: "@vitnode/core", // @ts-expect-error -- a browser cannot forge request headers additionalHeaders: { Cookie: "vitnode_auth=stolen" }, method: "get", @@ -90,7 +341,8 @@ describe("the universal fetcher offers only what both runtimes can honour", () = }); it("rejects an origin override", async () => { - await fetcher(users, { + await fetcher({ + plugin: "@vitnode/core", method: "get", module: "users", // @ts-expect-error -- the browser calls its own origin @@ -102,7 +354,9 @@ describe("the universal fetcher offers only what both runtimes can honour", () = it("still accepts the options both runtimes share", async () => { const controller = new AbortController(); - await fetcher(users, { + await fetcher({ + plugin: "@vitnode/core", + captchaToken: "solved", method: "get", module: "users", options: { signal: controller.signal }, @@ -110,27 +364,3 @@ 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", - }); - }); -}); diff --git a/packages/vitnode/src/tanstack/fetcher/fetcher.test.ts b/packages/vitnode/src/tanstack/fetcher/fetcher.test.ts index 9b27f6a88..1ad0c1914 100644 --- a/packages/vitnode/src/tanstack/fetcher/fetcher.test.ts +++ b/packages/vitnode/src/tanstack/fetcher/fetcher.test.ts @@ -4,8 +4,6 @@ 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(); @@ -20,12 +18,10 @@ vi.mock("@tanstack/react-start/server", () => ({ })); const { createIsomorphicFn } = await import("@tanstack/react-start"); -const { clientModule, fetcherClient, rawFetcherClient } = +const { 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) => { @@ -67,7 +63,12 @@ describe("which branch runs", () => { 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" }); + await fetcher({ + plugin: "@vitnode/core", + method: "get", + module: "users", + path: "/session", + }); const { headers } = lastCall(); @@ -77,7 +78,12 @@ describe("the server branch is the request-aware transport", () => { }); it("resolves the API origin from the request it is serving", async () => { - await fetcher(users, { method: "get", module: "users", path: "/session" }); + await fetcher({ + plugin: "@vitnode/core", + 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"); @@ -86,7 +92,12 @@ describe("the server branch is the request-aware transport", () => { it("calls a separately configured API server instead of itself", async () => { vi.stubEnv("VITNODE_API_URL", "http://localhost:8000"); - await fetcher(users, { method: "get", module: "users", path: "/session" }); + await fetcher({ + plugin: "@vitnode/core", + method: "get", + module: "users", + path: "/session", + }); expect(lastCall().url.origin).toBe("http://localhost:8000"); }); @@ -94,7 +105,8 @@ describe("the server branch is the request-aware transport", () => { 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, { + await fetcher({ + plugin: "@vitnode/core", args: { body: { email: "a@b.c", password: "secret" } }, method: "post", module: "users", @@ -127,7 +139,8 @@ describe("the browser branch talks to the Hono API directly", () => { }); it("makes one request, to /api/*, with the browser's own cookies", async () => { - await fetcherClient(users, { + await fetcherClient({ + plugin: "@vitnode/core", method: "get", module: "users", path: "/session", @@ -141,7 +154,8 @@ describe("the browser branch talks to the Hono API directly", () => { }); it("routes nothing through a server-function endpoint", async () => { - await fetcherClient(users, { + await fetcherClient({ + plugin: "@vitnode/core", args: { body: { email: "a@b.c", password: "secret" } }, method: "post", module: "users", @@ -154,7 +168,8 @@ describe("the browser branch talks to the Hono API directly", () => { }); it("forges none of the headers only a server may send", async () => { - await fetcherClient(users, { + await fetcherClient({ + plugin: "@vitnode/core", method: "get", module: "users", path: "/session", @@ -186,6 +201,7 @@ 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"); +const REGISTRY = join(here, "../../lib/fetcher/registry.ts"); /** What must never reach a browser bundle through this module. */ const SERVER_ONLY = [ @@ -204,7 +220,7 @@ describe("what the Start compiler is given", () => { // 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\)/, + /export const fetcher = createIsomorphicFn\(\)\s*\.server\(serverFetcher as IsomorphicFetcher\)\s*\.client\(fetcherClient\)/, ); expect(source).toMatch( /export const rawFetcher = createIsomorphicFn\(\)\s*\.server\(serverRawFetcher\)\s*\.client\(rawFetcherClient\)/, @@ -219,7 +235,7 @@ describe("what the Start compiler is given", () => { // `SERVER_ONLY` names. const clientOutput = source .replace( - /createIsomorphicFn\(\)\s*\.server\((\w+)\)\s*\.client\((\w+)\)/g, + /createIsomorphicFn\(\)\s*\.server\((\w+)(?: as \w+)?\)\s*\.client\((\w+)(?: as \w+)?\)/g, "$2", ) .replace(/import\s*\{[\s\S]*?\}\s*from\s*"\.\/server";/, ""); @@ -252,9 +268,17 @@ describe("the browser transport is browser-shaped on its own", () => { 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. + it("never pulls the API's runtime in behind the plugin registry", () => { expect(offenders(BROWSER_ENTRY, API_RUNTIME)).toEqual([]); + expect(offenders(REGISTRY, [...API_RUNTIME, ...SERVER_ONLY])).toEqual([]); + }); + + it("reaches the core API plugin through the registry as a type and nothing else", () => { + const registry = stripComments(readFileSync(REGISTRY, "utf8")); + + expect(registry).toMatch( + /import type \{ newBuildPluginApiCore \} from "@\/api\/plugin";/, + ); + expect(registry).not.toMatch(/^import \{/m); }); }); diff --git a/packages/vitnode/src/tanstack/fetcher/index.ts b/packages/vitnode/src/tanstack/fetcher/index.ts index ef293a2bb..7d0fad759 100644 --- a/packages/vitnode/src/tanstack/fetcher/index.ts +++ b/packages/vitnode/src/tanstack/fetcher/index.ts @@ -1,102 +1,35 @@ import { createIsomorphicFn } from "@tanstack/react-start"; import type { - BaseBuildModuleReturn, - BuildModuleReturn, -} from "@/api/lib/module"; -import type { Route } from "@/api/lib/route"; -import type { + FetcherClientOptions, UniversalFetcher, UniversalRawFetcher, } from "@/lib/fetcher-client"; -import type { - FetcherParams, - FetcherRequestOptions, - GetModulePaths, - GetValidMethodForPath, - GetValidPathsForModule, - InferResponseType, -} from "@/lib/fetcher/types"; -import { - clientModule, - fetcherClient, - rawFetcherClient, -} 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"; +export type { FetcherClientOptions, UniversalFetcher, UniversalRawFetcher }; +export type { ApiPluginRegistry } from "@/lib/fetcher/registry"; +export type { + FetcherRequest, + FetcherResponse, + PluginModulePath, + PluginRouteMethod, + PluginRoutePath, + RegisteredPluginId, +} from "@/lib/fetcher/types"; + +type IsomorphicFetcher = (request: never) => Promise; export const fetcher = createIsomorphicFn() - .server(serverFetcher) + .server(serverFetcher as IsomorphicFetcher) .client(fetcherClient) as UniversalFetcher; export const rawFetcher = createIsomorphicFn() .server(serverRawFetcher) .client(rawFetcherClient) as UniversalRawFetcher; - -export 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; diff --git a/packages/vitnode/src/tanstack/fetcher/server.test.ts b/packages/vitnode/src/tanstack/fetcher/server.test.ts index 8d1b6a8bf..d8361458e 100644 --- a/packages/vitnode/src/tanstack/fetcher/server.test.ts +++ b/packages/vitnode/src/tanstack/fetcher/server.test.ts @@ -1,8 +1,6 @@ // @vitest-environment node import { beforeEach, describe, expect, it, vi } from "vitest"; -import { usersModule } from "@/api/modules/users/users.module"; - const requestHeaders = new Headers(); const setCookie = vi.fn(); let requestUrl: null | string = "https://preview.example.com/login"; @@ -48,7 +46,8 @@ beforeEach(() => { describe("the request the visitor made is the request the API sees", () => { it("forwards the cookie, user agent and forwarded-for chain", async () => { - await fetcher(usersModule, { + await fetcher({ + plugin: "@vitnode/core", method: "get", module: "users", path: "/session", @@ -65,7 +64,8 @@ describe("the request the visitor made is the request the API sees", () => { it("falls back to the connection IP when no proxy wrote a chain", async () => { requestHeaders.delete("x-forwarded-for"); - await fetcher(usersModule, { + await fetcher({ + plugin: "@vitnode/core", method: "get", module: "users", path: "/session", @@ -75,7 +75,8 @@ describe("the request the visitor made is the request the API sees", () => { }); it("calls the origin this request arrived on", async () => { - await fetcher(usersModule, { + await fetcher({ + plugin: "@vitnode/core", method: "get", module: "users", path: "/session", @@ -90,7 +91,8 @@ describe("the request the visitor made is the request the API sees", () => { // `:8000`, so this request's own origin has no `/api/*` to answer. vi.stubEnv("VITNODE_API_URL", "http://localhost:8000"); - await fetcher(usersModule, { + await fetcher({ + plugin: "@vitnode/core", method: "get", module: "users", path: "/session", @@ -103,7 +105,8 @@ describe("the request the visitor made is the request the API sees", () => { it("lets an explicit origin win", async () => { vi.stubEnv("VITNODE_API_URL", "http://localhost:8000"); - await fetcher(usersModule, { + await fetcher({ + plugin: "@vitnode/core", method: "get", module: "users", origin: "https://api.example.com", @@ -116,7 +119,8 @@ describe("the request the visitor made is the request the API sees", () => { describe("the route's arguments reach the wire", () => { it("sends a body as JSON", async () => { - await fetcher(usersModule, { + await fetcher({ + plugin: "@vitnode/core", args: { body: { email: "a@b.c", password: "secret" } }, method: "post", module: "users", @@ -133,7 +137,8 @@ describe("the route's arguments reach the wire", () => { }); it("substitutes path parameters", async () => { - await fetcher(usersModule, { + await fetcher({ + plugin: "@vitnode/core", args: { params: { publicId: "device-7" } }, method: "delete", module: "users", @@ -148,7 +153,8 @@ describe("the route's arguments reach the wire", () => { describe("captchaToken", () => { it("is sent as the header the middleware reads", async () => { - await fetcher(usersModule, { + await fetcher({ + plugin: "@vitnode/core", args: { body: { email: "a@b.c" } }, captchaToken: "solved", method: "post", @@ -162,7 +168,8 @@ describe("captchaToken", () => { it("sends no header at all when the deployment has no captcha", async () => { // An empty token is a *present* header with no token, which a configured // deployment rejects as `400`. The absence is the meaningful part. - await fetcher(usersModule, { + await fetcher({ + plugin: "@vitnode/core", args: { body: { email: "a@b.c" } }, captchaToken: "", method: "post", @@ -184,7 +191,8 @@ describe("allowSaveCookies", () => { it("copies the API's cookies onto this response", async () => { apiFetch.mockReturnValue(withSetCookie(201)); - await fetcher(usersModule, { + await fetcher({ + plugin: "@vitnode/core", allowSaveCookies: true, args: { body: { email: "a@b.c", password: "secret" } }, method: "post", @@ -202,7 +210,8 @@ describe("allowSaveCookies", () => { it("ignores a refusal's cookies", async () => { apiFetch.mockReturnValue(withSetCookie(403)); - await fetcher(usersModule, { + await fetcher({ + plugin: "@vitnode/core", allowSaveCookies: true, args: { body: { email: "a@b.c", password: "wrong" } }, method: "post", @@ -216,7 +225,8 @@ describe("allowSaveCookies", () => { it("writes nothing unless it was asked to", async () => { apiFetch.mockReturnValue(withSetCookie(201)); - await fetcher(usersModule, { + await fetcher({ + plugin: "@vitnode/core", args: { body: { email: "a@b.c", password: "secret" } }, method: "post", module: "users", @@ -249,7 +259,8 @@ describe("outside a request", () => { it("falls back to the configured API origin", async () => { requestUrl = null; - await fetcher(usersModule, { + await fetcher({ + plugin: "@vitnode/core", method: "get", module: "users", path: "/session", @@ -262,7 +273,8 @@ describe("outside a request", () => { requestUrl = null; vi.stubEnv("VITNODE_API_URL", "https://api.example.com"); - await fetcher(usersModule, { + await fetcher({ + plugin: "@vitnode/core", method: "get", module: "users", path: "/session", diff --git a/packages/vitnode/src/tanstack/fetcher/server.ts b/packages/vitnode/src/tanstack/fetcher/server.ts index 7d2e31669..67334ecfe 100644 --- a/packages/vitnode/src/tanstack/fetcher/server.ts +++ b/packages/vitnode/src/tanstack/fetcher/server.ts @@ -7,19 +7,13 @@ import { } from "@tanstack/react-start/server"; import { config } from "dotenv"; -import type { - BaseBuildModuleReturn, - BuildModuleReturn, -} from "@/api/lib/module"; -import type { Route } from "@/api/lib/route"; import type { RawApiFetchArgs } from "@/lib/fetcher/raw"; import type { - FetcherParams, + FetcherRequest, FetcherRequestOptions, - GetModulePaths, - GetValidMethodForPath, - GetValidPathsForModule, - InferResponseType, + FetcherResponse, + PluginRouteMethod, + RegisteredPluginId, } from "@/lib/fetcher/types"; import { CONFIG } from "@/lib/config"; @@ -81,50 +75,40 @@ export const rawFetcher = async ({ origin: origin ?? resolveApiOrigin(), }); +export type FetcherServerOptions = FetcherRequestOptions & { + allowSaveCookies?: boolean; + captchaToken?: string; +}; + export async function fetcher< + P extends RegisteredPluginId, M extends string, - Routes extends Route[], - Modules extends BaseBuildModuleReturn[], - ModuleName extends GetModulePaths, - SelectedPath extends GetValidPathsForModule, - Method extends GetValidMethodForPath< - ModuleName, - SelectedPath, - M, - Routes, - Modules - > = GetValidMethodForPath, ->( - moduleReturn: BuildModuleReturn, - { - path, - method, - module, - args, - options, - formData, - additionalHeaders, - allowSaveCookies = false, - captchaToken, - origin, - prefixPath = "", - withPagination = false, - }: FetcherParams & - FetcherRequestOptions & { - allowSaveCookies?: boolean; - captchaToken?: string; - }, -): Promise< - InferResponseType + Path extends string, + Method extends string = PluginRouteMethod, +>({ + plugin, + module, + path, + method, + args, + options, + formData, + additionalHeaders, + allowSaveCookies = false, + captchaToken, + origin, + withPagination = false, +}: FetcherRequest & FetcherServerOptions): Promise< + FetcherResponse > { - const response = await coreFetcher(moduleReturn, { + const response = await coreFetcher({ + plugin, + module, path, method, - module, args, options, formData, - prefixPath, withPagination, additionalHeaders: { ...getForwardedApiHeaders({ captchaToken }), @@ -134,8 +118,7 @@ export async function fetcher< // request's own origin otherwise - and an explicit `origin` on the call // overrides both. origin: origin ?? resolveApiOrigin(), - } as FetcherParams & - FetcherRequestOptions); + } as FetcherRequest & FetcherRequestOptions); if (allowSaveCookies && shouldSaveApiCookies((response as Response).status)) { saveApiCookies(response); diff --git a/packages/vitnode/src/tanstack/files/query.ts b/packages/vitnode/src/tanstack/files/query.ts index 2fde7f938..3c8776e19 100644 --- a/packages/vitnode/src/tanstack/files/query.ts +++ b/packages/vitnode/src/tanstack/files/query.ts @@ -11,25 +11,19 @@ import type { DeleteMyFiles, DeleteMyFilesArgs, } from "@/views/files/my-files-delete"; -import type { - MyFilesPageFetcher, - MyFilesParams, -} from "@/views/files/my-files-query"; +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 { - myFilesPageFetcher, + fetchMyFilesPage, myFilesQueryOptions, myFilesQueryRoot, } from "@/views/files/my-files-query"; -const fetchMyFilesPage: MyFilesPageFetcher = myFilesPageFetcher(fetcher); - export const myFilesQuery = ({ params, userId, diff --git a/packages/vitnode/src/tanstack/profile/index.ts b/packages/vitnode/src/tanstack/profile/index.ts index 8bd0b43c5..bf615e429 100644 --- a/packages/vitnode/src/tanstack/profile/index.ts +++ b/packages/vitnode/src/tanstack/profile/index.ts @@ -26,11 +26,11 @@ export type { UserProfileFetcher, } from "@/views/profile/profile-query"; export { + fetchUserProfile, isProfileNotFound, normalizeProfileNameCode, PROFILE_QUERY_ROOT, ProfileRequestError, - userProfileFetcher, userProfileQueryKey, userProfileQueryOptions, } from "@/views/profile/profile-query"; diff --git a/packages/vitnode/src/tanstack/profile/query.ts b/packages/vitnode/src/tanstack/profile/query.ts index b4f9aa5e2..78d07a829 100644 --- a/packages/vitnode/src/tanstack/profile/query.ts +++ b/packages/vitnode/src/tanstack/profile/query.ts @@ -1,13 +1,8 @@ -import type { UserProfileFetcher } from "@/views/profile/profile-query"; - -import { fetcher } from "@/tanstack/fetcher"; import { - userProfileFetcher, + fetchUserProfile, userProfileQueryOptions, } from "@/views/profile/profile-query"; -const fetchProfile: UserProfileFetcher = userProfileFetcher(fetcher); - /** One public profile, for the screen, its loader and its breadcrumb. */ export const userProfileQuery = (nameCode: string) => - userProfileQueryOptions({ fetchProfile, nameCode }); + userProfileQueryOptions({ fetchProfile: fetchUserProfile, nameCode }); diff --git a/packages/vitnode/src/tanstack/search/feed.ts b/packages/vitnode/src/tanstack/search/feed.ts index 71ea5cef9..39165f04b 100644 --- a/packages/vitnode/src/tanstack/search/feed.ts +++ b/packages/vitnode/src/tanstack/search/feed.ts @@ -1,17 +1,11 @@ -import type { - SearchFeedPageFetcher, - SearchFeedParams, -} from "@/views/search/search-feed-query"; +import type { SearchFeedParams } from "@/views/search/search-feed-query"; -import { fetcher } from "@/tanstack/fetcher"; import { - searchFeedPageFetcher, + fetchSearchFeedPage, searchFeedQueryKey, searchFeedQueryOptions, } from "@/views/search/search-feed-query"; - -export const fetchSearchFeedPage: SearchFeedPageFetcher = - searchFeedPageFetcher(fetcher); +export { fetchSearchFeedPage } from "@/views/search/search-feed-query"; export const feedQueryKey = ({ locale, diff --git a/packages/vitnode/src/tanstack/settings/personal-policy.ts b/packages/vitnode/src/tanstack/settings/personal-policy.ts index b8f66b701..857206b64 100644 --- a/packages/vitnode/src/tanstack/settings/personal-policy.ts +++ b/packages/vitnode/src/tanstack/settings/personal-policy.ts @@ -1,13 +1,7 @@ -import type { PersonalInfoPolicyFetcher } from "@/views/auth/settings/overview/personal-update"; - -import { fetcher } from "@/tanstack/fetcher"; import { - personalInfoPolicyFetcher, + fetchPersonalInfoPolicy, personalInfoPolicyQueryOptions, } from "@/views/auth/settings/overview/personal-update"; -const fetchPolicy: PersonalInfoPolicyFetcher = - personalInfoPolicyFetcher(fetcher); - export const personalInfoPolicyQuery = () => - personalInfoPolicyQueryOptions({ fetchPolicy }); + personalInfoPolicyQueryOptions({ fetchPolicy: fetchPersonalInfoPolicy }); diff --git a/packages/vitnode/src/views/admin/admin-request.ts b/packages/vitnode/src/views/admin/admin-request.ts index db49daa25..6ca94cda2 100644 --- a/packages/vitnode/src/views/admin/admin-request.ts +++ b/packages/vitnode/src/views/admin/admin-request.ts @@ -1,11 +1,3 @@ -import type { BaseBuildModuleReturn } from "@/api/lib/module"; - -import { CONFIG_PLUGIN } from "@/config"; -import { clientModule } from "@/lib/fetcher-client"; - -export const adminModuleRef = (): T => - clientModule(CONFIG_PLUGIN.pluginId); - /** The `name` every {@link AdminRequestError} carries. See below. */ const ADMIN_REQUEST_ERROR = "AdminRequestError"; 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 42e73f42e..c1ddf9d3a 100644 --- a/packages/vitnode/src/views/admin/views/content/content-request.ts +++ b/packages/vitnode/src/views/admin/views/content/content-request.ts @@ -1,9 +1,8 @@ import type { z } from "zod"; -import type { UniversalRawFetcher } from "@/lib/fetcher-client"; import type { RawApiFetchArgs } from "@/lib/fetcher/raw"; -import { rawFetcherClient } from "@/lib/fetcher-client"; +import { rawFetcher } from "@/tanstack/fetcher"; import { AdminRequestError } from "@/views/admin/admin-request"; /** Which generated module a request is for. */ @@ -39,17 +38,10 @@ export const contentApiFetchArgs = ({ query, }); -/** - * One request to a generated content module, over whichever transport it is - * handed. See {@link ContentApiFetch}. - * - * 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 } }); +export const contentApiFetch: ContentApiFetch = async ( + request, + { signal } = {}, +) => await rawFetcher({ ...contentApiFetchArgs(request), options: { signal } }); /** * How one content request is carried. @@ -66,10 +58,6 @@ export type ContentApiFetch = ( 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 { /** What this read was, in words, for an error message somebody has to act on. */ 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 b7725e3ee..16f002ef4 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,16 +6,13 @@ import type { AnyContentTypeDefinition } from "@/content/types"; import { RECORD_STALE_TIME } from "@/lib/query-freshness"; import type { TranslationRow } from "../content-mutation"; -import type { ContentApiFetch, ContentApiTarget } from "../content-request"; +import type { ContentApiTarget } from "../content-request"; import { contentItemQueryKey, contentTranslationsQueryKey, } from "../content-query"; -import { - contentApiFetchInBrowser, - readContentApiJson, -} from "../content-request"; +import { contentApiFetch, readContentApiJson } from "../content-request"; export const zodContentItem = z .object({ @@ -53,24 +50,21 @@ export type ContentTranslationsFetcher = ( request: ContentItemRequest, ) => Promise; -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 => +export const fetchContentItem: ContentItemFetcher = async request => + await readContentApiJson( + await contentApiFetch({ + method: "get", + path: `/${request.itemId}`, + target: request.target, + }), + { describe: describeContentItem(request), schema: zodContentItem }, + ); + +export const fetchContentTranslations: ContentTranslationsFetcher = async request => ( await readContentApiJson( - await fetchApi({ + await contentApiFetch({ method: "get", path: `/${request.itemId}/translations`, target: request.target, @@ -82,13 +76,6 @@ export const 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/form/mutations-api.test.ts b/packages/vitnode/src/views/admin/views/content/form/mutations-api.test.ts index f40762632..aa5443741 100644 --- a/packages/vitnode/src/views/admin/views/content/form/mutations-api.test.ts +++ b/packages/vitnode/src/views/admin/views/content/form/mutations-api.test.ts @@ -12,7 +12,7 @@ vi.mock("../content-request", async () => { return { ...actual, - contentApiFetchInBrowser: async (request: ContentApiRequest) => + contentApiFetch: async (request: ContentApiRequest) => await fetchMock(request), }; }); diff --git a/packages/vitnode/src/views/admin/views/content/lib/api-result.ts b/packages/vitnode/src/views/admin/views/content/lib/api-result.ts index d7e24103c..bad618bd8 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/api-result.ts +++ b/packages/vitnode/src/views/admin/views/content/lib/api-result.ts @@ -11,7 +11,7 @@ import { import type { ContentMutationResult } from "../content-mutation"; import type { ContentApiRequest } from "../content-request"; -import { contentApiFetchInBrowser } from "../content-request"; +import { contentApiFetch } from "../content-request"; /** The shape `contentApiFetch` answers with on the server, read in a browser. */ export interface ContentFetchResult { @@ -38,7 +38,7 @@ export const sendContentApiRequest = async ( let response: Response; try { - response = await contentApiFetchInBrowser(request); + response = await contentApiFetch(request); } catch (error) { return unreachableContentResult(error); } diff --git a/packages/vitnode/src/views/admin/views/content/table/list-mutations.ts b/packages/vitnode/src/views/admin/views/content/table/list-mutations.ts index 575200eb4..0a74b85ac 100644 --- a/packages/vitnode/src/views/admin/views/content/table/list-mutations.ts +++ b/packages/vitnode/src/views/admin/views/content/table/list-mutations.ts @@ -3,7 +3,7 @@ import type { ContentPublicationAction } from "@/content/publication"; import type { ContentMutationResult } from "../content-mutation"; import type { ContentApiTarget } from "../content-request"; -import { contentApiFetchInBrowser } from "../content-request"; +import { contentApiFetch } from "../content-request"; import { contentFailureResult } from "../lib/api-result"; export type ContentRowMutationResult = ContentMutationResult & { @@ -53,7 +53,7 @@ export const setContentPublicationInBrowser = async ({ }): Promise => await readResult( async () => - await contentApiFetchInBrowser({ + await contentApiFetch({ method: "post", path: `/${id}/${action}`, target, @@ -82,7 +82,7 @@ export const deleteContentInBrowser = async ({ }): Promise => await readResult( async () => - await contentApiFetchInBrowser({ + await contentApiFetch({ ...(editorial ? { body: { expectedVersion: version } } : {}), method: "delete", path: `/${id}`, 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 34cd3aa72..923c4eda8 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,18 +8,11 @@ import type { import { RECORD_STALE_TIME } from "@/lib/query-freshness"; -import type { - ContentApiFetch, - ContentApiRequest, - ContentApiTarget, -} from "../content-request"; +import type { ContentApiRequest, ContentApiTarget } from "../content-request"; import type { ContentRowData } from "./cells"; import { contentListQueryKey } from "../content-query"; -import { - contentApiFetchInBrowser, - readContentApiJson, -} from "../content-request"; +import { contentApiFetch, readContentApiJson } from "../content-request"; /** One page of an admin content list. */ export type ContentListPage = AdminTablePage; @@ -111,20 +104,17 @@ export const describeContentList = (request: ContentListRequest): string => * 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 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); +export const fetchContentListPage: ContentListPageFetcher = async ( + request, + { signal } = {}, +) => + await readContentApiJson( + await contentApiFetch(contentListApiRequest(request), { signal }), + { + describe: describeContentList(request), + schema: zodContentListPage, + }, + ); /** * 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 c2ccc4beb..35bb484c0 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,27 +1,20 @@ 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, AdminTableParams, } from "@/views/admin/table/params"; -import { fetcherClient } from "@/lib/fetcher-client"; +import { CONFIG_PLUGIN } from "@/config"; import { OPERATIONAL_STALE_TIME } from "@/lib/query-freshness"; +import { fetcher } from "@/tanstack/fetcher"; import { - adminModuleRef, AdminRequestError, describeAdminParams, } from "@/views/admin/admin-request"; import { adminQueryRoot } from "@/views/admin/table/query"; -export const cronAdminModuleRef = adminModuleRef(); - -/** The module is mounted under `/admin/advanced`, not at the plugin root. */ -export const CRON_PREFIX_PATH = "/admin/advanced"; - export const CRON_ORDER_BY = ["createdAt", "lastRun", "nextRun"] as const; export type CronOrderBy = (typeof CRON_ORDER_BY)[number]; @@ -50,30 +43,25 @@ export type CronPage = AdminTablePage; /** How a page is actually fetched. See {@link cronQueryOptions}. */ export type CronPageFetcher = (params: CronParams) => Promise; -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), - ); - } +export const fetchCronPage: CronPageFetcher = async params => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { query: params }, + method: "get", + module: "admin/advanced/cron", + path: "/", + }); - return await response.json(); - }; + if (!response.ok) { + throw new AdminRequestError( + response.status, + "the cron list", + describeAdminParams(params), + ); + } -export const fetchCronPageInBrowser: CronPageFetcher = - cronPageFetcher(fetcherClient); + return await response.json(); +}; /** The root every cached page of the cron list hangs off. */ export const cronQueryRoot = adminQueryRoot("cron"); @@ -82,7 +70,7 @@ export const cronQueryKey = (params: CronParams) => [...cronQueryRoot, params] as const; export const cronQueryOptions = ({ - fetchPage = fetchCronPageInBrowser, + fetchPage = fetchCronPage, params, }: { fetchPage?: CronPageFetcher; diff --git a/packages/vitnode/src/views/admin/views/core/advanced/cron/run-action/run-cron.ts b/packages/vitnode/src/views/admin/views/core/advanced/cron/run-action/run-cron.ts index 2a9ede4fa..d671da287 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/cron/run-action/run-cron.ts +++ b/packages/vitnode/src/views/admin/views/core/advanced/cron/run-action/run-cron.ts @@ -1,7 +1,5 @@ +import { CONFIG_PLUGIN } from "@/config"; import { fetcherClient } from "@/lib/fetcher-client"; -import { CRON_PREFIX_PATH } from "@/views/admin/views/core/advanced/cron/cron-query"; - -import { cronAdminModuleRef } from "../cron-query"; export type RunCronResult = undefined | { error?: string }; @@ -9,13 +7,13 @@ export type RunCron = (id: number) => Promise; export const runCronInBrowser: RunCron = async id => { try { - const response = await fetcherClient(cronAdminModuleRef, { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { params: { id: String(id) } }, method: "post", - module: "cron", + module: "admin/advanced/cron", options: { credentials: "include" }, path: "/{id}", - prefixPath: CRON_PREFIX_PATH, }); if (!response.ok) return { error: "Failed to run cron job" }; 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 e420a5003..34585db34 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,27 +1,20 @@ 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, AdminTableParams, } from "@/views/admin/table/params"; -import { fetcherClient } from "@/lib/fetcher-client"; +import { CONFIG_PLUGIN } from "@/config"; import { OPERATIONAL_STALE_TIME } from "@/lib/query-freshness"; +import { fetcher } from "@/tanstack/fetcher"; import { - adminModuleRef, AdminRequestError, describeAdminParams, } from "@/views/admin/admin-request"; import { adminQueryRoot } from "@/views/admin/table/query"; -export const queueAdminModuleRef = adminModuleRef(); - -/** The module is mounted under `/admin/advanced`, not at the plugin root. */ -export const QUEUE_PREFIX_PATH = "/admin/advanced"; - /** The statuses a task can be in - `QUEUE_STATUSES` on `getQueueTasksRoute`. */ export const QUEUE_STATUSES = [ "pending", @@ -65,31 +58,25 @@ export type QueuePage = AdminTablePage; export type QueuePageFetcher = (params: QueueParams) => Promise; /** 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), - ); - } +export const fetchQueuePage: QueuePageFetcher = async params => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { query: params }, + method: "get", + module: "admin/advanced/queue", + path: "/", + }); - return await response.json(); - }; + if (!response.ok) { + throw new AdminRequestError( + response.status, + "the queue list", + describeAdminParams(params), + ); + } -/** One page, fetched from the browser. */ -export const fetchQueuePageInBrowser: QueuePageFetcher = - queuePageFetcher(fetcherClient); + return await response.json(); +}; /** The root every cached page of the queue list hangs off. */ export const queueQueryRoot = adminQueryRoot("queue"); @@ -98,7 +85,7 @@ export const queueQueryKey = (params: QueueParams) => [...queueQueryRoot, params] as const; export const queueQueryOptions = ({ - fetchPage = fetchQueuePageInBrowser, + fetchPage = fetchQueuePage, params, }: { fetchPage?: QueuePageFetcher; diff --git a/packages/vitnode/src/views/admin/views/core/advanced/search/search-index-mutations.ts b/packages/vitnode/src/views/admin/views/core/advanced/search/search-index-mutations.ts index 63d8669e5..32299a563 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/search/search-index-mutations.ts +++ b/packages/vitnode/src/views/admin/views/core/advanced/search/search-index-mutations.ts @@ -1,7 +1,6 @@ +import { CONFIG_PLUGIN } from "@/config"; import { fetcherClient } from "@/lib/fetcher-client"; -import { searchDebugAdminModuleRef } from "./search-index-query"; - /** What a mutation reports back. `error` is the API's own text. */ export interface SearchIndexMutationResult { data?: unknown; @@ -26,13 +25,13 @@ export interface SearchIndexActions { export const rebuildSearchIndexInBrowser: RebuildSearchIndex = async itemType => { try { - const response = await fetcherClient(searchDebugAdminModuleRef, { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { body: itemType ? { itemType } : {} }, method: "post", - module: "debug", + module: "admin/debug", options: { credentials: "include" }, path: "/search/rebuild", - prefixPath: "/admin", }); if (!response.ok) return { error: await response.text() }; @@ -47,13 +46,13 @@ export const rebuildSearchIndexInBrowser: RebuildSearchIndex = export const clearSearchCollectionInBrowser: ClearSearchCollection = async itemType => { try { - const response = await fetcherClient(searchDebugAdminModuleRef, { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { body: { itemType } }, method: "post", - module: "debug", + module: "admin/debug", options: { credentials: "include" }, path: "/search/clear", - prefixPath: "/admin", }); if (!response.ok) return { error: await response.text() }; 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 1c40ca91b..9fead86a3 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,20 +1,14 @@ 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 { CONFIG_PLUGIN } from "@/config"; import { OPERATIONAL_STALE_TIME } from "@/lib/query-freshness"; -import { adminModuleRef, AdminRequestError } from "@/views/admin/admin-request"; +import { fetcher } from "@/tanstack/fetcher"; +import { AdminRequestError } from "@/views/admin/admin-request"; import { adminQueryRoot } from "@/views/admin/table/query"; -import { ADMIN_DEBUG_PREFIX_PATH } from "@/views/admin/views/core/system/integrations/integrations-query"; import type { SearchCollection } from "./collection-status"; import type { SearchSyncError } from "./sync-errors"; -export const searchDebugAdminModuleRef = - adminModuleRef(); - export interface SearchIndexStatus { collections: SearchCollection[]; engine: string; @@ -31,32 +25,26 @@ export interface SearchIndexStatus { export type SearchIndexStatusFetcher = () => Promise; /** The status, over whichever transport the host hands in. */ -export const searchIndexStatusFetcher = - (transport: UniversalFetcher): SearchIndexStatusFetcher => - async () => { - const response = await transport(searchDebugAdminModuleRef, { - method: "get", - module: "debug", - path: "/search/status", - prefixPath: ADMIN_DEBUG_PREFIX_PATH, - }); - - if (!response.ok) { - throw new AdminRequestError(response.status, "the search index status"); - } +export const fetchSearchIndexStatus: SearchIndexStatusFetcher = async () => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "admin/debug", + path: "/search/status", + }); - return await response.json(); - }; + if (!response.ok) { + throw new AdminRequestError(response.status, "the search index status"); + } -/** The status, fetched from the browser. */ -export const fetchSearchIndexStatusInBrowser: SearchIndexStatusFetcher = - searchIndexStatusFetcher(fetcherClient); + return await response.json(); +}; /** The cache entry this screen reads and writes. */ export const searchIndexQueryKey = adminQueryRoot("search-index"); export const searchIndexQueryOptions = ({ - fetchStatus = fetchSearchIndexStatusInBrowser, + fetchStatus = fetchSearchIndexStatus, }: { fetchStatus?: SearchIndexStatusFetcher; } = {}) => 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 1ffe5f311..58bc64cfe 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 @@ -1,13 +1,12 @@ 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 { CONFIG_PLUGIN } from "@/config"; import { fetcherClient } from "@/lib/fetcher-client"; import { RECORD_STALE_TIME } from "@/lib/query-freshness"; -import { adminModuleRef } from "@/views/admin/admin-request"; +import { fetcher } from "@/tanstack/fetcher"; import { ADMIN_DASHBOARD_SCREEN, adminScopedQueryRoot, @@ -16,8 +15,6 @@ import { import type { DashboardMutationResult } from "./dashboard-actions"; import type { DashboardLayoutItem } from "./types"; -const adminModuleClientRef = adminModuleRef(); - /** The read, as arguments to whichever fetcher is carrying it. */ /** What the layout read resolves to: the stored items, or none. */ export type DashboardStoredLayout = AdminDashboardWidgetLayoutItem[]; @@ -25,30 +22,25 @@ export type DashboardStoredLayout = AdminDashboardWidgetLayoutItem[]; export type DashboardLayoutFetcher = () => Promise; /** The stored layout, over whichever transport the host hands in. */ -export const dashboardLayoutFetcher = - (transport: UniversalFetcher): DashboardLayoutFetcher => - async () => { - const response = await transport(adminModuleClientRef, { - method: "get", - module: "admin/dashboard", - path: "/", - }); - - if (!response.ok) return []; +export const fetchDashboardLayout: DashboardLayoutFetcher = async () => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "admin/dashboard", + path: "/", + }); - return (await response.json()).widgets; - }; + if (!response.ok) return []; -/** The stored layout, fetched from the browser. */ -export const fetchDashboardLayoutInBrowser: DashboardLayoutFetcher = - dashboardLayoutFetcher(fetcherClient); + return (await response.json()).widgets; +}; export const dashboardLayoutQueryKey = (adminUserId: AdminIdentity) => adminScopedQueryRoot(ADMIN_DASHBOARD_SCREEN, adminUserId); export const dashboardLayoutQueryOptions = ({ adminUserId, - fetchLayout = fetchDashboardLayoutInBrowser, + fetchLayout = fetchDashboardLayout, }: { adminUserId: AdminIdentity; fetchLayout?: DashboardLayoutFetcher; @@ -68,7 +60,8 @@ export const saveDashboardLayoutInBrowser = async ({ widgets: DashboardLayoutItem[]; }): Promise => { try { - const response = await fetcherClient(adminModuleClientRef, { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { body: { managed, diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/widget-mutations.ts b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/widget-mutations.ts index 16195aa65..64eb4e6ca 100644 --- a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/widget-mutations.ts +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/widget-mutations.ts @@ -1,15 +1,12 @@ import type { z } from "zod"; -import type { adminModule } from "@/api/modules/admin/admin.module"; import type { zodSendNotificationSchema } from "@/api/modules/admin/routes/notifications.route"; +import { CONFIG_PLUGIN } from "@/config"; import { fetcherClient } from "@/lib/fetcher-client"; -import { adminModuleRef } from "@/views/admin/admin-request"; import type { DashboardMutationResult } from "./dashboard-actions"; -const adminModuleClientRef = adminModuleRef(); - /** One widget's settings, saved. */ export const saveWidgetSettingsInBrowser = async ({ settings, @@ -19,7 +16,8 @@ export const saveWidgetSettingsInBrowser = async ({ widgetId: string; }): Promise => { try { - const response = await fetcherClient(adminModuleClientRef, { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { body: { settings, widgetId } }, method: "put", module: "admin/dashboard", @@ -42,7 +40,8 @@ export const sendNotificationInBrowser = async ( body: z.infer, ): Promise => { try { - const response = await fetcherClient(adminModuleClientRef, { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { body }, method: "post", module: "admin", 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 95b47d001..8707005e4 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,7 +1,5 @@ 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, @@ -9,20 +7,15 @@ import type { } from "@/views/admin/table/params"; import type { QueueStatus } from "@/views/admin/views/core/advanced/queue/queue-query"; -import { fetcherClient } from "@/lib/fetcher-client"; +import { CONFIG_PLUGIN } from "@/config"; import { OPERATIONAL_STALE_TIME } from "@/lib/query-freshness"; +import { fetcher } from "@/tanstack/fetcher"; import { - adminModuleRef, AdminRequestError, describeAdminParams, } from "@/views/admin/admin-request"; import { adminQueryRoot } from "@/views/admin/table/query"; -export const debugAdminModuleRef = adminModuleRef(); - -/** The debug module is mounted under `/admin`, not at the plugin root. */ -export const DEBUG_PREFIX_PATH = "/admin"; - // ------------------------------------------------------------ system log --- export const DEBUG_LOGS_ORDER_BY = ["type", "createdAt", "pluginId"] as const; @@ -58,31 +51,25 @@ export type DebugLogsPageFetcher = ( ) => Promise; /** One page of the log, over whichever transport the host hands in. */ -export const debugLogsPageFetcher = - (transport: UniversalFetcher): DebugLogsPageFetcher => - async params => { - const response = await transport(debugAdminModuleRef, { - 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 fetchDebugLogsPage: DebugLogsPageFetcher = async params => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { query: params }, + method: "get", + module: "admin/debug", + path: "/logs", + }); + + if (!response.ok) { + throw new AdminRequestError( + response.status, + "the system log", + describeAdminParams(params), + ); + } -/** One page of the log, fetched from the browser. */ -export const fetchDebugLogsPageInBrowser: DebugLogsPageFetcher = - debugLogsPageFetcher(fetcherClient); + return await response.json(); +}; /** The root every cached page of the system log hangs off. */ export const debugLogsQueryRoot = adminQueryRoot("debug-logs"); @@ -91,7 +78,7 @@ export const debugLogsQueryKey = (params: DebugLogsParams) => [...debugLogsQueryRoot, params] as const; export const debugLogsQueryOptions = ({ - fetchPage = fetchDebugLogsPageInBrowser, + fetchPage = fetchDebugLogsPage, params, }: { fetchPage?: DebugLogsPageFetcher; @@ -136,32 +123,26 @@ export interface DebugQueueSnapshot { export type DebugQueueFetcher = () => Promise; /** 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"); - } - - return await response.json(); - }; +export const fetchDebugQueue: DebugQueueFetcher = async () => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "admin/debug", + path: "/queue", + }); -/** The snapshot, fetched from the browser. */ -export const fetchDebugQueueInBrowser: DebugQueueFetcher = - debugQueueFetcher(fetcherClient); + if (!response.ok) { + throw new AdminRequestError(response.status, "the queue snapshot"); + } + + return await response.json(); +}; /** The cache entry the queue snapshot reads and writes. */ export const debugQueueQueryKey = adminQueryRoot("debug-queue"); export const debugQueueQueryOptions = ({ - fetchSnapshot = fetchDebugQueueInBrowser, + fetchSnapshot = fetchDebugQueue, }: { fetchSnapshot?: DebugQueueFetcher; } = {}) => diff --git a/packages/vitnode/src/views/admin/views/core/staff/staff-mutations.ts b/packages/vitnode/src/views/admin/views/core/staff/staff-mutations.ts index fbc87994f..715903646 100644 --- a/packages/vitnode/src/views/admin/views/core/staff/staff-mutations.ts +++ b/packages/vitnode/src/views/admin/views/core/staff/staff-mutations.ts @@ -3,12 +3,12 @@ import type { PermissionStaffType, } from "@/api/lib/permission-staff"; +import { CONFIG_PLUGIN } from "@/config"; import { fetcherClient } from "@/lib/fetcher-client"; import { type AdminMutationResult, runAdminApiMutation, } from "@/views/admin/views/core/shared/admin-mutation"; -import { adminModuleRef } from "@/views/admin/views/core/users/list/users-query"; export interface CreateStaffEntryInput { roleId?: number; @@ -25,7 +25,8 @@ export const createStaffEntry = async ({ expected: 201, parse: async response => (await response.json()) as { id: number }, request: async () => - await fetcherClient(adminModuleRef, { + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { body: { roleId, userId }, params: { type } }, method: "post", module: "admin/staff", @@ -60,7 +61,8 @@ export const updateStaffPermissions = async ({ unrestricted: boolean; }, request: async () => - await fetcherClient(adminModuleRef, { + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { body: { permissions, unrestricted }, params: { id, type }, @@ -84,7 +86,8 @@ export const deleteStaffEntry = async ({ expected: 200, parse: () => true as const, request: async () => - await fetcherClient(adminModuleRef, { + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { params: { id: String(id), type } }, method: "delete", module: "admin/staff", 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 906a97996..526b79ab7 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,7 +4,6 @@ import type { PermissionsStaffArgs, PermissionStaffType, } from "@/api/lib/permission-staff"; -import type { UniversalFetcher } from "@/lib/fetcher-client"; import type { AdminTableContract, AdminTablePage, @@ -14,7 +13,6 @@ import type { import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; import type { StaffCatalog } from "@/views/admin/views/core/staff/staff-model"; -import { fetcherClient } from "@/lib/fetcher-client"; import { AdminRequestError, describeAdminParams, @@ -26,7 +24,6 @@ import { adminScopedQueryRoot, } from "@/views/admin/views/core/shared/admin-scope"; import { STAFF_TYPE_SEGMENT } from "@/views/admin/views/core/staff/staff-model"; -import { adminModuleRef } from "@/views/admin/views/core/users/list/users-query"; /** The columns both staff lists sort by - `staffListAdminQuery`'s enum. */ export const ADMIN_STAFF_ORDER_BY = ["id", "createdAt", "updatedAt"] as const; @@ -54,7 +51,9 @@ export { adminStaffPermissions, staffPermissionModuleFor, } from "@/views/admin/views/core/shared/admin-permissions"; +import { CONFIG_PLUGIN } from "@/config"; import { RECORD_STALE_TIME } from "@/lib/query-freshness"; +import { fetcher } from "@/tanstack/fetcher"; /** A role reference as a staff row renders it. */ export interface AdminStaffRole { @@ -91,30 +90,30 @@ export type AdminStaffPageFetcher = ( options?: { signal?: AbortSignal }, ) => Promise; -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), - ); - } - - return await response.json(); - }; +export const fetchAdminStaffPage: AdminStaffPageFetcher = async ( + type, + params, + { signal } = {}, +) => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { query: params }, + method: "get", + module: "admin/staff", + options: { signal }, + path: type === "admin" ? "/admins" : "/moderators", + }); -export const fetchAdminStaffPageInBrowser: AdminStaffPageFetcher = - adminStaffPageFetcher(fetcherClient); + if (!response.ok) { + throw new AdminRequestError( + response.status, + `the ${STAFF_TYPE_SEGMENT[type]} staff list`, + describeAdminParams(params), + ); + } + + return await response.json(); +}; export const adminStaffQueryRoot = (adminUserId: AdminIdentity) => adminScopedQueryRoot(ADMIN_STAFF_SCREEN, adminUserId); @@ -132,7 +131,7 @@ export const adminStaffQueryKey = ({ export const adminStaffQueryOptions = ({ adminUserId, - fetchPage = fetchAdminStaffPageInBrowser, + fetchPage = fetchAdminStaffPage, params, type, }: { @@ -158,27 +157,23 @@ export const adminStaffQueryOptions = ({ export type AdminStaffCatalogFetcher = () => Promise; -export const adminStaffCatalogFetcher = - (transport: UniversalFetcher): AdminStaffCatalogFetcher => - async () => { - const response = await transport(adminModuleRef, { - 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 fetchAdminStaffCatalog: AdminStaffCatalogFetcher = async () => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "admin/staff", + path: "/permission-catalog", + }); -export const fetchAdminStaffCatalogInBrowser: AdminStaffCatalogFetcher = - adminStaffCatalogFetcher(fetcherClient); + if (!response.ok) { + throw new AdminRequestError( + response.status, + "the staff permission catalog", + ); + } + + return await response.json(); +}; /** * The catalog is what the *installation* declares, not what one administrator @@ -190,7 +185,7 @@ export const adminStaffCatalogQueryKey = (adminUserId: AdminIdentity) => export const adminStaffCatalogQueryOptions = ({ adminUserId, - fetchCatalog = fetchAdminStaffCatalogInBrowser, + fetchCatalog = fetchAdminStaffCatalog, }: { adminUserId: AdminIdentity; fetchCatalog?: AdminStaffCatalogFetcher; @@ -217,29 +212,28 @@ export type AdminStaffEntryFetcher = ( id: string, ) => Promise; -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}`, - ); - } - - return await response.json(); - }; +export const fetchAdminStaffEntry: AdminStaffEntryFetcher = async ( + type, + id, +) => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + 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}`, + ); + } -export const fetchAdminStaffEntryInBrowser: AdminStaffEntryFetcher = - adminStaffEntryFetcher(fetcherClient); + return await response.json(); +}; export const adminStaffEntryQueryKey = ({ adminUserId, @@ -253,7 +247,7 @@ export const adminStaffEntryQueryKey = ({ export const adminStaffEntryQueryOptions = ({ adminUserId, - fetchEntry = fetchAdminStaffEntryInBrowser, + fetchEntry = fetchAdminStaffEntry, id, type, }: { diff --git a/packages/vitnode/src/views/admin/views/core/system/files/actions/file-row-actions.tsx b/packages/vitnode/src/views/admin/views/core/system/files/actions/file-row-actions.tsx index 4968b3ee1..7d436ec1c 100644 --- a/packages/vitnode/src/views/admin/views/core/system/files/actions/file-row-actions.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/files/actions/file-row-actions.tsx @@ -8,12 +8,11 @@ import type { FileInUse } from "@/lib/files/in-use"; import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; import { Button } from "@/components/ui/button"; import { TooltipWithContent } from "@/components/ui/tooltip"; +import { CONFIG_PLUGIN } from "@/config"; import { fetcherClient } from "@/lib/fetcher-client"; import type { DeleteAdminFile } from "../files-delete"; -import { filesAdminModuleRef } from "../files-query"; - export const FileRowActions = ({ canDelete, canDownload, @@ -36,9 +35,9 @@ export const FileRowActions = ({ const handleDownload = async () => { setIsDownloading(true); try { - const res = await fetcherClient(filesAdminModuleRef, { - prefixPath: "/admin", - module: "files", + const res = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + module: "admin/files", path: "/{id}/download", method: "get", args: { params: { id: String(id) } }, diff --git a/packages/vitnode/src/views/admin/views/core/system/files/files-delete.ts b/packages/vitnode/src/views/admin/views/core/system/files/files-delete.ts index 4be2c8a10..852441131 100644 --- a/packages/vitnode/src/views/admin/views/core/system/files/files-delete.ts +++ b/packages/vitnode/src/views/admin/views/core/system/files/files-delete.ts @@ -1,12 +1,10 @@ import type { BulkDeleteFilesResult } from "@/lib/files/bulk-delete"; import type { DeleteFileResult } from "@/lib/files/in-use"; +import { CONFIG_PLUGIN } from "@/config"; import { fetcherClient } from "@/lib/fetcher-client"; import { runBulkFileDelete } from "@/lib/files/bulk-delete"; import { readFileInUse } from "@/lib/files/in-use"; -import { ADMIN_FILES_PREFIX_PATH } from "@/views/admin/views/core/system/files/files-query"; - -import { filesAdminModuleRef } from "./files-query"; /** Deleting one file. `force` releases retained revisions; see `FileInUse`. */ export interface DeleteAdminFileArgs { @@ -33,16 +31,16 @@ export const deleteAdminFileInBrowser: DeleteAdminFile = async ({ id, }) => { try { - const response = await fetcherClient(filesAdminModuleRef, { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { params: { id: String(id) }, query: force ? { force: "true" } : {}, }, method: "delete", - module: "files", + module: "admin/files", options: { credentials: "include" }, path: "/{id}", - prefixPath: ADMIN_FILES_PREFIX_PATH, }); if (response.status !== 200) { 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 52508ad9a..6d0c7a5c2 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,27 +1,20 @@ 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, AdminTableParams, } from "@/views/admin/table/params"; -import { fetcherClient } from "@/lib/fetcher-client"; +import { CONFIG_PLUGIN } from "@/config"; import { RECORD_STALE_TIME } from "@/lib/query-freshness"; +import { fetcher } from "@/tanstack/fetcher"; import { - adminModuleRef, AdminRequestError, describeAdminParams, } from "@/views/admin/admin-request"; import { adminQueryRoot } from "@/views/admin/table/query"; -export const filesAdminModuleRef = adminModuleRef(); - -/** The module is mounted under `/admin`, not at the plugin root. */ -export const ADMIN_FILES_PREFIX_PATH = "/admin"; - export const ADMIN_FILES_ORDER_BY = ["name", "size", "createdAt"] as const; export type AdminFilesOrderBy = (typeof ADMIN_FILES_ORDER_BY)[number]; @@ -71,31 +64,25 @@ export type AdminFilesPageFetcher = ( ) => Promise; /** One page, over whichever transport the host hands in. */ -export const adminFilesPageFetcher = - (transport: UniversalFetcher): AdminFilesPageFetcher => - async params => { - const response = await transport(filesAdminModuleRef, { - 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), - ); - } +export const fetchAdminFilesPage: AdminFilesPageFetcher = async params => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { query: params }, + method: "get", + module: "admin/files", + path: "/", + }); - return await response.json(); - }; + if (!response.ok) { + throw new AdminRequestError( + response.status, + "the uploaded files list", + describeAdminParams(params), + ); + } -/** One page, fetched from the browser. */ -export const fetchAdminFilesPageInBrowser: AdminFilesPageFetcher = - adminFilesPageFetcher(fetcherClient); + return await response.json(); +}; /** The root every cached page of the admin file list hangs off. */ export const adminFilesQueryRoot = adminQueryRoot("files"); @@ -104,7 +91,7 @@ export const adminFilesQueryKey = (params: AdminFilesParams) => [...adminFilesQueryRoot, params] as const; export const adminFilesQueryOptions = ({ - fetchPage = fetchAdminFilesPageInBrowser, + fetchPage = fetchAdminFilesPage, params, }: { fetchPage?: AdminFilesPageFetcher; 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 7fc97f659..65493a0df 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,18 +1,11 @@ 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 { CONFIG_PLUGIN } from "@/config"; import { RECORD_STALE_TIME } from "@/lib/query-freshness"; -import { adminModuleRef, AdminRequestError } from "@/views/admin/admin-request"; +import { fetcher } from "@/tanstack/fetcher"; +import { AdminRequestError } from "@/views/admin/admin-request"; import { adminQueryRoot } from "@/views/admin/table/query"; -export const debugAdminModuleRef = adminModuleRef(); - -/** The debug module is mounted under `/admin`, not at the plugin root. */ -export const ADMIN_DEBUG_PREFIX_PATH = "/admin"; - /** One AI model the "test AI" dialog can be pointed at. */ export interface AdminIntegrationModel { id: string; @@ -51,35 +44,25 @@ export interface AdminIntegrations { export type IntegrationsFetcher = () => Promise; /** 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"); - } +export const fetchIntegrations: IntegrationsFetcher = async () => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "admin/debug", + path: "/integrations", + }); - return await response.json(); - }; + if (!response.ok) { + throw new AdminRequestError(response.status, "the integrations board"); + } -/** The board's data, fetched from the browser. */ -export const fetchIntegrationsInBrowser: IntegrationsFetcher = - integrationsFetcher(fetcherClient); + return await response.json(); +}; /** The cache entry the board reads and writes. */ export const integrationsQueryKey = adminQueryRoot("integrations"); -export const integrationsQueryOptions = ({ - fetchIntegrations = fetchIntegrationsInBrowser, -}: { - fetchIntegrations?: IntegrationsFetcher; -} = {}) => +export const integrationsQueryOptions = () => queryOptions({ queryFn: async () => await fetchIntegrations(), queryKey: integrationsQueryKey, diff --git a/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/send-test-email-mutation.ts b/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/send-test-email-mutation.ts index 2db86fbf0..a96869eae 100644 --- a/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/send-test-email-mutation.ts +++ b/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/send-test-email-mutation.ts @@ -2,8 +2,8 @@ import type { z } from "zod"; import type { zodSendTestEmailSchema } from "@/api/modules/admin/debug/routes/send-test-email.route"; +import { CONFIG_PLUGIN } from "@/config"; import { fetcherClient } from "@/lib/fetcher-client"; -import { debugAdminModuleRef } from "@/views/admin/views/core/system/integrations/integrations-query"; export type SendTestEmailBody = z.infer; @@ -15,13 +15,13 @@ export type SendTestEmail = ( /** Sends the test email from the browser. */ export const sendTestEmailInBrowser: SendTestEmail = async body => { try { - const response = await fetcherClient(debugAdminModuleRef, { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { body }, method: "post", - module: "debug", + module: "admin/debug", options: { credentials: "include" }, path: "/send-test-email", - prefixPath: "/admin", }); if (!response.ok) return { error: await response.text() }; diff --git a/packages/vitnode/src/views/admin/views/core/system/integrations/test-ai/content.tsx b/packages/vitnode/src/views/admin/views/core/system/integrations/test-ai/content.tsx index 08605f409..08d48af44 100644 --- a/packages/vitnode/src/views/admin/views/core/system/integrations/test-ai/content.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/integrations/test-ai/content.tsx @@ -2,8 +2,6 @@ import { LoaderCircleIcon, TriangleAlertIcon } from "lucide-react"; import React from "react"; import { useTranslations } from "use-intl"; -import type { debugAdminModule } from "@/api/modules/admin/debug/debug.admin.module"; - import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; @@ -16,7 +14,7 @@ import { } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { CONFIG_PLUGIN } from "@/config"; -import { clientModule, fetcherClient } from "@/lib/fetcher-client"; +import { fetcherClient } from "@/lib/fetcher-client"; import type { TestAIModel } from "./test-ai"; @@ -55,17 +53,14 @@ export const ContentTestAI = ({ models }: { models: TestAIModel[] }) => { setError(null); try { - const res = await fetcherClient( - clientModule(CONFIG_PLUGIN.pluginId), - { - prefixPath: "/admin", - module: "debug", - path: "/test-ai", - method: "post", - args: { body: { model, prompt } }, - options: { credentials: "include", signal: controller.signal }, - }, - ); + const res = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + module: "admin/debug", + path: "/test-ai", + method: "post", + args: { body: { model, prompt } }, + options: { credentials: "include", signal: controller.signal }, + }); if (!res.ok || !res.body) { setError((await res.text()) || tError("internal_server_error")); diff --git a/packages/vitnode/src/views/admin/views/core/system/integrations/test-storage/content.tsx b/packages/vitnode/src/views/admin/views/core/system/integrations/test-storage/content.tsx index 514c967a0..4a8a5768d 100644 --- a/packages/vitnode/src/views/admin/views/core/system/integrations/test-storage/content.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/integrations/test-storage/content.tsx @@ -9,8 +9,6 @@ import React from "react"; import { toast } from "sonner"; import { useTranslations } from "use-intl"; -import type { debugAdminModule } from "@/api/modules/admin/debug/debug.admin.module"; - import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Attachment, @@ -23,7 +21,7 @@ import { AttachmentTrigger, } from "@/components/ui/attachment"; import { CONFIG_PLUGIN } from "@/config"; -import { clientModule, fetcherClient } from "@/lib/fetcher-client"; +import { fetcherClient } from "@/lib/fetcher-client"; export const ContentTestStorage = () => { const t = useTranslations("admin.system.integrations.storage.test"); @@ -36,17 +34,14 @@ export const ContentTestStorage = () => { const formData = new FormData(); formData.append("file", file); - const res = await fetcherClient( - clientModule(CONFIG_PLUGIN.pluginId), - { - prefixPath: "/admin", - module: "debug", - path: "/test-storage-upload", - method: "post", - formData, - options: { credentials: "include" }, - }, - ); + const res = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + module: "admin/debug", + path: "/test-storage-upload", + method: "post", + formData, + options: { credentials: "include" }, + }); if (!res.ok) throw new Error(await res.text()); return await res.json(); 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 b9a1de5de..931df5ca0 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,21 +1,20 @@ import { queryOptions } from "@tanstack/react-query"; import type { StaffPermissionSet } from "@/api/lib/permission-staff"; -import type { UniversalFetcher } from "@/lib/fetcher-client"; import type { UserImagePolicy } from "@/lib/user-images"; import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; import type { AdminUserRole } from "@/views/admin/views/core/users/list/users-query"; import { hasStaffPermission } from "@/api/lib/staff-permission"; -import { fetcherClient } from "@/lib/fetcher-client"; +import { CONFIG_PLUGIN } from "@/config"; import { RECORD_STALE_TIME } from "@/lib/query-freshness"; +import { fetcher } from "@/tanstack/fetcher"; import { AdminRequestError } from "@/views/admin/admin-request"; import { ADMIN_USER_PERMISSIONS } from "@/views/admin/views/core/shared/admin-permissions"; import { ADMIN_USERS_SCREEN, adminScopedQueryKey, } from "@/views/admin/views/core/shared/admin-scope"; -import { adminModuleRef } from "@/views/admin/views/core/users/list/users-query"; const MAX_USER_ID = 2_147_483_647; @@ -53,25 +52,21 @@ export interface AdminUserDetail { export type AdminUserFetcher = (id: string) => Promise; -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}`); - } +export const fetchAdminUser: AdminUserFetcher = async id => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { params: { id } }, + method: "get", + module: "admin/users", + path: "/{id}", + }); - return await response.json(); - }; + if (!response.ok) { + throw new AdminRequestError(response.status, "a user", `id=${id}`); + } -export const fetchAdminUserInBrowser: AdminUserFetcher = - adminUserFetcher(fetcherClient); + return await response.json(); +}; export const adminUserQueryKey = ({ adminUserId, @@ -83,7 +78,7 @@ export const adminUserQueryKey = ({ export const adminUserQueryOptions = ({ adminUserId, - fetchUser = fetchAdminUserInBrowser, + fetchUser = fetchAdminUser, id, }: { adminUserId: AdminIdentity; 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 23e8da326..334de949e 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,7 +1,5 @@ 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, @@ -10,11 +8,12 @@ import type { } from "@/views/admin/table/params"; import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; +import { CONFIG_PLUGIN } from "@/config"; import { fetcherClient } from "@/lib/fetcher-client"; import { RECORD_STALE_TIME } from "@/lib/query-freshness"; +import { fetcher } from "@/tanstack/fetcher"; import { AdminRequestError, - adminModuleRef as buildAdminModuleRef, describeAdminParams, } from "@/views/admin/admin-request"; import { normalizeAdminTableParams } from "@/views/admin/table/params"; @@ -24,8 +23,6 @@ import { adminScopedQueryRoot, } from "@/views/admin/views/core/shared/admin-scope"; -export const adminModuleRef = buildAdminModuleRef(); - /** The columns `listUsersAdminRoute` sorts by. Anything else is a `400`. */ export const ADMIN_USERS_ORDER_BY = ["createdAt", "name"] as const; export type AdminUsersOrderBy = (typeof ADMIN_USERS_ORDER_BY)[number]; @@ -117,30 +114,29 @@ export type AdminUsersPageFetcher = ( options?: { signal?: AbortSignal }, ) => Promise; -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), - ); - } +export const fetchAdminUsersPage: AdminUsersPageFetcher = async ( + params, + { signal } = {}, +) => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { query: params }, + method: "get", + module: "admin/users", + options: { signal }, + path: "/list", + }); - return await response.json(); - }; + if (!response.ok) { + throw new AdminRequestError( + response.status, + "the users list", + describeAdminParams(params), + ); + } -export const fetchAdminUsersPageInBrowser: AdminUsersPageFetcher = - adminUsersPageFetcher(fetcherClient); + return await response.json(); +}; export const adminUsersQueryRoot = (adminUserId: AdminIdentity) => adminScopedQueryRoot(ADMIN_USERS_SCREEN, adminUserId); @@ -155,7 +151,7 @@ export const adminUsersQueryKey = ({ export const adminUsersQueryOptions = ({ adminUserId, - fetchPage = fetchAdminUsersPageInBrowser, + fetchPage = fetchAdminUsersPage, params, }: { adminUserId: AdminIdentity; @@ -204,7 +200,8 @@ export const adminUserOptionsFrom = ( export const searchAdminUsersInBrowser: AdminUserSearchOptions = async search => { try { - const response = await fetcherClient(adminModuleRef, { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { query: { first: String(ADMIN_USER_SEARCH_LIMIT), search } }, method: "get", module: "admin/users", diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/roles-mutations.ts b/packages/vitnode/src/views/admin/views/core/users/roles/roles-mutations.ts index ec8c33bc7..18cf17bf3 100644 --- a/packages/vitnode/src/views/admin/views/core/users/roles/roles-mutations.ts +++ b/packages/vitnode/src/views/admin/views/core/users/roles/roles-mutations.ts @@ -2,12 +2,12 @@ import type { z } from "zod"; import type { zodCreateRoleAdminSchema } from "@/api/modules/admin/roles/routes/create.route"; +import { CONFIG_PLUGIN } from "@/config"; import { fetcherClient } from "@/lib/fetcher-client"; import { type AdminMutationResult, runAdminApiMutation, } from "@/views/admin/views/core/shared/admin-mutation"; -import { adminModuleRef } from "@/views/admin/views/core/users/list/users-query"; export type AdminRoleInput = z.infer; @@ -18,7 +18,8 @@ export const createAdminRole = async ( expected: 201, parse: async response => (await response.json()) as { id: number }, request: async () => - await fetcherClient(adminModuleRef, { + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { body }, method: "post", module: "admin/roles", @@ -35,7 +36,8 @@ export const updateAdminRole = async ( expected: 200, parse: () => true as const, request: async () => - await fetcherClient(adminModuleRef, { + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { body, params: { id: String(id) } }, method: "patch", module: "admin/roles", @@ -64,7 +66,8 @@ export const deleteAdminRole = async (args: { expected: 200, parse: () => true as const, request: async () => - await fetcherClient(adminModuleRef, { + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: deleteAdminRoleArgs(args), method: "delete", module: "admin/roles", 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 759c0948c..4f15d889a 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,6 +1,5 @@ import { queryOptions } from "@tanstack/react-query"; -import type { UniversalFetcher } from "@/lib/fetcher-client"; import type { AdminTableContract, AdminTablePage, @@ -9,8 +8,10 @@ import type { } from "@/views/admin/table/params"; import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; +import { CONFIG_PLUGIN } from "@/config"; import { fetcherClient } from "@/lib/fetcher-client"; import { RECORD_STALE_TIME } from "@/lib/query-freshness"; +import { fetcher } from "@/tanstack/fetcher"; import { AdminRequestError, describeAdminParams, @@ -21,7 +22,6 @@ import { adminScopedQueryKey, adminScopedQueryRoot, } from "@/views/admin/views/core/shared/admin-scope"; -import { adminModuleRef } from "@/views/admin/views/core/users/list/users-query"; /** The columns `listRolesAdminRoute` sorts by. */ export const ADMIN_ROLES_ORDER_BY = ["id", "createdAt", "updatedAt"] as const; @@ -79,29 +79,25 @@ export type AdminRolesPageFetcher = ( params: AdminRolesParams, ) => Promise; -export const adminRolesPageFetcher = - (transport: UniversalFetcher): AdminRolesPageFetcher => - async params => { - const response = await transport(adminModuleRef, { - args: { query: params }, - method: "get", - module: "admin/roles", - path: "/list", - }); - - if (!response.ok) { - throw new AdminRequestError( - response.status, - "the roles list", - describeAdminParams(params), - ); - } +export const fetchAdminRolesPage: AdminRolesPageFetcher = async params => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { query: params }, + method: "get", + module: "admin/roles", + path: "/list", + }); - return await response.json(); - }; + if (!response.ok) { + throw new AdminRequestError( + response.status, + "the roles list", + describeAdminParams(params), + ); + } -export const fetchAdminRolesPageInBrowser: AdminRolesPageFetcher = - adminRolesPageFetcher(fetcherClient); + return await response.json(); +}; export const adminRolesQueryRoot = (adminUserId: AdminIdentity) => adminScopedQueryRoot(ADMIN_ROLES_SCREEN, adminUserId); @@ -116,7 +112,7 @@ export const adminRolesQueryKey = ({ export const adminRolesQueryOptions = ({ adminUserId, - fetchPage = fetchAdminRolesPageInBrowser, + fetchPage = fetchAdminRolesPage, params, }: { adminUserId: AdminIdentity; @@ -154,7 +150,8 @@ export const adminRoleOptionsFrom = ( export const searchAdminRolesInBrowser: AdminRoleSearch = async search => { try { - const response = await fetcherClient(adminModuleRef, { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { query: { first: String(ADMIN_ROLE_SEARCH_LIMIT), search } }, method: "get", module: "admin/roles", diff --git a/packages/vitnode/src/views/admin/views/core/users/users-mutations.ts b/packages/vitnode/src/views/admin/views/core/users/users-mutations.ts index 283df6383..64226d046 100644 --- a/packages/vitnode/src/views/admin/views/core/users/users-mutations.ts +++ b/packages/vitnode/src/views/admin/views/core/users/users-mutations.ts @@ -1,5 +1,6 @@ import type { UserImageKind } from "@/lib/user-images"; +import { CONFIG_PLUGIN } from "@/config"; import { fetcherClient } from "@/lib/fetcher-client"; import { readApiErrorMessage } from "@/lib/read-api-error"; import { @@ -7,8 +8,6 @@ import { runAdminApiMutation, } from "@/views/admin/views/core/shared/admin-mutation"; -import { adminModuleRef } from "./list/users-query"; - export interface AdminUserUpdateInput { email?: string; name?: string; @@ -32,7 +31,8 @@ export const updateAdminUser = async ( expected: 200, parse: async response => (await response.json()) as AdminUserUpdated, request: async () => - await fetcherClient(adminModuleRef, { + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { body, params: { id: String(id) } }, method: "patch", module: "admin/users", @@ -60,7 +60,8 @@ export const verifyAdminUserEmail = async ( expected: 200, parse: async response => (await response.json()) as AdminUserVerified, request: async () => - await fetcherClient(adminModuleRef, { + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { params: { id: String(id) } }, method: "post", module: "admin/users", @@ -88,7 +89,8 @@ export const createAdminUser = async ( expected: 201, parse: async response => (await response.json()) as AdminUserCreated, request: async () => - await fetcherClient(adminModuleRef, { + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { body }, method: "post", module: "admin/users", @@ -134,7 +136,8 @@ export const uploadAdminUserImage = async ( const formData = new FormData(); formData.append("file", file); - const response = await fetcherClient(adminModuleRef, { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { params: { id: String(id), kind } }, formData, method: "post", @@ -157,7 +160,8 @@ export const removeAdminUserImage = async ( id: number, kind: UserImageKind, ): Promise => { - const response = await fetcherClient(adminModuleRef, { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { params: { id: String(id), kind } }, method: "delete", module: "admin/users", diff --git a/packages/vitnode/src/views/auth/settings/devices/devices-query.test.ts b/packages/vitnode/src/views/auth/settings/devices/devices-query.test.ts index 5fa1873a7..a52315d77 100644 --- a/packages/vitnode/src/views/auth/settings/devices/devices-query.test.ts +++ b/packages/vitnode/src/views/auth/settings/devices/devices-query.test.ts @@ -26,12 +26,7 @@ describe("one list per visitor, one cache entry each", () => { // in the same entry or the loader fills one while the component reads the // other. expect(hashKey(devicesQueryOptions({ userId: 10 }).queryKey)).toBe( - hashKey( - devicesQueryOptions({ - fetchDevices: async () => Promise.resolve({ devices: [] }), - userId: 10, - }).queryKey, - ), + hashKey(devicesQueryOptions({ userId: 10 }).queryKey), ); }); 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 191fc581a..ec045715e 100644 --- a/packages/vitnode/src/views/auth/settings/devices/devices-query.ts +++ b/packages/vitnode/src/views/auth/settings/devices/devices-query.ts @@ -1,15 +1,8 @@ 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"; import { RECORD_STALE_TIME } from "@/lib/query-freshness"; - -export const usersModuleRef = clientModule( - CONFIG_PLUGIN.pluginId, -); +import { fetcher } from "@/tanstack/fetcher"; /** Which icon a row gets, and the only three values the API will send. */ export const DEVICE_TYPES = ["desktop", "tablet", "mobile"] as const; @@ -53,35 +46,25 @@ export const isDevicesRequestError = ( ): error is DevicesRequestError => error instanceof Error && error.name === DEVICES_REQUEST_ERROR; -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); +export const fetchDevices: DevicesFetcher = async () => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "users", + path: "/devices", + }); - return await response.json(); - }; + if (!response.ok) throw new DevicesRequestError(response.status); -export const fetchDevicesInBrowser: DevicesFetcher = - devicesFetcher(fetcherClient); + return await response.json(); +}; export const DEVICES_IDENTITY_ROOT = ["devices", "user"] as const; export const devicesQueryKey = (userId: number) => [...DEVICES_IDENTITY_ROOT, userId] as const; -export const devicesQueryOptions = ({ - fetchDevices = fetchDevicesInBrowser, - userId, -}: { - fetchDevices?: DevicesFetcher; - userId: number; -}) => +export const devicesQueryOptions = ({ userId }: { userId: number }) => queryOptions({ // `userId` is deliberately absent from the request: the owner comes from // the session cookie, on the server, on every call. diff --git a/packages/vitnode/src/views/auth/settings/devices/devices-revoke.ts b/packages/vitnode/src/views/auth/settings/devices/devices-revoke.ts index 969099992..f3ee4dff0 100644 --- a/packages/vitnode/src/views/auth/settings/devices/devices-revoke.ts +++ b/packages/vitnode/src/views/auth/settings/devices/devices-revoke.ts @@ -1,9 +1,8 @@ +import { CONFIG_PLUGIN } from "@/config"; import { fetcherClient } from "@/lib/fetcher-client"; import type { Device } from "./devices-query"; -import { usersModuleRef } from "./devices-query"; - /** Signing out one device. The id is the row's own `publicId`. */ export interface RevokeDeviceArgs { publicId: string; @@ -37,7 +36,8 @@ export const revokeDeviceInBrowser: RevokeDevice = async ({ publicId }) => { if (!isDevicePublicId(publicId)) return { error: { status: 400 } }; try { - const response = await fetcherClient(usersModuleRef, { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { params: { publicId } }, method: "delete", module: "users", diff --git a/packages/vitnode/src/views/auth/settings/overview/personal-update.ts b/packages/vitnode/src/views/auth/settings/overview/personal-update.ts index 872313ae0..be7c449f1 100644 --- a/packages/vitnode/src/views/auth/settings/overview/personal-update.ts +++ b/packages/vitnode/src/views/auth/settings/overview/personal-update.ts @@ -1,17 +1,14 @@ import { queryOptions } from "@tanstack/react-query"; -import type { usersModule } from "@/api/modules/users/users.module"; -import type { UniversalFetcher } from "@/lib/fetcher-client"; import type { PersonalInformationFields, UserPersonalInformation, } from "@/lib/user-personal-information"; import { CONFIG_PLUGIN } from "@/config"; -import { clientModule, fetcherClient } from "@/lib/fetcher-client"; +import { fetcherClient } from "@/lib/fetcher-client"; import { OPERATIONAL_STALE_TIME } from "@/lib/query-freshness"; - -const usersModuleRef = clientModule(CONFIG_PLUGIN.pluginId); +import { fetcher } from "@/tanstack/fetcher"; export interface PersonalInfoPolicy { canEdit: boolean; @@ -20,30 +17,26 @@ export interface PersonalInfoPolicy { export type PersonalInfoPolicyFetcher = () => Promise; -export const personalInfoPolicyFetcher = - (transport: UniversalFetcher): PersonalInfoPolicyFetcher => - async () => { - const response = await transport(usersModuleRef, { - method: "get", - module: "users", - path: "/me/policy", - }); - - if (!response.ok) { - throw new Error(`The account policy route answered ${response.status}.`); - } +export const fetchPersonalInfoPolicy: PersonalInfoPolicyFetcher = async () => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "users", + path: "/me/policy", + }); - return await response.json(); - }; + if (!response.ok) { + throw new Error(`The account policy route answered ${response.status}.`); + } -export const fetchPersonalInfoPolicyInBrowser: PersonalInfoPolicyFetcher = - personalInfoPolicyFetcher(fetcherClient); + return await response.json(); +}; export const personalInfoPolicyQueryKey = () => ["vitnode", "users", "me", "policy"] as const; export const personalInfoPolicyQueryOptions = ({ - fetchPolicy = fetchPersonalInfoPolicyInBrowser, + fetchPolicy = fetchPersonalInfoPolicy, }: { fetchPolicy?: PersonalInfoPolicyFetcher } = {}) => queryOptions({ queryFn: async () => await fetchPolicy(), @@ -66,7 +59,8 @@ export type UpdatePersonalInformation = ( export const updatePersonalInformationInBrowser: UpdatePersonalInformation = async input => { try { - const response = await fetcherClient(usersModuleRef, { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { body: input }, method: "patch", module: "users", diff --git a/packages/vitnode/src/views/files/actions/file-row-actions.tsx b/packages/vitnode/src/views/files/actions/file-row-actions.tsx index bda58fb93..666dc8394 100644 --- a/packages/vitnode/src/views/files/actions/file-row-actions.tsx +++ b/packages/vitnode/src/views/files/actions/file-row-actions.tsx @@ -3,14 +3,13 @@ import React from "react"; import { toast } from "sonner"; import { useTranslations } from "use-intl"; -import type { userFilesModule } from "@/api/modules/users/files/files.module"; import type { FileInUse } from "@/lib/files/in-use"; import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; import { Button } from "@/components/ui/button"; import { TooltipWithContent } from "@/components/ui/tooltip"; import { CONFIG_PLUGIN } from "@/config"; -import { clientModule, fetcherClient } from "@/lib/fetcher-client"; +import { fetcherClient } from "@/lib/fetcher-client"; import type { DeleteMyFile } from "../my-files-delete"; @@ -32,17 +31,14 @@ export const MyFileRowActions = ({ const handleDownload = async () => { setIsDownloading(true); try { - const res = await fetcherClient( - clientModule(CONFIG_PLUGIN.pluginId), - { - prefixPath: "/users", - module: "files", - path: "/{id}/download", - method: "get", - args: { params: { id: String(id) } }, - options: { credentials: "include" }, - }, - ); + const res = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + module: "users/files", + path: "/{id}/download", + method: "get", + args: { params: { id: String(id) } }, + options: { credentials: "include" }, + }); if (!res.ok) throw new Error(await res.text()); const blob = await res.blob(); diff --git a/packages/vitnode/src/views/files/my-files-delete.ts b/packages/vitnode/src/views/files/my-files-delete.ts index a2ac4c34c..bbb947316 100644 --- a/packages/vitnode/src/views/files/my-files-delete.ts +++ b/packages/vitnode/src/views/files/my-files-delete.ts @@ -1,15 +1,13 @@ import type { BulkDeleteFilesResult } from "@/lib/files/bulk-delete"; import type { DeleteFileResult } from "@/lib/files/in-use"; +import { CONFIG_PLUGIN } from "@/config"; import { fetcherClient } from "@/lib/fetcher-client"; import { runBulkFileDelete, shouldRefreshAfterBulkDelete, } from "@/lib/files/bulk-delete"; import { readFileInUse } from "@/lib/files/in-use"; -import { FILES_PREFIX_PATH } from "@/views/files/my-files-query"; - -import { userFilesModuleRef } from "./my-files-query"; export interface DeleteMyFileArgs { force?: boolean; @@ -34,16 +32,16 @@ export const deleteMyFileInBrowser: DeleteMyFile = async ({ id, }) => { try { - const response = await fetcherClient(userFilesModuleRef, { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { params: { id: String(id) }, query: force ? { force: "true" } : {}, }, method: "delete", - module: "files", + module: "users/files", options: { credentials: "include" }, path: "/{id}", - prefixPath: FILES_PREFIX_PATH, }); if (response.status !== 200) { diff --git a/packages/vitnode/src/views/files/my-files-query.ts b/packages/vitnode/src/views/files/my-files-query.ts index 77abbcf00..a3fd2a02b 100644 --- a/packages/vitnode/src/views/files/my-files-query.ts +++ b/packages/vitnode/src/views/files/my-files-query.ts @@ -1,20 +1,10 @@ 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"; -import { clientModule, fetcherClient } from "@/lib/fetcher-client"; import { RECORD_STALE_TIME } from "@/lib/query-freshness"; import { readFirstValue, readPageSize } from "@/lib/table-params"; - -export const userFilesModuleRef = clientModule( - CONFIG_PLUGIN.pluginId, -); - -/** The module is mounted under `/users`, not at the plugin root. */ -export const FILES_PREFIX_PATH = "/users"; +import { fetcher } from "@/tanstack/fetcher"; /** The columns the list route will sort by. Anything else is ignored. */ export const MY_FILES_ORDER_BY = ["createdAt", "name", "size"] as const; @@ -152,25 +142,23 @@ export const isMyFilesRequestError = ( ): error is MyFilesRequestError => error instanceof Error && error.name === MY_FILES_REQUEST_ERROR; -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(); - }; +export const fetchMyFilesPage: MyFilesPageFetcher = async ( + params, + { signal } = {}, +) => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { query: params }, + method: "get", + module: "users/files", + options: { signal }, + path: "/", + }); -export const fetchMyFilesPageInBrowser: MyFilesPageFetcher = - myFilesPageFetcher(fetcherClient); + if (!response.ok) throw new MyFilesRequestError(response.status, params); + + return await response.json(); +}; export const MY_FILES_IDENTITY_ROOT = ["files", "user"] as const; @@ -186,7 +174,7 @@ export const myFilesQueryKey = ({ }) => [...myFilesQueryRoot(userId), params] as const; export const myFilesQueryOptions = ({ - fetchPage = fetchMyFilesPageInBrowser, + fetchPage = fetchMyFilesPage, params, userId, }: { diff --git a/packages/vitnode/src/views/profile/images/self-images-query.ts b/packages/vitnode/src/views/profile/images/self-images-query.ts index 79e943d3d..e921016fb 100644 --- a/packages/vitnode/src/views/profile/images/self-images-query.ts +++ b/packages/vitnode/src/views/profile/images/self-images-query.ts @@ -1,44 +1,37 @@ import { queryOptions } from "@tanstack/react-query"; -import type { UniversalFetcher } from "@/lib/fetcher-client"; import type { UserImageKind, UserImagePolicy } from "@/lib/user-images"; -import { userImagesModule } from "@/api/modules.client"; +import { CONFIG_PLUGIN } from "@/config"; import { fetcherClient } from "@/lib/fetcher-client"; import { OPERATIONAL_STALE_TIME } from "@/lib/query-freshness"; import { readApiErrorMessage } from "@/lib/read-api-error"; +import { fetcher } from "@/tanstack/fetcher"; import { PROFILE_QUERY_ROOT } from "../profile-query"; -const IMAGES_PREFIX_PATH = "/users"; - export type UserImagePolicyFetcher = () => Promise; -export const userImagePolicyFetcher = - (transport: UniversalFetcher): UserImagePolicyFetcher => - async () => { - const response = await transport(userImagesModule, { - method: "get", - module: "images", - path: "/policy", - prefixPath: IMAGES_PREFIX_PATH, - }); - - if (!response.ok) { - throw new Error(`The image policy route answered ${response.status}.`); - } +export const fetchUserImagePolicy: UserImagePolicyFetcher = async () => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "users/images", + path: "/policy", + }); - return await response.json(); - }; + if (!response.ok) { + throw new Error(`The image policy route answered ${response.status}.`); + } -export const fetchUserImagePolicyInBrowser: UserImagePolicyFetcher = - userImagePolicyFetcher(fetcherClient); + return await response.json(); +}; export const userImagePolicyQueryKey = () => [...PROFILE_QUERY_ROOT, "images", "policy"] as const; export const userImagePolicyQueryOptions = ({ - fetchPolicy = fetchUserImagePolicyInBrowser, + fetchPolicy = fetchUserImagePolicy, }: { fetchPolicy?: UserImagePolicyFetcher } = {}) => queryOptions({ queryFn: async () => await fetchPolicy(), @@ -73,13 +66,13 @@ export const uploadOwnUserImage = async ( const formData = new FormData(); formData.append("file", file); - const response = await fetcherClient(userImagesModule, { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { params: { kind } }, formData, method: "post", - module: "images", + module: "users/images", path: "/{kind}", - prefixPath: IMAGES_PREFIX_PATH, }); if (!response.ok) { @@ -95,12 +88,12 @@ export const uploadOwnUserImage = async ( export const removeOwnUserImage = async ( kind: UserImageKind, ): Promise => { - const response = await fetcherClient(userImagesModule, { + const response = await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, args: { params: { kind } }, method: "delete", - module: "images", + module: "users/images", path: "/{kind}", - prefixPath: IMAGES_PREFIX_PATH, }); if (!response.ok) { diff --git a/packages/vitnode/src/views/profile/profile-query.ts b/packages/vitnode/src/views/profile/profile-query.ts index 72426746a..3756c1daa 100644 --- a/packages/vitnode/src/views/profile/profile-query.ts +++ b/packages/vitnode/src/views/profile/profile-query.ts @@ -1,11 +1,10 @@ import { queryOptions } from "@tanstack/react-query"; import type { RoleNameEntry } from "@/components/role-name"; -import type { UniversalFetcher } from "@/lib/fetcher-client"; -import { usersModule } from "@/api/modules.client"; -import { fetcherClient } from "@/lib/fetcher-client"; +import { CONFIG_PLUGIN } from "@/config"; import { RECORD_STALE_TIME } from "@/lib/query-freshness"; +import { fetcher } from "@/tanstack/fetcher"; export const MAX_NAME_CODE_LENGTH = 255; @@ -58,25 +57,21 @@ export const isProfileNotFound = (error: unknown): boolean => error.name === PROFILE_REQUEST_ERROR && (error as ProfileRequestError).status === 404; -export const userProfileFetcher = - (transport: UniversalFetcher): UserProfileFetcher => - async nameCode => { - const response = await transport(usersModule, { - args: { params: { nameCode: encodeURIComponent(nameCode) } }, - method: "get", - module: "users", - path: "/profile/{nameCode}", - }); - - if (!response.ok) { - throw new ProfileRequestError(response.status, nameCode); - } +export const fetchUserProfile: UserProfileFetcher = async nameCode => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { params: { nameCode: encodeURIComponent(nameCode) } }, + method: "get", + module: "users", + path: "/profile/{nameCode}", + }); - return await response.json(); - }; + if (!response.ok) { + throw new ProfileRequestError(response.status, nameCode); + } -export const fetchUserProfileInBrowser: UserProfileFetcher = - userProfileFetcher(fetcherClient); + return await response.json(); +}; export const PROFILE_QUERY_ROOT = ["vitnode", "profile"] as const; @@ -84,7 +79,7 @@ export const userProfileQueryKey = (nameCode: string) => [...PROFILE_QUERY_ROOT, nameCode] as const; export const userProfileQueryOptions = ({ - fetchProfile = fetchUserProfileInBrowser, + fetchProfile = fetchUserProfile, nameCode, }: { fetchProfile?: UserProfileFetcher; diff --git a/packages/vitnode/src/views/search/search-feed-query.ts b/packages/vitnode/src/views/search/search-feed-query.ts index 76ea9b0f8..bf7083623 100644 --- a/packages/vitnode/src/views/search/search-feed-query.ts +++ b/packages/vitnode/src/views/search/search-feed-query.ts @@ -1,11 +1,8 @@ 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"; import { RECORD_STALE_TIME } from "@/lib/query-freshness"; +import { fetcher } from "@/tanstack/fetcher"; import type { SearchFeedPage } from "./types"; @@ -26,10 +23,6 @@ export interface SearchFeedParams { types?: string; } -export const searchModuleRef = clientModule( - CONFIG_PLUGIN.pluginId, -); - export interface SearchFeedPageArgs { cursor: SearchFeedCursor; @@ -128,21 +121,23 @@ export type SearchFeedPageFetcher = ( * 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(); - }; +export const fetchSearchFeedPage: SearchFeedPageFetcher = async ( + args, + { signal } = {}, +) => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { query: searchFeedQuery(args) }, + method: "get", + module: "search", + options: { signal }, + path: "/", + }); + + assertSearchFeedResponse(response, args); + + return await response.json(); +}; /** * One page, fetched from the browser. @@ -151,9 +146,6 @@ export const 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 = - searchFeedPageFetcher(fetcherClient); - /** * The feed, as the one query definition every caller shares. * @@ -189,7 +181,7 @@ export const fetchSearchFeedPageInBrowser: SearchFeedPageFetcher = * off), so a hydrated feed is not refetched behind the reader. */ export const searchFeedQueryOptions = ({ - fetchPage = fetchSearchFeedPageInBrowser, + fetchPage = fetchSearchFeedPage, initialData, locale, params, diff --git a/plugins/blog/global.d.ts b/plugins/blog/global.d.ts index c74dd53ee..4786c0398 100644 --- a/plugins/blog/global.d.ts +++ b/plugins/blog/global.d.ts @@ -3,8 +3,18 @@ import core from "@vitnode/core/locales/en.json" with { type: "json" }; import plugin from "./src/locales/en.json" with { type: "json" }; +import type { blogApiPlugin } from "./src/config.api"; + declare module "use-intl" { interface AppConfig { Messages: typeof plugin & typeof core; } } + +declare module "@vitnode/core/lib/fetcher/registry" { + interface ApiPluginRegistry { + "@vitnode/blog": typeof blogApiPlugin; + } +} + +export type { ApiPluginRegistry } from "@vitnode/core/lib/fetcher/registry"; diff --git a/plugins/blog/src/config.test-d.ts b/plugins/blog/src/config.test-d.ts deleted file mode 100644 index 501a35af8..000000000 --- a/plugins/blog/src/config.test-d.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { ContentFrontendPluginSource } from "@vitnode/core/lib/plugin"; - -import { contentTypeAdmin } from "@vitnode/core/lib/plugin"; -import { describe, expectTypeOf, it } from "vitest"; - -import { blogCategoryContentType } from "@/content/category"; -import { blogPostContentType } from "@/content/post"; - -import { adminContent } from "./admin/content"; - -describe("blog content admin registration", () => { - it("accepts overrides for fields the content type has", () => { - contentTypeAdmin({ - definition: blogCategoryContentType, - fields: { color: { component: () => null } }, - columns: { color: { cell: () => null } }, - }); - - contentTypeAdmin({ - definition: blogPostContentType, - fields: { content: { component: () => null } }, - forms: { layout: () => null }, - }); - }); - - it("refuses an override for a field that does not exist", () => { - contentTypeAdmin({ - definition: blogCategoryContentType, - // @ts-expect-error - the category has no `colour` - fields: { colour: { component: () => null } }, - }); - - contentTypeAdmin({ - definition: blogPostContentType, - // @ts-expect-error - the article's body is `content`, not `body` - fields: { body: { component: () => null } }, - }); - }); - - it("exports a content source the generated registry can consume", () => { - expectTypeOf(adminContent).toExtend(); - expectTypeOf(adminContent.pluginId).toEqualTypeOf<"@vitnode/blog">(); - expectTypeOf(adminContent.contentTypes).not.toBeUndefined(); - }); - - it("keeps the presentation modes literal", () => { - expectTypeOf(blogPostContentType.admin.create.mode).toEqualTypeOf< - "dialog" | "page" - >(); - expectTypeOf(blogCategoryContentType.admin.edit.mode).toEqualTypeOf< - "dialog" | "page" - >(); - }); -}); diff --git a/plugins/example/global.d.ts b/plugins/example/global.d.ts index c74dd53ee..92b0e5a14 100644 --- a/plugins/example/global.d.ts +++ b/plugins/example/global.d.ts @@ -3,8 +3,18 @@ import core from "@vitnode/core/locales/en.json" with { type: "json" }; import plugin from "./src/locales/en.json" with { type: "json" }; +import type { exampleApiPlugin } from "./src/config.api"; + declare module "use-intl" { interface AppConfig { Messages: typeof plugin & typeof core; } } + +declare module "@vitnode/core/lib/fetcher/registry" { + interface ApiPluginRegistry { + "@vitnode/example": typeof exampleApiPlugin; + } +} + +export type { ApiPluginRegistry } from "@vitnode/core/lib/fetcher/registry"; From 259210f2ac0744f03cbaa3bb849f6254343e7286 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Mon, 14 Sep 2026 20:47:47 +0200 Subject: [PATCH 2/2] =?UTF-8?q?refactor(fetcher):=20=E2=9C=A8=20restructur?= =?UTF-8?q?e=20API=20plugin=20types=20and=20enhance=20type=20safety=20acro?= =?UTF-8?q?ss=20fetcher=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/copilot-instructions.md | 82 ---- .vscode/settings.json | 9 +- apps/api/.next/trace | 1 - apps/api/.next/trace-build | 1 - apps/web/.vscode/settings.json | 11 - apps/web/content/docs/dev/ai/index.mdx | 2 +- apps/web/content/docs/dev/cache.mdx | 6 +- apps/web/content/docs/dev/data-loading.mdx | 159 -------- apps/web/content/docs/dev/debugging/index.mdx | 2 +- apps/web/content/docs/dev/fetcher.mdx | 220 ++++++++--- apps/web/content/docs/dev/meta.json | 1 - apps/web/content/docs/dev/performance.mdx | 6 +- .../content/docs/dev/plugins/api/modules.mdx | 49 ++- .../content/docs/dev/routing/breadcrumbs.mdx | 4 +- apps/web/content/docs/dev/routing/loading.mdx | 4 +- .../content/docs/dev/routing/not-found.mdx | 4 +- apps/web/content/docs/dev/routing/routes.mdx | 4 +- .../web/content/docs/dev/server-functions.mdx | 18 +- apps/web/src/api-registry.gen.ts | 25 +- .../root/npmignore.template | 1 + .../src/plugin/create/route-templates.test.ts | 62 +-- .../src/plugin/create/route-templates.ts | 53 ++- .../plugin/create/scaffold-typecheck.test.ts | 177 --------- packages/vitnode/src/api/lib/plugin.ts | 3 + packages/vitnode/src/api/plugin.ts | 4 + .../framework/api-registry/generate.test.ts | 21 +- .../src/framework/api-registry/generate.ts | 26 +- .../src/framework/api-registry/index.ts | 1 + packages/vitnode/src/lib/fetcher-client.ts | 44 ++- packages/vitnode/src/lib/fetcher/contract.ts | 160 ++++++++ packages/vitnode/src/lib/fetcher/core.ts | 27 +- packages/vitnode/src/lib/fetcher/registry.ts | 19 +- packages/vitnode/src/lib/fetcher/types.ts | 368 ++++++++++-------- .../src/tanstack/fetcher/fetcher.test-d.ts | 59 ++- .../src/tanstack/fetcher/fetcher.test.ts | 13 +- .../vitnode/src/tanstack/fetcher/index.ts | 7 +- .../vitnode/src/tanstack/fetcher/server.ts | 37 +- plugins/blog/global.d.ts | 10 - plugins/blog/package.json | 3 +- plugins/blog/src/config.api.test-d.ts | 114 ++++++ plugins/blog/src/config.api.ts | 6 + plugins/example/global.d.ts | 10 - plugins/example/src/config.api.ts | 6 + 43 files changed, 1008 insertions(+), 831 deletions(-) delete mode 100644 .github/copilot-instructions.md delete mode 100644 apps/api/.next/trace delete mode 100644 apps/api/.next/trace-build delete mode 100644 apps/web/.vscode/settings.json delete mode 100644 apps/web/content/docs/dev/data-loading.mdx delete mode 100644 packages/create-vitnode-app/src/plugin/create/scaffold-typecheck.test.ts create mode 100644 packages/vitnode/src/lib/fetcher/contract.ts create mode 100644 plugins/blog/src/config.api.test-d.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index c3779db2c..000000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,82 +0,0 @@ -# VitNode AI Coding Agent Guidelines (Extended) - -The repository is a monorepo for the VitNode framework, which includes a backend API, a web application that also serves the documentation, and shared packages. The codebase uses modern web technologies and follows specific conventions for development based on TanStack Start and Hono.js 4. - -- Do not nest ternary operators, - -## Architecture & Key Patterns - -- **Monorepo Structure:** - - `apps/` contains main apps (`web` for the site, AdminCP, mounted API and - documentation; `api` for deploying the backend on its own, and it owns - `migrations/`) - - `packages/` holds shared code, core framework, ESLint and Prettier configs, and CLI tools - - `plugins/` for extendable features -- **Frontend:** - - TanStack Start on Vite, file-based routes under `apps/web/src/routes/` - - Navigation: use `@vitnode/core/tanstack/layout`'s `RouterLink`, or TanStack - Router's own `Link` / `useNavigate`. - - Forms: Use `@tanstack/react-form@1`, `createServerFn` for mutations - - UI: Shadcn UI, Tailwind CSS 4, dark/light mode with system detection - - i18n: Use `use-intl`, `t('key')` for translations, `createTranslator` - (server), `useTranslations` (client) - - Accessibility: WCAG 2.1 AA, semantic HTML, ARIA, keyboard/screen reader support -- **Backend:** - - Hono.js 4, OpenAPI via `@hono/zod-openapi`, Zod 4 for validation - - Database: PostgreSQL via Drizzle ORM, access via `c.get('database')` - - API: RESTful, versioned, rate-limited, secure session management - - Error handling: Use Hono's error middleware, log via `c.get('log')` - - Plugins: Register via `VitNodeAPI` config, routes auto-mounted by pluginId -- **Docs:** - - Written in `.mdx` using Fumadocs, main entry: `apps/web/content/docs/dev/index.mdx` - - Use `// [!code ++]` to highlight code, `// [!code --]` to hide - - No h1 tags, no emoji in headings - -## Developer Workflow - -- **Package Manager:** Use `pnpm` for all installs/scripts -- **Scripts:** - - `pnpm dev` (dev server), `pnpm build`, `pnpm lint`, `pnpm db:migrate`, `pnpm docker:dev` -- **CLI:** - - Create apps/plugins via `pnpm create vitnode-app@canary` (see `packages/create-vitnode-app`) - - CLI prompts for package manager, app mode, ESLint, Prettier, Docker, install (see `questions.ts`) -- **Linting/Formatting:** - - Use configs from `packages/config/` - - File names: snake_case, ESModule only - - TypeScript 5 strict mode -- **Testing:** - - Use Vitest (see `vitest.config.ts`) -- **Config:** - - Centralized in `vitnode.config.ts` and `api/config.ts` - - Extend via plugins in config arrays - -## Integration & Conventions - -- **External:** - - TanStack Start, TanStack Router, TanStack Query, TanStack Form, Hono.js, Drizzle ORM, Zod, Shadcn UI, Tailwind, use-intl -- **Internal:** - - Navigation, config, API, middleware, plugin system -- **Security:** - - XSS protection, content security policy, secure cookies - -## Examples - -- See `apps/api/src/index.ts` for backend API setup -- See `packages/vitnode/src/api/config.ts` for API registration and middleware -- See `packages/vitnode/src/tanstack/layout/router-link.tsx` for the navigation API -- See `apps/web/source.config.ts` and `apps/web/src/docs/` for docs site config - ---- - -For unclear or missing patterns, ask for clarification or request more examples from maintainers. - -## New Code - -If you add new code or change existing code, always verify that -everything still works by running _each_ of the following checks: - -1. `npm run lint` to run the linter. -2. `npm run lint:fix` to fix any linting issues. -3. `npm run test` to run the tests. - -Complete the task only after all checks pass. diff --git a/.vscode/settings.json b/.vscode/settings.json index bc2ce3ddd..6d8ccfeb8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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 } } diff --git a/apps/api/.next/trace b/apps/api/.next/trace deleted file mode 100644 index a9f03560e..000000000 --- a/apps/api/.next/trace +++ /dev/null @@ -1 +0,0 @@ -[{"name":"generate-buildid","duration":129,"timestamp":852974614045,"id":4,"parentId":1,"tags":{},"startTime":1787921630328,"traceId":"a265ee6242f51c14"},{"name":"load-custom-routes","duration":203,"timestamp":852974614228,"id":5,"parentId":1,"tags":{},"startTime":1787921630328,"traceId":"a265ee6242f51c14"},{"name":"create-dist-dir","duration":168,"timestamp":852974614443,"id":6,"parentId":1,"tags":{},"startTime":1787921630329,"traceId":"a265ee6242f51c14"},{"name":"clean","duration":173,"timestamp":852974615539,"id":7,"parentId":1,"tags":{},"startTime":1787921630330,"traceId":"a265ee6242f51c14"},{"name":"next-build","duration":26810,"timestamp":852974588983,"id":1,"tags":{"buildMode":"default","version":"16.3.1","bundler":"turbopack","failed":true},"startTime":1787921630303,"traceId":"a265ee6242f51c14"}] diff --git a/apps/api/.next/trace-build b/apps/api/.next/trace-build deleted file mode 100644 index 176d9fb61..000000000 --- a/apps/api/.next/trace-build +++ /dev/null @@ -1 +0,0 @@ -[{"name":"next-build","duration":26810,"timestamp":852974588983,"id":1,"tags":{"buildMode":"default","version":"16.3.1","bundler":"turbopack","failed":true},"startTime":1787921630303,"traceId":"a265ee6242f51c14"}] diff --git a/apps/web/.vscode/settings.json b/apps/web/.vscode/settings.json deleted file mode 100644 index 00b5278e5..000000000 --- a/apps/web/.vscode/settings.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "files.watcherExclude": { - "**/routeTree.gen.ts": true - }, - "search.exclude": { - "**/routeTree.gen.ts": true - }, - "files.readonlyInclude": { - "**/routeTree.gen.ts": true - } -} diff --git a/apps/web/content/docs/dev/ai/index.mdx b/apps/web/content/docs/dev/ai/index.mdx index 88743a2d6..b9ff029d2 100644 --- a/apps/web/content/docs/dev/ai/index.mdx +++ b/apps/web/content/docs/dev/ai/index.mdx @@ -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' diff --git a/apps/web/content/docs/dev/cache.mdx b/apps/web/content/docs/dev/cache.mdx index 72fc7dfc9..1277bc0b8 100644 --- a/apps/web/content/docs/dev/cache.mdx +++ b/apps/web/content/docs/dev/cache.mdx @@ -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 @@ -174,8 +174,8 @@ should reach the database. { - return await fetchAnnouncements(context.locale) - }, -}) - -const AnnouncementsPage = ({ - loaderData, -}: PluginRoutePageProps) => ( -

- {loaderData.map((item) => ( -

- {item.title} -

- ))} -
-) - -export default AnnouncementsPage -``` - -## Invalidating After Mutations - -After creating, editing, or deleting a record, invalidate the query key so TanStack Query refetches fresh data: - -```tsx -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { toast } from 'sonner' -import { announcementsQueryKey } from './query' - -const queryClient = useQueryClient() - -const mutation = useMutation({ - mutationFn: createAnnouncement, - onSuccess: async () => { - // [!code ++:2] - await queryClient.invalidateQueries({ queryKey: announcementsQueryKey() }) - toast.success('Announcement published!') - }, -}) -``` - ---- - -## Built-in Queries - -Core ships three hooks for the state every page tends to need. Each one wraps a -query the framework already warms, so calling it is a cache read rather than a -new request. - -| Hook | Import | Suspends | Answers | -| :--------------------------- | :----------------------------- | :------- | :---------------------------------------------------------- | -| `useSessionQuery()` | `@vitnode/core/tanstack/auth` | No | The signed-in visitor, or `null` | -| `useAdminSessionQuery()` | `@vitnode/core/tanstack/admin` | Yes | Whether this browser is inside the AdminCP, and as whom | -| `useMiddlewareConfigQuery()` | `@vitnode/core/tanstack/auth` | Yes | What the deployment has switched on, before anyone signs in | - -### Deployment configuration - -`useMiddlewareConfigQuery()` reads `GET /api/@vitnode/core/middleware` - the -public answer to "what is turned on here". It needs no session, which is why the -login, registration, SSO callback and password-reset screens can all read it: - -```tsx title="plugins/contact/src/pages/contact-page.tsx" -import { useMiddlewareConfigQuery } from '@vitnode/core/tanstack/auth' - -export const ContactScreen = () => { - // [!code ++:1] - const { data: config } = useMiddlewareConfigQuery() - - return -} -``` - -| Field | What it says | -| :---------- | :-------------------------------------------------------------------- | -| `sso` | The social providers to draw buttons for | -| `isEmail` | Whether an email adapter is configured - password reset needs one | -| `captcha` | The public site key and widget type, or `undefined` | -| `ai.models` | The [configured AI models](/docs/dev/ai); empty when AI is not set up | -| `isKnown` | `false` when the read failed - see below | - -`isKnown` is the one field the API does not send. The fetch never throws: a -deployment whose API is unreachable answers `UNKNOWN_MIDDLEWARE_CONFIG` with -`isKnown: false`, so a login screen still renders instead of blanking. Check it -before treating "no captcha" or "no email" as a deliberate choice. - -Route loaders warm it with the options factory rather than the hook: - -```ts -await queryClient.query({ - ...middlewareConfigQueryOptions(), - staleTime: "static", -}) -``` - -`staleTime: "static"` is what makes a loader cheap: a cached entry is handed -back as-is, so a second visit to the route does not pay for another round trip. -The component's own `useQuery` keeps that entry honest afterwards, refetching on -its normal schedule. - ---- - -## Freshness Constants - -VitNode provides standard stale times in `@vitnode/core/lib/query-freshness`: - -| Constant | Duration | Use Case | -| :------------------ | :--------- | :------------------------------------------ | -| `RECORD_STALE_TIME` | 30 seconds | Fast-changing user feeds, announcements | -| `STATIC_STALE_TIME` | 5 minutes | Site configuration, navigation, permissions | - -## Learn More - - - - - - diff --git a/apps/web/content/docs/dev/debugging/index.mdx b/apps/web/content/docs/dev/debugging/index.mdx index c8075a035..ce97ab7f8 100644 --- a/apps/web/content/docs/dev/debugging/index.mdx +++ b/apps/web/content/docs/dev/debugging/index.mdx @@ -100,7 +100,7 @@ is compiled out of a `vite build`. {/* Image prompt: A screenshot of a VitNode dev server at localhost:3000 with the TanStack Devtools panel open on the TanStack Query tab - a list of cached query keys down the left, one selected, its cached JSON and a fresh/stale badge in the detail pane on the right. Dark theme, 1440x900. */} Navigate around with the Query panel open and the shape of -[data loading](/docs/dev/data-loading) becomes obvious: a route's loader warms +[data fetching](/docs/dev/fetcher) becomes obvious: a route's loader warms its entries before React renders, and the screen reads the same ones back. diff --git a/apps/web/content/docs/dev/fetcher.mdx b/apps/web/content/docs/dev/fetcher.mdx index ecaa58a32..9a87c7fd5 100644 --- a/apps/web/content/docs/dev/fetcher.mdx +++ b/apps/web/content/docs/dev/fetcher.mdx @@ -1,13 +1,20 @@ --- -title: API Fetcher -description: Call Hono endpoints with end-to-end type safety using a universal client that works seamlessly across SSR and the browser. +title: Data Fetching +description: Universal type-safe API client, route loaders, cache invalidation, and built-in queries in TanStack Start. icon: ArrowRightLeft --- -import { BoxesIcon, DatabaseIcon, MapIcon, ServerIcon } from 'lucide-react' +import { + BoxesIcon, + ClockIcon, + DatabaseIcon, + MapIcon, + ServerIcon, + ShieldCheckIcon, +} from 'lucide-react' import { Tab, Tabs } from 'fumadocs-ui/components/tabs' -In TanStack Start, network calls must bridge two execution environments: server rendering during SSR and browser navigation afterwards. +In TanStack Start, data fetching bridges two execution environments: server-side rendering during SSR and in-app client navigation afterwards. VitNode's universal `fetcher` from `@vitnode/core/tanstack/fetcher` eliminates duplicate transport logic. During SSR, it relays visitor cookies, IP addresses, and headers to the API; in the browser, it calls `/api/*` directly with `credentials: "include"`. One call, end-to-end type inference, zero drama. @@ -39,22 +46,6 @@ Four fields identify an endpoint: | `path` | Route path declared inside the module (`'/'`, `'/{id}'`) | Module's route declarations | | `method` | HTTP method declared for that path (`'get'`, `'post'`, etc.) | Declared routes for that path | -## Type inference & compile checks - -The fetcher checks your endpoint definition through `ApiPluginRegistry`. An invalid method, typo in the path, or missing parameter is caught at compile time: - -```ts -await fetcher({ - plugin: '@acme/site-notes', - // @ts-expect-error Type '"delete"' is not assignable to type '"get"'. - method: 'delete', - module: 'notes', - path: '/', -}) -``` - -The response is status-aware: `response.status` is typed as a union of declared HTTP codes (e.g. `200 | 404`). Once you narrow on `response.status`, `response.json()` automatically narrows to that status code's specific response schema. - ## Arguments: body, params, and query The `args` object is required whenever a route declares Zod schemas for parameters, queries, or request bodies: @@ -88,44 +79,74 @@ export const pinNote = async (id: string, pinned: boolean) => { - `formData` — Pass `formData` instead of `args.body` for file uploads. - `captchaToken` — Pass `captchaToken` for captcha-protected endpoints. -## Using fetcher in queries & mutations +## Type inference & compile checks + +The fetcher validates endpoints through `ApiPluginRegistry`. An invalid method, typo in the path, or missing parameter produces an immediate compile-time error: + +```ts +await fetcher({ + plugin: '@acme/site-notes', + // @ts-expect-error Type '"delete"' is not assignable to type '"get"'. + method: 'delete', + module: 'notes', + path: '/', +}) +``` + +The response is status-aware: `response.status` is typed as a union of declared HTTP codes (e.g. `200 | 404`). Once you narrow on `response.status`, `response.json()` automatically narrows to that status code's specific schema. - - +## How the registry types your calls -Warm queries in your route's `load` function and read from the cache with `useQuery`: +Each plugin exports a lightweight contract type from its `config.api.ts`: -```ts title="plugins/site-notes/src/features/notes/notes-query.ts" -import { queryOptions } from '@tanstack/react-query' -import { fetcher } from '@vitnode/core/tanstack/fetcher' +```ts title="plugins/site-notes/src/config.api.ts" +import type { ApiPluginContract } from '@vitnode/core/lib/fetcher' -export const notesQueryKey = ['@acme/site-notes', 'notes'] as const +export type VitNodeApiPlugin = ApiPluginContract< + ReturnType +> +``` -export const notesQuery = () => - queryOptions({ - queryKey: notesQueryKey, - queryFn: async ({ signal }) => { - const response = await fetcher({ - plugin: '@acme/site-notes', - method: 'get', - module: 'notes', - options: { signal }, - path: '/', - }) +`ApiPluginContract` strips away runtime internals (Hono handlers, database connections, and background workers) and extracts only what the compiler needs: the plugin ID, module paths, route endpoints, methods, and Zod schemas. - if (!response.ok) { - throw new Error(`Failed to load notes: ${response.status}`) - } +When you run `vite dev` or `vite build`, the `vitnode:plugin-routes` plugin automatically updates your app's `src/api-registry.gen.ts`: - return await response.json() - }, - }) +```ts title="apps/web/src/api-registry.gen.ts" +import type { VitNodeApiPlugin as ApiPlugin0 } from '@acme/site-notes/config.api' + +declare module '@vitnode/core/lib/fetcher/registry' { + interface ApiPluginRegistry { + '@acme/site-notes': ApiPlugin0 + } +} + +export type { ApiPluginRegistry } from '@vitnode/core/lib/fetcher/registry' ``` -```ts title="plugins/site-notes/src/pages/notes-page.tsx" -import { definePluginRoute } from '@vitnode/core/routing' + + All imports in the registry use `import type`. This guarantees that + server-side code, database credentials, and internal handlers never leak into + the browser bundle. The actual API factory executes exclusively in + `vitnode.api.config.ts`. + + +## Loading data in plugin routes + +Plugin routes load data using `definePluginRoute({ load })`. The loader runs during SSR and client navigation, handing typed `loaderData` to the page component: + +```tsx title="plugins/site-notes/src/pages/notes-page.tsx" +import { + definePluginRoute, + type PluginRoutePageProps, +} from '@vitnode/core/routing' import { notesQuery } from '../features/notes/notes-query' +interface Note { + id: string + title: string +} + +// [!code ++:8] export const route = definePluginRoute({ load: async ({ context }) => { return await context.queryClient.query({ @@ -134,12 +155,30 @@ export const route = definePluginRoute({ }) }, }) + +const NotesPage = ({ loaderData }: PluginRoutePageProps) => { + return ( +
+ {loaderData.map((note) => ( +

+ {note.title} +

+ ))} +
+ ) +} + +export default NotesPage ``` -
- +`staleTime: "static"` makes route loaders fast and cheap: a cached entry is returned instantly without redundant network trips on repeat visits, while the component's `useQuery` keeps data fresh in the background. + +## Mutations & cache invalidation -Execute mutations and invalidate affected cache tags: +After creating, editing, or deleting records, call `queryClient.invalidateQueries` to refetch fresh data, then display a `sonner` toast notification: ```tsx title="plugins/site-notes/src/features/notes/use-create-note.ts" import { useMutation, useQueryClient } from '@tanstack/react-query' @@ -175,8 +214,51 @@ export const useCreateNote = () => { } ``` - -
+## Built-in system queries + +Core provides three pre-warmed query hooks for common application state: + +| Hook | Import | Suspends | Description | +| :--------------------------- | :----------------------------- | :------- | :--------------------------------------------- | +| `useSessionQuery()` | `@vitnode/core/tanstack/auth` | No | Signed-in visitor session, or `null` | +| `useAdminSessionQuery()` | `@vitnode/core/tanstack/admin` | Yes | AdminCP staff identity and permissions | +| `useMiddlewareConfigQuery()` | `@vitnode/core/tanstack/auth` | Yes | Public deployment configuration before sign-in | + +### Deployment configuration + +`useMiddlewareConfigQuery()` reads public server capabilities without requiring an active session, allowing login and registration screens to adapt dynamically: + +```tsx title="plugins/site-notes/src/pages/login-page.tsx" +import { useMiddlewareConfigQuery } from '@vitnode/core/tanstack/auth' + +const LoginPage = () => { + const { data: config } = useMiddlewareConfigQuery() + + return ( +
+ {config.captcha ?

Captcha enabled

: null} + {config.sso?.length ?

Social sign-in available

: null} +
+ ) +} + +export default LoginPage +``` + +- `sso` — Enabled social login providers. +- `isEmail` — Whether an email adapter is configured (required for password reset). +- `captcha` — Public site key and provider type (`recaptcha` or `cloudflare`). +- `ai.models` — Configured AI models in the deployment. +- `isKnown` — `false` if the API was unreachable (`UNKNOWN_MIDDLEWARE_CONFIG`), ensuring login forms still render. + +## Freshness constants + +VitNode provides standard stale times in `@vitnode/core/lib/query-freshness`: + +| Constant | Duration | Recommended use case | +| :------------------ | :--------- | :-------------------------------------------------------- | +| `RECORD_STALE_TIME` | 30 seconds | Fast-changing feeds, dynamic user activity, announcements | +| `STATIC_STALE_TIME` | 5 minutes | Site configuration, navigation menus, role permissions | ## Universal vs. server-only fetcher @@ -196,6 +278,28 @@ export const useCreateNote = () => { server fetchers should never reach client bundles.
+## Browser-only requests with fetcherClient + +For client code that **never** executes on the server—such as browser event listeners, interactive dialogs, or framework-neutral utilities—call `fetcherClient` from `@vitnode/core/lib/fetcher-client`: + +```ts title="plugins/site-notes/src/features/notes/client-actions.ts" +import { fetcherClient } from '@vitnode/core/lib/fetcher-client' + +export const deleteNote = async (id: string) => { + const response = await fetcherClient({ + plugin: '@acme/site-notes', + args: { params: { id } }, + method: 'delete', + module: 'notes', + path: '/{id}', + }) + + return response.ok +} +``` + +`fetcherClient` shares the exact same call signature and type inference as the universal `fetcher`, but is built exclusively for the browser with `credentials: "include"` and automatic rate-limiting notifications. + ## Content Engine routes Public routes created via [Content Engine](/docs/dev/content-engine) (`publicApi`) are automatically typed under `module: "content/"`: @@ -219,12 +323,6 @@ For generated AdminCP content modules where schemas are dynamic, use `rawFetcher description="Define content types, schemas, and typed public endpoints." href="/docs/dev/content-engine" /> - } - title="Data Loading" - description="Learn caching, query prefetching, and TanStack Query integration." - href="/docs/dev/data-loading" - /> } title="Plugin Routes" @@ -237,4 +335,10 @@ For generated AdminCP content modules where schemas are dynamic, use `rawFetcher description="Implement server-only workflows, cookie relays, and background jobs." href="/docs/dev/server-functions" /> + } + title="Cache" + description="Learn frontend query cache and Redis domain caching." + href="/docs/dev/cache" + />
diff --git a/apps/web/content/docs/dev/meta.json b/apps/web/content/docs/dev/meta.json index f361fb9c7..9ba15f210 100644 --- a/apps/web/content/docs/dev/meta.json +++ b/apps/web/content/docs/dev/meta.json @@ -14,7 +14,6 @@ "plugins", "routing", "fetcher", - "data-loading", "cache", "server-functions", "database", diff --git a/apps/web/content/docs/dev/performance.mdx b/apps/web/content/docs/dev/performance.mdx index 6418640ec..1c75d0d47 100644 --- a/apps/web/content/docs/dev/performance.mdx +++ b/apps/web/content/docs/dev/performance.mdx @@ -210,13 +210,13 @@ Inspect the output chunk sizes in `apps/web/.output/public/assets/`, then load a diff --git a/apps/web/content/docs/dev/plugins/api/modules.mdx b/apps/web/content/docs/dev/plugins/api/modules.mdx index 27d5d51f0..eda613a17 100644 --- a/apps/web/content/docs/dev/plugins/api/modules.mdx +++ b/apps/web/content/docs/dev/plugins/api/modules.mdx @@ -89,30 +89,45 @@ here is callable, and checked, the moment you save. -### Register the API type for your own pages +### Export the type an application registers Your pages call the endpoint through the universal fetcher, and the fetcher -looks the plugin up in the API plugin registry. Register yours once, in the -plugin's `global.d.ts`, as a **type** - `create-vitnode-app --plugin` writes -this file for you: +looks the plugin up in the API plugin registry. Export one reduced type beside +your factory - `create-vitnode-app --plugin` writes this for you: -```ts title="plugins/site-notes/global.d.ts" -import type { siteNotesApiPlugin } from './src/config.api' +```ts title="plugins/site-notes/src/config.api.ts" +import type { ApiPluginContract } from '@vitnode/core/api/lib/plugin' + +import { buildApiPlugin } from '@vitnode/core/api/lib/plugin' -declare module '@vitnode/core/lib/fetcher/registry' { - interface ApiPluginRegistry { - '@acme/site-notes': typeof siteNotesApiPlugin // [!code ++] - } -} +export const siteNotesApiPlugin = () => + buildApiPlugin({ modules: [notesModule], pluginId: '@acme/site-notes' }) -export type { ApiPluginRegistry } from '@vitnode/core/lib/fetcher/registry' +// [!code ++:3] +export type VitNodeApiPlugin = ApiPluginContract< + ReturnType +> ``` -`import type` is the whole point: the factory is never executed here, so none -of the Hono, database or secret code behind it is ever reached by a browser -build. The app that installs your plugin writes its own entry into its -`src/api-registry.gen.ts`, so this file is for developing the plugin on its -own. +`ApiPluginContract` keeps the four things a call needs - the literal plugin id, +every module path you serve, your module tree and your route definitions - and +drops everything else. Your Hono application, content models, event listeners, +queue tasks, WebSockets, search indexers and messages are runtime concerns, so +an app that installs your plugin never resolves them to type-check a call. + +`import type` is the whole point: the factory is never executed here, so none of +the Hono, database or secret code behind it is ever reached by a browser build. + + + Your package augments nothing. The app that installs you writes the entry into + its own generated `src/api-registry.gen.ts`, from the plugins it configured. A + package that registered itself would put its routes into the registry of every + project that merely installed it. + + To type-check your own pages before any app has installed the plugin, the + scaffold writes `test-fixtures/api-registry.d.ts` - the same entry an app + generates, kept outside `src` and out of the published package. + diff --git a/apps/web/content/docs/dev/routing/breadcrumbs.mdx b/apps/web/content/docs/dev/routing/breadcrumbs.mdx index eedbcae2d..7f0f68de7 100644 --- a/apps/web/content/docs/dev/routing/breadcrumbs.mdx +++ b/apps/web/content/docs/dev/routing/breadcrumbs.mdx @@ -155,9 +155,9 @@ Both spellings do the same thing. The trail simply ends at the deepest route tha /> } - title="Data Loading" + title="Data Fetching" description="Fetch data in loaders so crumbs and pages receive typed loaderData." - href="/docs/dev/data-loading" + href="/docs/dev/fetcher" /> } diff --git a/apps/web/content/docs/dev/routing/loading.mdx b/apps/web/content/docs/dev/routing/loading.mdx index 36bea6c44..e045bafe7 100644 --- a/apps/web/content/docs/dev/routing/loading.mdx +++ b/apps/web/content/docs/dev/routing/loading.mdx @@ -118,9 +118,9 @@ export default AdminNotesPage } - title="Data Loading" + title="Data Fetching" description="Learn universal fetchers, cache invalidation, and background mutations." - href="/docs/dev/data-loading" + href="/docs/dev/fetcher" /> } diff --git a/apps/web/content/docs/dev/routing/not-found.mdx b/apps/web/content/docs/dev/routing/not-found.mdx index 671ceb22f..aacab14bd 100644 --- a/apps/web/content/docs/dev/routing/not-found.mdx +++ b/apps/web/content/docs/dev/routing/not-found.mdx @@ -132,8 +132,8 @@ VitNode provides `ErrorContent` to render standardized error presentations acros /> } - title="Data Loading" + title="Data Fetching" description="Server and client data loading with universal fetchers." - href="/docs/dev/data-loading" + href="/docs/dev/fetcher" /> diff --git a/apps/web/content/docs/dev/routing/routes.mdx b/apps/web/content/docs/dev/routing/routes.mdx index ada8da516..964a8a0cd 100644 --- a/apps/web/content/docs/dev/routing/routes.mdx +++ b/apps/web/content/docs/dev/routing/routes.mdx @@ -310,9 +310,9 @@ export default NotesPage } - title="Data Loading" + title="Data Fetching" description="Learn universal fetchers, cache invalidation, and background mutations." - href="/docs/dev/data-loading" + href="/docs/dev/fetcher" /> } diff --git a/apps/web/content/docs/dev/server-functions.mdx b/apps/web/content/docs/dev/server-functions.mdx index 7875cc970..6e6f61520 100644 --- a/apps/web/content/docs/dev/server-functions.mdx +++ b/apps/web/content/docs/dev/server-functions.mdx @@ -103,11 +103,11 @@ The call is the same shape as the universal one - `plugin`, `module`, `path`, `method`, `args` - and it is typed from the same registry. What the server fetcher adds is exactly what a browser cannot do: -| Option | What it does | -| :------------------ | :------------------------------------------------------------ | -| `allowSaveCookies` | relays the API's `Set-Cookie` headers onto the host response | -| `additionalHeaders` | adds headers a browser may not forge, `Cookie` included | -| `origin` | calls a different API origin than the configured one | +| Option | What it does | +| :------------------ | :----------------------------------------------------------- | +| `allowSaveCookies` | relays the API's `Set-Cookie` headers onto the host response | +| `additionalHeaders` | adds headers a browser may not forge, `Cookie` included | +| `origin` | calls a different API origin than the configured one | Two things make this the exception rather than the rule: @@ -125,12 +125,12 @@ end up in a shared module by accident. diff --git a/apps/web/src/api-registry.gen.ts b/apps/web/src/api-registry.gen.ts index a0d5aa52e..af4dd1627 100644 --- a/apps/web/src/api-registry.gen.ts +++ b/apps/web/src/api-registry.gen.ts @@ -1,14 +1,31 @@ /* eslint-disable */ // This file is generated by VitNode. Do not edit it, and do not format it. +// +// It is rewritten by the `vitnode:plugin-routes` Vite plugin on every +// `vite dev` and `vite build`, from one input: the plugins configured in +// `src/vitnode.config.ts`, and which of them export a `config.api` module. +// +// This is the only place an installed plugin's API is registered, and it is what +// `fetcher` resolves a call against. A plugin that augmented the registry from +// inside its own package would put its routes into the registry of every project +// that merely installed it, configured or not. +// +// Every import is `import type`, and each names one already-reduced type rather +// than a whole module namespace. A plugin's `config.api` reaches its Hono +// handlers, its database and its secrets; naming the type keeps all of that out +// of this graph, and out of the browser bundle. +// +// Same plugin configuration in, same bytes out: the entries are sorted by +// plugin id. -import type * as apiPlugin0 from '@vitnode/blog/config.api' -import type * as apiPlugin1 from '@vitnode/example/config.api' +import type { VitNodeApiPlugin as ApiPlugin0 } from '@vitnode/blog/config.api' +import type { VitNodeApiPlugin as ApiPlugin1 } from '@vitnode/example/config.api' declare module '@vitnode/core/lib/fetcher/registry' { interface ApiPluginRegistry { - '@vitnode/blog': typeof apiPlugin0 - '@vitnode/example': typeof apiPlugin1 + '@vitnode/blog': ApiPlugin0 + '@vitnode/example': ApiPlugin1 } } diff --git a/packages/create-vitnode-app/copy-of-vitnode-plugin/root/npmignore.template b/packages/create-vitnode-app/copy-of-vitnode-plugin/root/npmignore.template index bb5e95899..a3234cbec 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-plugin/root/npmignore.template +++ b/packages/create-vitnode-app/copy-of-vitnode-plugin/root/npmignore.template @@ -10,6 +10,7 @@ /.swcrc /components.json /global.d.ts +/test-fixtures /tsup.config.ts /vitest.config.ts /tsconfig.json diff --git a/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts b/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts index 2231e00e7..7a4b6fa4b 100644 --- a/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts +++ b/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { pluginApiConfigTemplate, pluginApiModuleTemplate, + pluginApiRegistryFixtureTemplate, pluginApiRouteTemplate, pluginApiVariableName, pluginConfigTemplate, @@ -238,6 +239,7 @@ describe("the generated constant", () => { .filter( ([file]) => file !== "global.d.ts" && + file !== "test-fixtures/api-registry.d.ts" && file !== "src/const.ts" && file !== "src/locales/en.json" && file !== "src/pages/home-page.tsx" && @@ -282,43 +284,42 @@ describe("the generated API module", () => { }); describe("the generated type registrations", () => { - it("imports the API factory as a type, which is what keeps Hono out of the browser", () => { - const types = pluginGlobalTypesTemplate("@acme/blog"); + it("keeps the message tree registration a generated plugin always had", () => { + const types = pluginGlobalTypesTemplate(); + expect(types).toContain('declare module "use-intl" {'); expect(types).toContain( - 'import type { blogApiPlugin } from "./src/config.api";', + "Messages: typeof plugin & typeof core & typeof coreApi;", ); - expect(types).not.toMatch(/^import \{[^}]*ApiPlugin/m); }); - it("registers the plugin's API under its own id, so its pages can call it", () => { - const types = pluginGlobalTypesTemplate("@acme/blog"); + it("registers no API from inside the package", () => { + // A package-level augmentation lands in the registry of every project that + // installs the plugin, configured or not. The app's generated registry is + // the one source of truth. + const types = pluginGlobalTypesTemplate(); - expect(types).toContain( - 'declare module "@vitnode/core/lib/fetcher/registry" {', - ); - expect(types).toContain("interface ApiPluginRegistry {"); - expect(types).toContain('"@acme/blog": typeof blogApiPlugin;'); + expect(types).not.toContain("ApiPluginRegistry"); + expect(types).not.toContain("fetcher/registry"); }); - it("keeps the message tree registration a generated plugin always had", () => { - const types = pluginGlobalTypesTemplate("blog"); + it("registers the plugin for its own type-checking, outside the package", () => { + const fixture = pluginApiRegistryFixtureTemplate("@acme/blog"); - expect(types).toContain('declare module "use-intl" {'); - expect(types).toContain( - "Messages: typeof plugin & typeof core & typeof coreApi;", + expect(fixture).toContain( + 'import type { VitNodeApiPlugin } from "../src/config.api";', ); - }); - - it("loads the registry module it augments, or the augmentation would merge into nothing", () => { - expect(pluginGlobalTypesTemplate("blog")).toContain( + expect(fixture).toContain('"@acme/blog": VitNodeApiPlugin;'); + // A `declare module` merges only into a module the program has loaded. + expect(fixture).toContain( 'export type { ApiPluginRegistry } from "@vitnode/core/lib/fetcher/registry";', ); }); it("evaluates nothing", () => { // A `.d.ts` that ran the factory would build a Hono app at type-check time. - expect(pluginGlobalTypesTemplate("blog")).not.toContain("()"); + expect(pluginApiRegistryFixtureTemplate("blog")).not.toContain("()"); + expect(pluginGlobalTypesTemplate()).not.toContain("()"); }); }); @@ -332,6 +333,18 @@ describe("the generated API config", () => { expect(config).toContain("modules: [helloModule],"); }); + it("exports the reduced type an application's registry imports", () => { + const config = pluginApiConfigTemplate("@acme/my-blog"); + + expect(config).toContain( + 'import type { ApiPluginContract } from "@vitnode/core/api/lib/plugin";', + ); + expect(config).toContain( + "export type VitNodeApiPlugin = ApiPluginContract<", + ); + expect(config).toContain("ReturnType"); + }); + it("exports a factory an app can import beside the UI one", () => { expect(pluginApiConfigTemplate("@acme/my-blog")).toContain( "export const myBlogApiPlugin = () =>", @@ -418,6 +431,7 @@ describe("the scaffold as a whole", () => { expect(Object.keys(files)).toContain("src/config.api.ts"); expect(Object.keys(files)).toContain("src/const.ts"); expect(Object.keys(files)).toContain("global.d.ts"); + expect(Object.keys(files)).toContain("test-fixtures/api-registry.d.ts"); expect(Object.keys(files)).not.toContain("src/api/client.ts"); }); @@ -436,7 +450,11 @@ describe("the scaffold as a whole", () => { // reaches an app through its package exports, and the app's own generated // registry is rewritten from the plugin list on every build. Object.keys(pluginRouteScaffold("blog")).forEach(file => { - expect(file.startsWith("src/") || file === "global.d.ts").toBe(true); + expect( + file.startsWith("src/") || + file.startsWith("test-fixtures/") || + file === "global.d.ts", + ).toBe(true); expect(file).not.toContain(".."); }); }); diff --git a/packages/create-vitnode-app/src/plugin/create/route-templates.ts b/packages/create-vitnode-app/src/plugin/create/route-templates.ts index 3a37319a8..c81ecd0aa 100644 --- a/packages/create-vitnode-app/src/plugin/create/route-templates.ts +++ b/packages/create-vitnode-app/src/plugin/create/route-templates.ts @@ -224,7 +224,9 @@ export const helloModule = buildModule({ `; export const pluginApiConfigTemplate = (pluginName: string): string => - `import { buildApiPlugin } from "@vitnode/core/api/lib/plugin"; + `import type { ApiPluginContract } from "@vitnode/core/api/lib/plugin"; + +import { buildApiPlugin } from "@vitnode/core/api/lib/plugin"; import { CONFIG_PLUGIN } from "@/const"; @@ -235,30 +237,53 @@ export const ${pluginApiVariableName(pluginName)} = () => pluginId: CONFIG_PLUGIN.pluginId, modules: [helloModule], }); + +/** + * What an application's generated api-registry.gen.ts imports to make this + * plugin's routes callable through the fetcher. + * + * Reduced here, once, to the module tree and the route definitions - so no app + * that installs this plugin re-derives them, and none of the Hono, database or + * secret code behind the factory is reachable from a browser build. + */ +export type VitNodeApiPlugin = ApiPluginContract< + ReturnType +>; `; -export const pluginGlobalTypesTemplate = (pluginName: string): string => +/** + * `test-fixtures/api-registry.d.ts` - what makes this plugin's own pages + * type-check before any application has installed it. + * + * The entry an app's generated `src/api-registry.gen.ts` will write, kept here + * rather than in `global.d.ts` and kept out of the published package: a plugin + * that registered itself would add its routes to the registry of every project + * that installed it, whether or not that project configured it. + */ +export const pluginApiRegistryFixtureTemplate = (pluginName: string): string => + `import type { VitNodeApiPlugin } from "../src/config.api"; + +declare module "@vitnode/core/lib/fetcher/registry" { + interface ApiPluginRegistry { + "${pluginName}": VitNodeApiPlugin; + } +} + +export type { ApiPluginRegistry } from "@vitnode/core/lib/fetcher/registry"; +`; + +export const pluginGlobalTypesTemplate = (): string => `/// import coreApi from "@vitnode/core/locales/api/en.json" with { type: "json" }; import core from "@vitnode/core/locales/en.json" with { type: "json" }; import plugin from "./src/locales/en.json" with { type: "json" }; -import type { ${pluginApiVariableName(pluginName)} } from "./src/config.api"; - declare module "use-intl" { interface AppConfig { Messages: typeof plugin & typeof core & typeof coreApi; } } - -declare module "@vitnode/core/lib/fetcher/registry" { - interface ApiPluginRegistry { - "${pluginName}": typeof ${pluginApiVariableName(pluginName)}; - } -} - -export type { ApiPluginRegistry } from "@vitnode/core/lib/fetcher/registry"; `; /** @@ -292,7 +317,9 @@ export const pluginPackageExports = (): Record< export const pluginRouteScaffold = ( pluginName: string, ): Record => ({ - "global.d.ts": pluginGlobalTypesTemplate(pluginName), + "global.d.ts": pluginGlobalTypesTemplate(), + "test-fixtures/api-registry.d.ts": + pluginApiRegistryFixtureTemplate(pluginName), "src/api/modules/hello/hello.module.ts": pluginApiModuleTemplate(), "src/api/modules/hello/hello.route.ts": pluginApiRouteTemplate(), "src/config.api.ts": pluginApiConfigTemplate(pluginName), diff --git a/packages/create-vitnode-app/src/plugin/create/scaffold-typecheck.test.ts b/packages/create-vitnode-app/src/plugin/create/scaffold-typecheck.test.ts deleted file mode 100644 index 4d0e21b16..000000000 --- a/packages/create-vitnode-app/src/plugin/create/scaffold-typecheck.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { existsSync, readdirSync } from "node:fs"; -import { - mkdir, - mkdtemp, - readFile, - rm, - symlink, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; - -import { pluginRouteScaffold } from "./route-templates.js"; - -const PLUGIN_NAME = "@acme/notes"; - -const repoRoot = resolve(import.meta.dirname, "../../../../.."); -const corePackage = join(repoRoot, "packages", "vitnode"); -const configPackage = join(repoRoot, "packages", "config"); -const templateRoot = join( - repoRoot, - "packages", - "create-vitnode-app", - "copy-of-vitnode-plugin", - "root", -); - -const coreIsBuilt = existsSync( - join(corePackage, "dist", "src", "tanstack", "fetcher", "index.d.ts"), -); - -const PROBE = `import { fetcher } from "@vitnode/core/tanstack/fetcher"; - -import { CONFIG_PLUGIN } from "@/const"; - -export const hello = async () => { - const response = await fetcher({ - plugin: CONFIG_PLUGIN.pluginId, - method: "get", - module: "hello", - path: "/", - }); - - const status: 200 = response.status; - const message: string = (await response.json()).message; - - return { message, status }; -}; - -export const literal = async () => - await fetcher({ - plugin: "${PLUGIN_NAME}", - method: "get", - module: "hello", - path: "/", - }); - -export const wrongModule = async () => - await fetcher({ - plugin: CONFIG_PLUGIN.pluginId, - method: "get", - // @ts-expect-error -- the scaffold mounts only \`hello\` - module: "notes", - path: "/", - }); - -export const wrongMethod = async () => - await fetcher({ - plugin: CONFIG_PLUGIN.pluginId, - // @ts-expect-error -- \`/\` is a \`get\` - method: "post", - module: "hello", - path: "/", - }); - -export const wrongPlugin = async () => - await fetcher({ - // @ts-expect-error -- only registered plugins are callable - plugin: "@acme/other", - method: "get", - module: "hello", - path: "/", - }); -`; - -const linkEntries = async (from: string, into: string): Promise => { - for (const entry of readdirSync(from, { withFileTypes: true })) { - if (entry.name === ".bin") continue; - - const target = join(from, entry.name); - const link = join(into, entry.name); - - if (entry.name.startsWith("@")) { - await mkdir(link, { recursive: true }); - await linkEntries(target, link); - continue; - } - - if (!existsSync(link)) await symlink(target, link); - } -}; - -const writeScaffold = async (pluginPath: string): Promise => { - const files = { - ...pluginRouteScaffold(PLUGIN_NAME), - "src/probe.ts": PROBE, - "tsconfig.check.json": JSON.stringify({ - compilerOptions: { - declaration: false, - declarationMap: false, - emitDeclarationOnly: false, - incremental: false, - noEmit: true, - }, - extends: "./tsconfig.json", - }), - "tsconfig.json": await readFile( - join(templateRoot, "tsconfig.json"), - "utf8", - ), - }; - - for (const [file, contents] of Object.entries(files)) { - await mkdir(dirname(join(pluginPath, file)), { recursive: true }); - await writeFile(join(pluginPath, file), contents, "utf8"); - } - - const modules = join(pluginPath, "node_modules"); - await mkdir(join(modules, "@vitnode"), { recursive: true }); - await symlink(corePackage, join(modules, "@vitnode", "core")); - await symlink(configPackage, join(modules, "@vitnode", "config")); - await linkEntries(join(corePackage, "node_modules"), modules); -}; - -const typecheck = (pluginPath: string) => - spawnSync( - process.execPath, - [ - join(pluginPath, "node_modules", "typescript", "bin", "tsc"), - "-p", - join(pluginPath, "tsconfig.check.json"), - "--pretty", - "false", - ], - { cwd: pluginPath, encoding: "utf8" }, - ); - -describe.skipIf(!coreIsBuilt)( - "a generated plugin type-checks against the fetcher it ships with", - () => { - let pluginPath = ""; - let result: ReturnType; - - beforeAll(async () => { - pluginPath = await mkdtemp(join(tmpdir(), "vitnode-plugin-scaffold-")); - await writeScaffold(pluginPath); - result = typecheck(pluginPath); - }, 240_000); - - afterAll(async () => { - if (pluginPath) await rm(pluginPath, { force: true, recursive: true }); - }); - - it("compiles the scaffold and a typed call to its own endpoint", () => { - expect(result.stdout + result.stderr).toBe(""); - expect(result.status).toBe(0); - }); - - it("rejects a module, method or plugin the scaffold does not register", () => { - // The `@ts-expect-error` lines in the probe are consumed only when the - // call really is an error; an unused one fails the compile above. - expect(PROBE.match(/@ts-expect-error/g)).toHaveLength(3); - }); - }, -); diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts index b306a3757..d7969a82e 100644 --- a/packages/vitnode/src/api/lib/plugin.ts +++ b/packages/vitnode/src/api/lib/plugin.ts @@ -3,6 +3,7 @@ import { OpenAPIHono } from "@hono/zod-openapi"; import type { RegisteredContentType } from "@/content/registry"; import type { AnyContentModel } from "@/content/server/model"; import type { AnyContentTypeDefinition } from "@/content/types"; +import type { ApiPluginContract } from "@/lib/fetcher/contract"; import type { LocaleMessagesMap } from "@/lib/i18n/types"; import { @@ -22,6 +23,8 @@ import { validateSearchIndexers } from "../models/search"; import { checkPluginId } from "./check-plugin-id"; import { applyModuleTags } from "./openapi-tags"; +export type { ApiPluginContract }; + export interface BuildPluginApiReturn< P extends string = string, Modules extends readonly BaseBuildModuleReturn

[] = diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts index 45cd3deb4..5587e3f96 100644 --- a/packages/vitnode/src/api/plugin.ts +++ b/packages/vitnode/src/api/plugin.ts @@ -1,3 +1,5 @@ +import type { ApiPluginContract } from "@/lib/fetcher/contract"; + import { CONFIG_PLUGIN } from "@/config"; import { buildApiPlugin } from "./lib/plugin"; @@ -75,3 +77,5 @@ export const newBuildPluginApiCore = buildApiPlugin({ }, }, }); + +export type VitNodeApiPlugin = ApiPluginContract; diff --git a/packages/vitnode/src/framework/api-registry/generate.test.ts b/packages/vitnode/src/framework/api-registry/generate.test.ts index 967683f26..f146ecccf 100644 --- a/packages/vitnode/src/framework/api-registry/generate.test.ts +++ b/packages/vitnode/src/framework/api-registry/generate.test.ts @@ -18,17 +18,26 @@ const SHOP: ResolvedApiPluginModule = { }; describe("generateApiRegistrySource", () => { - it("imports every plugin API module as a type and nothing else", () => { + it("names one reduced type per plugin rather than a module namespace", () => { const source = generateApiRegistrySource([BLOG, SHOP]); expect(source).toContain( - "import type * as apiPlugin0 from '@acme/blog/config.api'", + "import type { VitNodeApiPlugin as ApiPlugin0 } from '@acme/blog/config.api'", ); expect(source).toContain( - "import type * as apiPlugin1 from '@acme/shop/config.api'", + "import type { VitNodeApiPlugin as ApiPlugin1 } from '@acme/shop/config.api'", ); + // A namespace import would make the registry resolve every export of a + // plugin's API config, and searching them for the factory. + expect(source).not.toContain("* as"); + }); + + it("imports types and never values, and executes nothing", () => { + const source = generateApiRegistrySource([BLOG, SHOP]); + expect(source).not.toMatch(/^import (?!type )/m); expect(source).not.toContain("()"); + expect(source).not.toContain("typeof "); }); it("augments the registry the fetcher reads, keyed by plugin id", () => { @@ -36,7 +45,7 @@ describe("generateApiRegistrySource", () => { expect(source).toContain(`declare module '${API_REGISTRY_SPECIFIER}'`); expect(source).toContain("interface ApiPluginRegistry {"); - expect(source).toContain("'@acme/blog': typeof apiPlugin0"); + expect(source).toContain("'@acme/blog': ApiPlugin0"); }); it("sorts by plugin id, whatever order it was handed", () => { @@ -54,7 +63,7 @@ describe("generateApiRegistrySource", () => { const source = generateApiRegistrySource([]); expect(source).toContain("interface ApiPluginRegistry {\n }"); - expect(source).not.toContain("import "); + expect(source).not.toMatch(/^import /m); expect( source .trimEnd() @@ -81,7 +90,7 @@ describe("generateApiRegistrySource", () => { ]); expect(source).toContain("'@acme/it\\'s/config.api'"); - expect(source).toContain("'@acme/it\\'s': typeof apiPlugin0"); + expect(source).toContain("'@acme/it\\'s': ApiPlugin0"); }); it("is a pure function of its input", () => { diff --git a/packages/vitnode/src/framework/api-registry/generate.ts b/packages/vitnode/src/framework/api-registry/generate.ts index 6a595ad1f..541ec0f9f 100644 --- a/packages/vitnode/src/framework/api-registry/generate.ts +++ b/packages/vitnode/src/framework/api-registry/generate.ts @@ -4,16 +4,38 @@ import { toSingleQuotedLiteral } from "../plugin-routes/resolve.js"; export const API_REGISTRY_SPECIFIER = "@vitnode/core/lib/fetcher/registry"; +/** The one type every plugin's `config.api.ts` exports for this file to read. */ +export const API_PLUGIN_TYPE = "VitNodeApiPlugin"; + const HEADER = `/* eslint-disable */ // This file is generated by VitNode. Do not edit it, and do not format it. +// +// It is rewritten by the \`vitnode:plugin-routes\` Vite plugin on every +// \`vite dev\` and \`vite build\`, from one input: the plugins configured in +// \`src/vitnode.config.ts\`, and which of them export a \`config.api\` module. +// +// This is the only place an installed plugin's API is registered, and it is what +// \`fetcher\` resolves a call against. A plugin that augmented the registry from +// inside its own package would put its routes into the registry of every project +// that merely installed it, configured or not. +// +// Every import is \`import type\`, and each names one already-reduced type rather +// than a whole module namespace. A plugin's \`config.api\` reaches its Hono +// handlers, its database and its secrets; naming the type keeps all of that out +// of this graph, and out of the browser bundle. +// +// Same plugin configuration in, same bytes out: the entries are sorted by +// plugin id. `; +const alias = (index: number): string => `ApiPlugin${index}`; + const importLines = (modules: readonly ResolvedApiPluginModule[]): string => modules .map( (module, index) => - `import type * as apiPlugin${index} from ${toSingleQuotedLiteral(module.specifier)}`, + `import type { ${API_PLUGIN_TYPE} as ${alias(index)} } from ${toSingleQuotedLiteral(module.specifier)}`, ) .join("\n"); @@ -21,7 +43,7 @@ const entryLines = (modules: readonly ResolvedApiPluginModule[]): string => modules .map( (module, index) => - ` ${toSingleQuotedLiteral(module.pluginId)}: typeof apiPlugin${index}`, + ` ${toSingleQuotedLiteral(module.pluginId)}: ${alias(index)}`, ) .join("\n"); diff --git a/packages/vitnode/src/framework/api-registry/index.ts b/packages/vitnode/src/framework/api-registry/index.ts index 7bf16fd77..524b6867b 100644 --- a/packages/vitnode/src/framework/api-registry/index.ts +++ b/packages/vitnode/src/framework/api-registry/index.ts @@ -1,4 +1,5 @@ export { + API_PLUGIN_TYPE, API_REGISTRY_SPECIFIER, generateApiRegistrySource, } from "./generate.js"; diff --git a/packages/vitnode/src/lib/fetcher-client.ts b/packages/vitnode/src/lib/fetcher-client.ts index 055cf352f..3b4a5b34a 100644 --- a/packages/vitnode/src/lib/fetcher-client.ts +++ b/packages/vitnode/src/lib/fetcher-client.ts @@ -1,10 +1,9 @@ import type { RawApiFetchArgs } from "./fetcher/raw"; import type { - FetcherRequest, + FetcherCall, FetcherRequestOptions, - FetcherResponse, - PluginRouteMethod, RegisteredPluginId, + ResponseFor, } from "./fetcher/types"; import { coreFetcher } from "./fetcher/core"; @@ -12,14 +11,17 @@ import { isRateLimited, notifyRateLimited } from "./fetcher/rate-limit"; import { rawApiFetch } from "./fetcher/raw"; import { CAPTCHA_TOKEN_HEADER } from "./fetcher/request-context"; +export type { ApiEndpoint, ApiPluginContract } from "./fetcher/contract"; export type { ApiPluginRegistry } from "./fetcher/registry"; export type { - FetcherRequest, - FetcherResponse, + AllEndpoints, + ApiRequest, + PluginEndpoints, PluginModulePath, PluginRouteMethod, PluginRoutePath, RegisteredPluginId, + ResponseFor, } from "./fetcher/types"; export type FetcherClientOptions = Omit< @@ -33,19 +35,19 @@ export async function fetcherClient< P extends RegisteredPluginId, M extends string, Path extends string, - Method extends string = PluginRouteMethod, + Method extends string, >({ - plugin, - module, - path, - method, args, - options, - withPagination = false, captchaToken, formData, -}: FetcherClientOptions & FetcherRequest): Promise< - FetcherResponse + method, + module, + options, + path, + plugin, + withPagination = false, +}: FetcherCall): Promise< + ResponseFor > { const additionalHeaders: Record = {}; @@ -54,16 +56,16 @@ export async function fetcherClient< } const response = await coreFetcher({ - plugin, - module, - path, - method, + additionalHeaders, args, + formData, + method, + module, options: { credentials: "include", ...options }, + path, + plugin, withPagination, - additionalHeaders, - formData, - } as FetcherRequest & FetcherRequestOptions); + } as FetcherCall); if (isRateLimited(response)) { notifyRateLimited(response); diff --git a/packages/vitnode/src/lib/fetcher/contract.ts b/packages/vitnode/src/lib/fetcher/contract.ts new file mode 100644 index 000000000..9ec3af0d0 --- /dev/null +++ b/packages/vitnode/src/lib/fetcher/contract.ts @@ -0,0 +1,160 @@ +/** + * One endpoint an API plugin serves, with every field that identifies it + * correlated: the plugin, the full module path, the route path, the method, and + * the route definition arguments and responses are inferred from. + */ +export interface ApiEndpoint { + readonly method: string; + readonly module: string; + readonly path: string; + readonly plugin: string; + readonly route: unknown; +} + +/** One route, as much of one as resolving a call reads. */ +export interface ApiRouteNode { + readonly route: { readonly method: string; readonly path: string }; +} + +/** + * A module, as much of one as resolving a call reads: its name, its children and + * its route definitions. + * + * `modules` is `readonly unknown[]` rather than a list of modules, which keeps + * this shallow. Route resolution checks a module against this shape at every + * path segment, and a recursive one would turn each of those checks into a + * structural comparison of a whole subtree. + * + * It is optional because that is how `buildModule` declares it, and matching + * exactly is what lets the registry hold the module tuple a plugin already built + * rather than a rebuilt copy: rebuilding creates a fresh object type per module + * for resolution to compare against, where holding the original reuses types the + * compiler resolved once, reading the plugin's declarations. + * + * Nothing here can reach `hono`, `contentModels`, `events`, `queueTasks`, + * `webSockets`, `searchIndexers` or `messages`: a property is only resolved when + * something asks for it, and nothing below asks. + */ +export interface ApiModuleContract { + readonly modules?: readonly unknown[]; + readonly name: string; + readonly routes: readonly ApiRouteNode[]; +} + +/** + * What every registry entry is, shallowly. + * + * Deliberately not stated in terms of {@link ApiModuleContract}: this is checked + * on the way into route resolution, and a recursive shape would turn one lookup + * into a structural comparison of a plugin's whole module tree. + */ +export interface ApiPluginShape { + readonly endpoints: unknown; + readonly modulePaths: string; + readonly modules: readonly unknown[]; + readonly pluginId: string; +} + +type ChildrenOf = Extract< + M["modules"], + readonly ApiModuleContract[] +>; + +/** + * Whether a module names itself and its routes with literals. + * + * `buildContentAdminModule` builds one module per content type from a runtime + * `permissionModule` string and types its routes as the open `RouteConfig`, so + * its name and paths are `string`. Letting one in would widen `module` and + * `path` to `string` for the whole plugin, and every invalid call would compile. + */ +type IsTypedModule = string extends M["name"] + ? false + : string extends M["routes"][number]["route"]["path"] + ? false + : true; + +/** + * Every module path below one module, built bottom-up. + * + * A subtree is spelled the same way wherever it is mounted, so keying this on + * the module alone lets the compiler compute a subtree once and reuse it. + * Threading a prefix down instead makes every node a distinct instantiation, and + * the whole tree is walked again for each one. + */ +type ModulePathsOfModule = + M extends ApiModuleContract + ? IsTypedModule extends true + ? `${M["name"]}/${ModulePathsOfModules>}` | M["name"] + : never + : never; + +type ModulePathsOfModules = + ModulePathsOfModule; + +type EndpointsOfRoutes< + Plugin extends string, + Module extends string, + Routes extends readonly ApiRouteNode[], +> = Routes[number] extends infer R + ? R extends { route: infer Route extends { method: string; path: string } } + ? { + method: Lowercase; + module: Module; + path: Route["path"]; + plugin: Plugin; + route: Route; + } + : never + : never; + +type EndpointsOfModule< + Plugin extends string, + Prefix extends string, + M extends ApiModuleContract, +> = M extends ApiModuleContract + ? IsTypedModule extends true + ? | EndpointsOfModules> + | EndpointsOfRoutes + : never + : never; + +type EndpointsOfModules< + Plugin extends string, + Prefix extends string, + Modules extends readonly ApiModuleContract[], +> = EndpointsOfModule; + +/** + * The whole type-level surface the fetcher needs from one API plugin. + * + * Every member is computed once, in the plugin's own `config.api.ts`, and the + * registry stores the results - so no consumer walks a plugin's module tree to + * answer a question about a call: + * + * - `modulePaths` is every module path the plugin serves, as one string union. + * Offering completions for `module` is then a single lookup, where deriving it + * meant re-walking the tree. + * - `modules` is the module tuple the plugin already built. A call *resolves* + * through it, one step per path segment against a handful of siblings compared + * on `name` alone - cheaper than filtering a union of every endpoint the + * plugin serves. + * - `endpoints` is every endpoint flattened, for {@link ApiRequest} and for + * anything enumerating an installation's API. Nothing on the path of an + * ordinary call reads it, so it stays unmaterialized until asked for. + * + * None of it reaches a plugin's Hono application, content models, event + * listeners, queue tasks, WebSockets, search indexers or messages. Those are + * runtime concerns, and resolving them is what made typing inside a call slow. + */ +export type ApiPluginContract = T extends { + modules: infer Modules extends readonly ApiModuleContract[]; + pluginId: infer Id extends string; +} + ? { + endpoints: EndpointsOfModules; + modulePaths: ModulePathsOfModules; + modules: Modules; + pluginId: Id; + } + : never; diff --git a/packages/vitnode/src/lib/fetcher/core.ts b/packages/vitnode/src/lib/fetcher/core.ts index f47ac38ba..1e58f4191 100644 --- a/packages/vitnode/src/lib/fetcher/core.ts +++ b/packages/vitnode/src/lib/fetcher/core.ts @@ -1,9 +1,8 @@ import type { - FetcherRequest, + FetcherCall, FetcherRequestOptions, - FetcherResponse, - PluginRouteMethod, RegisteredPluginId, + ResponseFor, } from "./types"; import { rawApiFetch } from "./raw"; @@ -18,20 +17,20 @@ export async function coreFetcher< P extends RegisteredPluginId, M extends string, Path extends string, - Method extends string = PluginRouteMethod, + Method extends string, >({ - plugin, - module, - path, - method, - args, - options, additionalHeaders = {}, - withPagination = false, + args, formData, + method, + module, + options, origin, -}: FetcherRequest & FetcherRequestOptions): Promise< - FetcherResponse + path, + plugin, + withPagination = false, +}: FetcherCall): Promise< + ResponseFor > { const input = args as FetcherInput | undefined; @@ -50,5 +49,5 @@ export async function coreFetcher< withPagination, }); - return response as FetcherResponse; + return response as ResponseFor; } diff --git a/packages/vitnode/src/lib/fetcher/registry.ts b/packages/vitnode/src/lib/fetcher/registry.ts index 04d292c03..ee6cbd397 100644 --- a/packages/vitnode/src/lib/fetcher/registry.ts +++ b/packages/vitnode/src/lib/fetcher/registry.ts @@ -1,5 +1,20 @@ -import type { newBuildPluginApiCore } from "@/api/plugin"; +import type { VitNodeApiPlugin } from "@/api/plugin"; +/** + * The API of every plugin this installation serves, keyed by plugin id. + * + * Core registers itself below. An application adds one entry per configured + * plugin from its generated `src/api-registry.gen.ts`, and that generated file + * is the only place an installed plugin is registered - a package that + * augmented this globally would put its routes in the registry of every + * consumer, configured or not. + * + * Each entry is an {@link ApiPluginContract}: the plugin id, the module tree and + * the route definitions, and nothing else. The Hono application, the content + * models, the listeners, the queues and the search indexers a plugin also + * carries are runtime concerns, and resolving them here would make every + * keystroke in a `fetcher` call pay for them. + */ export interface ApiPluginRegistry { - "@vitnode/core": typeof newBuildPluginApiCore; + "@vitnode/core": VitNodeApiPlugin; } diff --git a/packages/vitnode/src/lib/fetcher/types.ts b/packages/vitnode/src/lib/fetcher/types.ts index 78afe9886..6fc8b67be 100644 --- a/packages/vitnode/src/lib/fetcher/types.ts +++ b/packages/vitnode/src/lib/fetcher/types.ts @@ -2,6 +2,7 @@ import type { ResponseFormat } from "hono/types"; import type { StatusCode, SuccessStatusCode } from "hono/utils/http-status"; import type { z } from "zod"; +import type { ApiEndpoint, ApiModuleContract } from "./contract"; import type { ApiPluginRegistry } from "./registry"; interface ClientResponse< @@ -38,144 +39,52 @@ interface ClientResponse< url: string; } -interface RouteShape { - readonly route: { - readonly method: string; - readonly path: string; - }; -} - -export interface ModuleSpec { - readonly modules?: readonly ModuleSpec[]; - readonly name: string; - readonly routes: readonly RouteShape[]; -} - -interface ApiPluginSpec { - readonly modules: readonly ModuleSpec[]; - readonly pluginId: string; -} - -type ApiPluginFactory = (...args: never[]) => ApiPluginSpec; - -type ApiPluginWithId = Extract; - -type ResolveApiPluginExport< - Entry, - P extends string, -> = Entry extends ApiPluginFactory - ? ApiPluginWithId, P> - : Entry extends ApiPluginSpec - ? ApiPluginWithId - : never; - -type ResolveApiPluginEntry = Entry extends - ApiPluginFactory | ApiPluginSpec - ? ResolveApiPluginExport - : { - [K in keyof Entry]: ResolveApiPluginExport; - }[keyof Entry]; - export type RegisteredPluginId = Extract; -export type RegisteredApiPlugin

= - ResolveApiPluginEntry; - -type RegisteredModules

= - RegisteredApiPlugin

extends { - modules: infer M extends readonly ModuleSpec[]; - } - ? M +/** + * Every endpoint one plugin serves, as one flat union. + * + * A plain lookup: the flattening happened once, in the plugin's own + * `ApiPluginContract`, and the registry stores the result. + */ +/** + * The three things a call reads off a registry entry, each behind its own + * inference so that reading one never resolves the others. + * + * `ApiPluginRegistry` is an open interface, so an indexed access on it cannot be + * proven valid for an entry some augmentation adds - hence the conditional. It + * matches on one member at a time on purpose: a guard stated as the whole + * contract would resolve `endpoints` too, and flattening every endpoint an + * installation serves is work no ordinary call needs. + */ +type ModulePathsOf

= + ApiPluginRegistry[P] extends { modulePaths: infer Paths extends string } + ? Paths : never; -type SubModules = Extract< - M["modules"], - readonly ModuleSpec[] ->; - -type IsTypedModule = string extends M["name"] - ? false - : string extends M["routes"][number]["route"]["path"] - ? false - : true; - -type ModulePathOf = M extends ModuleSpec - ? IsTypedModule extends true - ? `${M["name"]}/${ModulePathOf[number]>}` | M["name"] - : never - : never; - -export type PluginModulePath

= ModulePathOf< - RegisteredModules

[number] ->; - -type ModuleNamed< - Modules extends readonly ModuleSpec[], - Name extends string, -> = Extract; - -type ResolveModule< - Modules extends readonly ModuleSpec[], - Path extends string, -> = Path extends `${infer Head}/${infer Rest}` - ? ResolveModule>, Rest> - : ModuleNamed; - -export type PluginModule< - P extends RegisteredPluginId, - M extends string, -> = ResolveModule, M>; - -type RoutesOf = Mod extends { - routes: infer Routes extends readonly RouteShape[]; +type ModulesOf

= ApiPluginRegistry[P] extends { + modules: infer Modules extends readonly ApiModuleContract[]; } - ? Routes[number] + ? Modules : never; -export type PluginRoutePath< - P extends RegisteredPluginId, - M extends string, -> = RoutesOf>["route"]["path"]; - -export type PluginRouteMethod< - P extends RegisteredPluginId, - M extends string, - Path extends string, -> = Lowercase< - Extract< - Extract< - RoutesOf>, - { route: { path: Path } } - >["route"]["method"], - string - > ->; - -export type PluginRouteConfig< - P extends RegisteredPluginId, - M extends string, - Path extends string, - Method extends string, -> = Extract< - RoutesOf>, - { route: { method: Method; path: Path } } ->["route"]; - -export interface UnknownModulePath { - readonly validModulePaths: Valid; -} - -export interface UnknownRoutePath { - readonly validRoutePaths: Valid; -} +export type PluginEndpoints

= + ApiPluginRegistry[P] extends { endpoints: infer E extends ApiEndpoint } + ? E + : never; -export interface UnknownRouteMethod { - readonly validMethods: Valid; -} +export type AllEndpoints = PluginEndpoints; type ValidModule

= - M extends PluginModulePath

? M : UnknownModulePath>; + M extends PluginModulePath

? M : PluginModulePath

; +/** + * Both checks stop at the first field that is wrong, and report there. + * + * A module nobody serves has no paths and no methods, so validating those too + * would answer `never` and put three errors on one mistake - burying the field + * that actually needs fixing. + */ type ValidPath< P extends RegisteredPluginId, M extends string, @@ -184,7 +93,7 @@ type ValidPath< M extends PluginModulePath

? Path extends PluginRoutePath ? Path - : UnknownRoutePath> + : PluginRoutePath : Path; type ValidMethod< @@ -197,10 +106,72 @@ type ValidMethod< ? Path extends PluginRoutePath ? Method extends PluginRouteMethod ? Method - : UnknownRouteMethod> + : PluginRouteMethod : Method : Method; +/** + * The route half of a call. + * + * Each field is its own type parameter intersected with what the registry says + * is valid there. Two things depend on that shape and would break without it: + * + * - The intersection with the bare parameter is what keeps the literal. A field + * typed as the conditional alone makes the compiler compute a base constraint + * it cannot finish on a cold checker, and the literal widens to `string` - + * after which inference falls back to the union of every route and nothing is + * checked at all. + * - Inferring the four fields separately, rather than the request object as a + * whole, is what leaves `args` typed by the route instead of by whatever the + * caller wrote. Excess property checking only fires against a parameter's own + * declared property type, so this is what rejects a key the Zod schema does + * not declare. + */ +export interface FetcherRoute< + P extends RegisteredPluginId, + M extends string, + Path extends string, + Method extends string, +> { + method: Method & ValidMethod; + module: M & ValidModule; + path: Path & ValidPath; + plugin: P; +} + +type ModuleNamed< + Modules extends readonly ApiModuleContract[], + Name extends string, +> = Extract; + +type ChildrenOf = Extract< + M["modules"], + readonly ApiModuleContract[] +>; + +/** + * The module a path names, resolved a segment at a time. + * + * Every step compares against one module's own children on the `name` property + * alone - a handful of siblings, one property each - so `admin/advanced/cron` + * costs three small comparisons. Filtering a flat union of every endpoint the + * plugin serves costs one comparison per endpoint instead, on every call. + * + * Indexed access rather than `infer`: both answer the same type, and the extra + * conditional an `infer` needs is paid at every segment of every call. + */ +type ResolveModule< + Modules extends readonly ApiModuleContract[], + Path extends string, +> = Path extends `${infer Head}/${infer Rest}` + ? ResolveModule>, Rest> + : ModuleNamed; + +type RoutesOfModule< + P extends RegisteredPluginId, + M extends string, +> = ResolveModule, M>["routes"][number]; + type ExtractZodType = T extends z.ZodType ? z.infer : never; type InferInputType< @@ -237,11 +208,9 @@ export type BuildArgsType = { ]: InferInputType; }; -export type FetcherArgs = [RouteCfg] extends [never] - ? { args?: unknown } - : keyof BuildArgsType extends never - ? { args?: undefined } - : { args: BuildArgsType }; +export type FetcherArgs = keyof BuildArgsType extends never + ? { args?: undefined } + : { args: BuildArgsType }; type InferStatusCode = K extends `${infer N extends number}` ? N @@ -249,32 +218,80 @@ type InferStatusCode = K extends `${infer N extends number}` ? K : never; -export interface FetcherRoute< +export type InferResponseType = RouteCfg extends { + responses: infer S; +} + ? { + [K in keyof S]: S[K] extends infer Response + ? Response extends { content: infer C } + ? { + [Fmt in keyof C]: ClientResponse< + C[Fmt] extends { schema: infer Schema } + ? ExtractZodType + : never, + InferStatusCode, + Fmt extends string ? Fmt : string + >; + }[keyof C] + : ClientResponse> + : never; + }[keyof S] + : never; + +/** + * One endpoint as the four fields that identify it. + * + * Deliberately without `args`: this union holds every endpoint the installation + * serves, and inferring each one's Zod body, params and query here would make + * every call site pay for the arguments of every *other* route. A call's own + * arguments are resolved once, from the route it selected - see + * {@link FetcherCall}. + */ +type RequestForEndpoint = E extends ApiEndpoint + ? { + method: E["method"]; + module: E["module"]; + path: E["path"]; + plugin: E["plugin"]; + } + : never; + +/** + * Every call the configured plugins accept, as one discriminated union. + * + * `plugin`, `module`, `path` and `method` are literal in every member, so the + * compiler picks the endpoint a caller wrote rather than testing members one by + * one - and reports a wrong `method` or `args` against that endpoint instead of + * against the whole union. It is also what a completion inside a call reads: + * one indexed access into a union the compiler has already built, where + * enumerating a plugin's module paths used to re-walk its module tree. + */ +export type ApiRequest = RequestForEndpoint; + +/** The route one call selected, resolved through its plugin's module tree. */ +export type RouteOf< P extends RegisteredPluginId, M extends string, Path extends string, Method extends string, -> { - method: Method & ValidMethod; - module: M & ValidModule; - path: Path & ValidPath; - plugin: P; -} +> = Extract< + RoutesOfModule, + { route: { method: Method; path: Path } } +>["route"]; -export type FetcherRequest< +export type ResponseFor< P extends RegisteredPluginId, M extends string, Path extends string, - Method extends string = PluginRouteMethod, -> = FetcherArgs> & - FetcherRoute; + Method extends string, +> = InferResponseType>; /** * Everything a fetcher takes that is *not* the route. * * Shared so `coreFetcher`, `fetcher` and `fetcherClient` describe one transport * rather than three that drift, and so the route half of every call stays - * exactly {@link FetcherRequest} - which is what makes a missing `args` an error. + * exactly {@link ApiRequest} - which is what makes a missing `args` an error. */ export interface FetcherRequestOptions { additionalHeaders?: HeadersInit; @@ -299,29 +316,44 @@ export interface FetcherRequestOptions { withPagination?: boolean; } -export type InferResponseType = RouteCfg extends { - responses: infer S; -} - ? { - [K in keyof S]: S[K] extends infer Response - ? Response extends { content: infer C } - ? { - [Fmt in keyof C]: ClientResponse< - C[Fmt] extends { schema: infer Schema } - ? ExtractZodType - : never, - InferStatusCode, - Fmt extends string ? Fmt : string - >; - }[keyof C] - : ClientResponse> - : never; - }[keyof S] - : never; +/** + * One call: the endpoint, its arguments, and whatever the transport accepts. + * + * The arguments are typed by the route the four literal fields selected, and by + * nothing else - which is what makes a key the Zod schema does not declare an + * error, and what keeps `allowSaveCookies` off a call a browser might make. + */ +export type FetcherCall< + P extends RegisteredPluginId, + M extends string, + Path extends string, + Method extends string, + Options, +> = FetcherArgs> & + FetcherRoute & + Options; -export type FetcherResponse< +/** Every module path one plugin serves. */ +/** + * Every module path one plugin serves. + * + * One indexed access into a union the plugin already computed. This is what a + * completion for `module` reads, and deriving it per call site by walking the + * module tree is what made typing one slow. + */ +export type PluginModulePath

= ModulePathsOf

; + +/** Every route path one module serves. */ +export type PluginRoutePath< + P extends RegisteredPluginId, + M extends string, +> = RoutesOfModule["route"]["path"]; + +/** Every method one route answers. */ +export type PluginRouteMethod< P extends RegisteredPluginId, M extends string, Path extends string, - Method extends string = PluginRouteMethod, -> = InferResponseType>; +> = Lowercase< + Extract, { route: { path: Path } }>["route"]["method"] +>; diff --git a/packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts b/packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts index 15868ce09..2ff398604 100644 --- a/packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts +++ b/packages/vitnode/src/tanstack/fetcher/fetcher.test-d.ts @@ -1,6 +1,8 @@ import { z } from "@hono/zod-openapi"; import { describe, expectTypeOf, it } from "vitest"; +import type { ApiPluginContract } from "@/lib/fetcher/contract"; + import { buildModule } from "@/api/lib/module"; import { buildApiPlugin } from "@/api/lib/plugin"; import { buildRoute } from "@/api/lib/route"; @@ -110,9 +112,11 @@ const notesApiPlugin = () => ], }); +type NotesApiPlugin = ApiPluginContract>; + declare module "../../lib/fetcher/registry" { interface ApiPluginRegistry { - "@acme/notes": typeof notesApiPlugin; + "@acme/notes": NotesApiPlugin; } } @@ -127,6 +131,37 @@ describe("the plugin API keeps its literal shape", () => { }); }); +describe("the contract carries the API surface and nothing else", () => { + it("keeps the literal plugin id", () => { + expectTypeOf().toEqualTypeOf<"@acme/notes">(); + }); + + it("flattens every module path, at every depth", () => { + expectTypeOf().toEqualTypeOf< + "content" | "content/posts" | "notes" | "notes/tags" | "notes/tags/colors" + >(); + }); + + it("correlates plugin, module, path and method on every endpoint", () => { + type Colors = Extract< + NotesApiPlugin["endpoints"], + { module: "notes/tags/colors" } + >; + + expectTypeOf().toEqualTypeOf<"@acme/notes">(); + expectTypeOf().toEqualTypeOf<"/">(); + expectTypeOf().toEqualTypeOf<"get">(); + }); + + it("exposes no runtime member of the plugin it was built from", () => { + type Members = keyof NotesApiPlugin; + + expectTypeOf().toEqualTypeOf< + "endpoints" | "modulePaths" | "modules" | "pluginId" + >(); + }); +}); + describe("the universal fetcher resolves a route from the plugin id", () => { it("infers the status and the body of a top-level core module", async () => { const response = await fetcher({ @@ -245,6 +280,19 @@ describe("the universal fetcher rejects what the registry does not describe", () }); }); + it("reports the field that is wrong, and only that field", async () => { + // A module nobody serves has no paths and no methods either, so the two + // fields that depend on it stay the caller's own rather than becoming + // `never` and burying the one mistake under three errors. + await fetcher({ + plugin: "@acme/notes", + method: "get", + // @ts-expect-error -- not a module of `@acme/notes` + module: "does-not-exist", + path: "/anything", + }); + }); + it("rejects a path the module does not serve", async () => { await fetcher({ plugin: "@acme/notes", @@ -258,12 +306,19 @@ describe("the universal fetcher rejects what the registry does not describe", () it("rejects a method the route does not answer", async () => { await fetcher({ plugin: "@acme/notes", - args: { query: {} }, // @ts-expect-error -- the public list is a `get` method: "delete", module: "content/posts", path: "/", }); + + await fetcher({ + plugin: "@acme/notes", + // @ts-expect-error -- `/{id}/pin` is a `post` + method: "get", + module: "notes", + path: "/{id}/pin", + }); }); it("rejects a call that omits required arguments", async () => { diff --git a/packages/vitnode/src/tanstack/fetcher/fetcher.test.ts b/packages/vitnode/src/tanstack/fetcher/fetcher.test.ts index 1ad0c1914..d062db122 100644 --- a/packages/vitnode/src/tanstack/fetcher/fetcher.test.ts +++ b/packages/vitnode/src/tanstack/fetcher/fetcher.test.ts @@ -276,9 +276,20 @@ describe("the browser transport is browser-shaped on its own", () => { it("reaches the core API plugin through the registry as a type and nothing else", () => { const registry = stripComments(readFileSync(REGISTRY, "utf8")); + // The already-reduced contract, not the plugin object: the registry's job + // is to name an API surface, not to resolve one. expect(registry).toMatch( - /import type \{ newBuildPluginApiCore \} from "@\/api\/plugin";/, + /import type \{ VitNodeApiPlugin \} from "@\/api\/plugin";/, ); expect(registry).not.toMatch(/^import \{/m); }); + + it("registers core and leaves every other plugin to the application", () => { + const registry = stripComments(readFileSync(REGISTRY, "utf8")); + const entries = [...registry.matchAll(/^\s*"([^"]+)":/gm)].map( + match => match[1], + ); + + expect(entries).toEqual(["@vitnode/core"]); + }); }); diff --git a/packages/vitnode/src/tanstack/fetcher/index.ts b/packages/vitnode/src/tanstack/fetcher/index.ts index 7d0fad759..17d3a31b3 100644 --- a/packages/vitnode/src/tanstack/fetcher/index.ts +++ b/packages/vitnode/src/tanstack/fetcher/index.ts @@ -14,14 +14,17 @@ import { } from "./server"; export type { FetcherClientOptions, UniversalFetcher, UniversalRawFetcher }; +export type { ApiEndpoint, ApiPluginContract } from "@/lib/fetcher/contract"; export type { ApiPluginRegistry } from "@/lib/fetcher/registry"; export type { - FetcherRequest, - FetcherResponse, + AllEndpoints, + ApiRequest, + PluginEndpoints, PluginModulePath, PluginRouteMethod, PluginRoutePath, RegisteredPluginId, + ResponseFor, } from "@/lib/fetcher/types"; type IsomorphicFetcher = (request: never) => Promise; diff --git a/packages/vitnode/src/tanstack/fetcher/server.ts b/packages/vitnode/src/tanstack/fetcher/server.ts index 67334ecfe..b6652eeb7 100644 --- a/packages/vitnode/src/tanstack/fetcher/server.ts +++ b/packages/vitnode/src/tanstack/fetcher/server.ts @@ -9,11 +9,10 @@ import { config } from "dotenv"; import type { RawApiFetchArgs } from "@/lib/fetcher/raw"; import type { - FetcherRequest, + FetcherCall, FetcherRequestOptions, - FetcherResponse, - PluginRouteMethod, RegisteredPluginId, + ResponseFor, } from "@/lib/fetcher/types"; import { CONFIG } from "@/lib/config"; @@ -84,31 +83,31 @@ export async function fetcher< P extends RegisteredPluginId, M extends string, Path extends string, - Method extends string = PluginRouteMethod, + Method extends string, >({ - plugin, - module, - path, - method, - args, - options, - formData, additionalHeaders, allowSaveCookies = false, + args, captchaToken, + formData, + method, + module, + options, origin, + path, + plugin, withPagination = false, -}: FetcherRequest & FetcherServerOptions): Promise< - FetcherResponse +}: FetcherCall): Promise< + ResponseFor > { const response = await coreFetcher({ - plugin, - module, - path, - method, args, - options, formData, + method, + module, + options, + path, + plugin, withPagination, additionalHeaders: { ...getForwardedApiHeaders({ captchaToken }), @@ -118,7 +117,7 @@ export async function fetcher< // request's own origin otherwise - and an explicit `origin` on the call // overrides both. origin: origin ?? resolveApiOrigin(), - } as FetcherRequest & FetcherRequestOptions); + } as FetcherCall); if (allowSaveCookies && shouldSaveApiCookies((response as Response).status)) { saveApiCookies(response); diff --git a/plugins/blog/global.d.ts b/plugins/blog/global.d.ts index 4786c0398..c74dd53ee 100644 --- a/plugins/blog/global.d.ts +++ b/plugins/blog/global.d.ts @@ -3,18 +3,8 @@ import core from "@vitnode/core/locales/en.json" with { type: "json" }; import plugin from "./src/locales/en.json" with { type: "json" }; -import type { blogApiPlugin } from "./src/config.api"; - declare module "use-intl" { interface AppConfig { Messages: typeof plugin & typeof core; } } - -declare module "@vitnode/core/lib/fetcher/registry" { - interface ApiPluginRegistry { - "@vitnode/blog": typeof blogApiPlugin; - } -} - -export type { ApiPluginRegistry } from "@vitnode/core/lib/fetcher/registry"; diff --git a/plugins/blog/package.json b/plugins/blog/package.json index 8898fd50a..fb9d3abfa 100644 --- a/plugins/blog/package.json +++ b/plugins/blog/package.json @@ -33,8 +33,7 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "vitest run", - "test:watch": "vitest", - "test:types": "vitest run --typecheck.only" + "test:watch": "vitest" }, "dependencies": { "@hono/zod-openapi": "^1.6.3", diff --git a/plugins/blog/src/config.api.test-d.ts b/plugins/blog/src/config.api.test-d.ts new file mode 100644 index 000000000..8c7781134 --- /dev/null +++ b/plugins/blog/src/config.api.test-d.ts @@ -0,0 +1,114 @@ +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { describe, expectTypeOf, it } from "vitest"; + +import type { VitNodeApiPlugin } from "./config.api"; + +import { blogApiPlugin } from "./config.api"; + +describe("the blog API plugin keeps its literal shape", () => { + it("retains the plugin id and both registered modules", () => { + const plugin = blogApiPlugin(); + + expectTypeOf(plugin.pluginId).toEqualTypeOf<"@vitnode/blog">(); + expectTypeOf(plugin.modules.length).toEqualTypeOf<2>(); + expectTypeOf(plugin.modules[0].name).toEqualTypeOf<"admin">(); + expectTypeOf(plugin.modules[1].name).toEqualTypeOf<"content">(); + }); +}); + +describe("the contract reduces that plugin to its API surface", () => { + it("keeps the literal plugin id", () => { + expectTypeOf< + VitNodeApiPlugin["pluginId"] + >().toEqualTypeOf<"@vitnode/blog">(); + }); + + it("lists the module paths a call may name", () => { + // `admin/content` and its per-content-type children are absent on purpose: + // `buildContentAdminModule` names them from a runtime string, so they carry + // no literal type and admitting them would widen `module` to `string`. + expectTypeOf().toEqualTypeOf< + "admin" | "content" | "content/blog" + >(); + }); + + it("carries no runtime member of the plugin", () => { + expectTypeOf().toEqualTypeOf< + "endpoints" | "modulePaths" | "modules" | "pluginId" + >(); + }); +}); + +describe("the universal fetcher reaches the blog's public content routes", () => { + it("lists posts through the generated content module", async () => { + const response = await fetcher({ + plugin: "@vitnode/blog", + args: { query: { first: "10" } }, + method: "get", + module: "content/blog", + path: "/", + }); + + expectTypeOf(response.status).toEqualTypeOf<200 | 400>(); + + if (response.status === 200) { + expectTypeOf( + (await response.json()).pageInfo.hasNextPage, + ).toEqualTypeOf(); + } + }); + + it("reads one post by its slug", async () => { + const response = await fetcher({ + plugin: "@vitnode/blog", + args: { params: { slug: "hello-world" } }, + method: "get", + module: "content/blog", + path: "/{slug}", + }); + + expectTypeOf(response.status).toEqualTypeOf<200 | 404>(); + }); + + it("offers no public module for a content type without a public API", async () => { + await fetcher({ + plugin: "@vitnode/blog", + method: "get", + // @ts-expect-error -- categories declare no `publicApi` + module: "content/categories", + path: "/", + }); + }); + + it("rejects a slug read that omits its parameter", async () => { + // @ts-expect-error -- `/{slug}` declares params + await fetcher({ + plugin: "@vitnode/blog", + method: "get", + module: "content/blog", + path: "/{slug}", + }); + }); + + it("rejects a write on the read-only public API", async () => { + await fetcher({ + plugin: "@vitnode/blog", + // @ts-expect-error -- the public list is a `get` + method: "post", + module: "content/blog", + path: "/", + }); + }); + + it("rejects the cookie relay, which only a server can honour", async () => { + await fetcher({ + plugin: "@vitnode/blog", + // @ts-expect-error -- `allowSaveCookies` is on `tanstack/fetcher/server` + allowSaveCookies: true, + args: { query: { first: "10" } }, + method: "get", + module: "content/blog", + path: "/", + }); + }); +}); diff --git a/plugins/blog/src/config.api.ts b/plugins/blog/src/config.api.ts index b0a60f669..5e5404c0e 100644 --- a/plugins/blog/src/config.api.ts +++ b/plugins/blog/src/config.api.ts @@ -1,3 +1,5 @@ +import type { ApiPluginContract } from "@vitnode/core/api/lib/plugin"; + import { buildApiPlugin } from "@vitnode/core/api/lib/plugin"; import { buildContentPublicModule } from "@vitnode/core/content/server"; @@ -19,3 +21,7 @@ export const blogApiPlugin = () => }), ], }); + +export type VitNodeApiPlugin = ApiPluginContract< + ReturnType +>; diff --git a/plugins/example/global.d.ts b/plugins/example/global.d.ts index 92b0e5a14..c74dd53ee 100644 --- a/plugins/example/global.d.ts +++ b/plugins/example/global.d.ts @@ -3,18 +3,8 @@ import core from "@vitnode/core/locales/en.json" with { type: "json" }; import plugin from "./src/locales/en.json" with { type: "json" }; -import type { exampleApiPlugin } from "./src/config.api"; - declare module "use-intl" { interface AppConfig { Messages: typeof plugin & typeof core; } } - -declare module "@vitnode/core/lib/fetcher/registry" { - interface ApiPluginRegistry { - "@vitnode/example": typeof exampleApiPlugin; - } -} - -export type { ApiPluginRegistry } from "@vitnode/core/lib/fetcher/registry"; diff --git a/plugins/example/src/config.api.ts b/plugins/example/src/config.api.ts index d45f0f405..a359f553f 100644 --- a/plugins/example/src/config.api.ts +++ b/plugins/example/src/config.api.ts @@ -1,3 +1,5 @@ +import type { ApiPluginContract } from "@vitnode/core/api/lib/plugin"; + import { buildApiPlugin } from "@vitnode/core/api/lib/plugin"; import { buildContentPublicModule } from "@vitnode/core/content/server"; @@ -25,3 +27,7 @@ export const exampleApiPlugin = () => }), ], }); + +export type VitNodeApiPlugin = ApiPluginContract< + ReturnType +>;