TypeScript RPC Client
Controllers implemented with Vovk.ts compile to RPC modules that share the same structure but have different argument signatures. For example, given the controller below:
import { z } from 'zod';
import { withZod } from 'vovk-zod';
import { prefix, post, operation } from 'vovk';
@prefix('users')
export default class UserController {
@operation({
summary: 'Update user (Zod)',
description: 'Update user by ID with Zod validation',
})
@post('{id}')
static updateUser = withZod({
body: z
.object({
name: z.string().meta({ description: 'User full name' }),
age: z.number().min(0).max(120).meta({ description: 'User age' }),
email: z.email().meta({ description: 'User email' }),
})
.meta({ description: 'User object' }),
params: z.object({
id: z.uuid().meta({ description: 'User ID' }),
}),
query: z.object({
notify: z.enum(['email', 'push', 'none']).meta({ description: 'Notification type' }),
}),
output: z
.object({
success: z.boolean().meta({ description: 'Success status' }),
})
.meta({ description: 'Response object' }),
async handle(req, { id }) {
const { name, age } = await req.json();
const notify = req.nextUrl.searchParams.get('notify');
// do something with the data
console.log(`Updating user ${id}:`, { name, age, notify });
return {
success: true,
};
},
});
}It compiles to the following RPC method, which accepts body, params, and query as a three-part input.
import { UserRPC } from 'vovk-client';
const updatedUser = await UserRPC.updateUser({
body: { name: 'John Doe', age: 30, email: 'john@example.com' },
params: { id: '69' },
query: { notify: 'push' },
});Behind the scenes, updateUser performs client-side validation, serializes query and params into the URL, and issues a standard fetch request. The server handles it in UserController.updateUser. The RPC method returns a promise that resolves to the return type used in the controller method.
const resp = await fetch(`/api/users/${id}?notify=push`, {
method: 'POST',
body: JSON.stringify({
/* ... */
}),
});
const updatedUser = await resp.json();RPC Method Options
In addition to body, params, and query, every RPC method accepts a set of options. This list can be extended via a custom fetcher.
apiRoot
Overrides the default API root path. The default is /api and can also be configured via rootEntry and/or origin.
init
Lets you pass RequestInit options (the fetch options) such as headers and credentials, as well as Next.js-specific options like next: { revalidate: number }.
const user = await UserRPC.updateUser({
body: {
/* ... */
},
params: {
/* ... */
},
query: {
/* ... */
},
init: {
headers: {
'X-Custom-Header': 'value',
},
credentials: 'include',
next: { revalidate: 60 },
},
});transform
Allows you to post-process the result. Provide a function that receives the parsed response data and the original Response and returns a transformed value.
const user = await UserRPC.updateUser({
body: {
/* ... */
},
params: {
/* ... */
},
query: {
/* ... */
},
transform: (data, response) => {
// Modify the response here
return value;
},
});You can also return the Response alongside the data:
const [user, response] = await UserRPC.updateUser({
body: {
/* ... */
},
params: {
/* ... */
},
query: {
/* ... */
},
transform: (data, response) => [data, response],
});
response satisfies Response;disableClientValidation
Turns off client-side validation for this call. Useful when debugging to surface server-side validation errors instead. See the validation page for details.
await UserRPC.updateUser({
// ...
disableClientValidation: true,
});interpretAs
Overrides how the response content type is interpreted. Useful, for example, when the server returns JSON Lines but does not set content-type to application/jsonl.
const user = await UserRPC.updateUser({
body: {
/* ... */
},
params: {
/* ... */
},
query: {
/* ... */
},
interpretAs: 'application/jsonl',
});validateOnClient
Overrides the validateOnClient setting from imports.
Customization
You can customize the client’s fetch function and its types to match your app’s needs. See the fetcher customization docs for details.
await UserRPC.updateUser({
// ...
successMessage: 'Successfully updated the user',
someOtherCustomFlag: true,
});Type Override
If type inference cannot determine the return type, you can specify it explicitly—no need to cast to unknown first.
import { UserRPC } from 'vovk-client';
import type { SomeType } from '../types';
// ...
// Override the return type
const updatedUser = await UserRPC.updateUser<SomeType>(/* ... */);Async Iterable
import { get } from 'vovk';
export default class UserController {
@get()
static async *doSomething(/* ... */) {
yield* iterable;
}
}If the handler returns an async iterable, the client casts the method to a disposable async iterator to enable JSON Lines streaming.
import { StreamRPC } from 'vovk-client';
using stream = await StreamRPC.getJSONLines();
for await (const { message } of stream) {
console.log('Received message:', message);
}Access to Schema
Every RPC method exposes the emitted JSON schema through the following properties:
schema- the schema for this method of typeVovkHandlerSchema;controllerSchema- the schema object of the method’s controller of typeVovkControllerSchema;segmentSchema- the schema object of the segment of typeVovkSegmentSchema;fullSchema- the full schema object of typeVovkSchemathat includes all available segments as well as emitted config (by default,"libs"and"outputConfig"options only; see config documentation).
console.log(UserRPC.updateUser.schema.validation.body); // get body validation JSON schema
console.log(UserRPC.updateUser.schema.operationObject); // get OpenAPI operationObject spec for this method
console.log(UserRPC.updateUser.fullSchema.meta.config.libs.ajv); // get config optionThis design also allows you to create LLM tools that can use the schema to define the tool parameters.
React Query
Every RPC method exposes a queryKey utility that returns a globally unique key for use with @tanstack/react-query . It is an array: [segmentName, controllerPrefix, rpcModuleName, decoratorPath, httpMethod, ...key], where ...key is an optional array of extra values you provide to differentiate similar queries.
import { useQuery } from '@tanstack/react-query';
import { UserRPC } from 'vovk-client';
const MyComponent = () => {
const query = useQuery({
queryKey: UserRPC.getUser.queryKey(['123']),
queryFn: () =>
UserRPC.getUser({
params: { id: '123' },
}),
});
return <div>{query.isLoading ? 'Loading...' : JSON.stringify(query.data)}</div>;
};You can use the key for cache invalidation, refetching, and other React Query features.
queryClient.invalidateQueries({
queryKey: UserRPC.getUser.queryKey().slice(0, 3), // Invalidate all queries for the `UserRPC` module
});Streamed responses can utilize streamedQuery, which lets you consume JSON Lines as an array.
import { useQuery, experimental_streamedQuery as streamedQuery } from '@tanstack/react-query';
import { JSONLinesRPC } from 'vovk-client';
const JSONLinesComponent = () => {
const query = useQuery({
queryKey: JSONLinesRPC.streamTokens.queryKey(),
queryFn: streamedQuery({
streamFn: () => JSONLinesRPC.streamTokens(),
}),
});
return (
<div>
Stream result: {query.data?.map(({ message }, i) => <span key={i}>{message}</span>) ?? <em>Loading...</em>}
</div>
);
};Mutations work with RPC module methods as expected.
import { useMutation } from '@tanstack/react-query';
import { UserRPC } from 'vovk-client';
const MyComponent = () => {
const mutation = useMutation({
mutationFn: UserRPC.updateUser,
});
return (
<div>
<button
onClick={() =>
mutation.mutate({
body: { name: 'John Doe', age: 30 },
params: { id: '123' },
})
}
>
Update User
</button>
{mutation.isLoading ? 'Loading...' : JSON.stringify(mutation.data)}
</div>
);
};openapi and schema
The generated client also exposes openapi and schema exports for accessing the OpenAPI spec and the Vovk Schema, respectively.
import { openapi, schema } from 'vovk-client';You can also import them from module paths that exclude RPC modules:
import { openapi } from 'vovk-client/openapi';
import { schema } from 'vovk-client/schema';This also works with the segmented client; in that case, both openapi and schema include data only for the selected segment:
import { openapi } from '@/client/admin/openapi';
import { schema } from '@/client/admin/schema';Used Templates
The TypeScript RPC client is generated from the following templates:
- cjs, mjs (
requiresschemaCjs and openapiCjs) - compiled CJS and ESM modules with TypeScript definitions; used by default in the composed client; - ts (
requiresschemaTs and openapiTs) - uncompiled TypeScript module with type definitions; used by default in the segmented client; - mixins -
.d.tstypes and.jsonfiles generated when OpenAPI Mixins are used; - readme, packageJson -
README.mdwith RPC documentation andpackage.jsonsuitable for publishing the generated library as an NPM package.
For more information, see the client templates documentation.