TypeScript RPC Client
The client uses only fetch and web streams, so it runs in Node.js, Bun, Deno, browsers and Cloudflare Workers.
A controller, with its procedures as static methods, compiles to an RPC module. The module has the same structure, but its methods take different arguments. For example, take this controller:
import { z } from 'zod';
import { procedure, 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 = procedure({
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' }),
}).handle(async (req, { id }) => {
const { name, age } = await req.vovk.body();
const { notify } = req.vovk.query();
// do something with the data
console.log(`Updating user ${id}:`, { name, age, notify });
return {
success: true,
};
});
}It compiles to an RPC module with an updateUser method. The method takes body, params and query in one object:
import { UserRPC } from '@/client';
const updatedUser = await UserRPC.updateUser({
body: { name: 'John Doe', age: 30, email: 'john@example.com' },
params: { id: '123e4567-e89b-12d3-a456-426614174000' },
query: { notify: 'push' },
});updateUser validates the input on the client, puts query and params into the URL, and sends a standard fetch request, which UserController.updateUser handles on the server. The RPC method returns a promise of the procedure’s return type. The result is the handler’s value after JSON, so a handler returns JSON data: a Date arrives as a string. .fn() returns the value as is. In plain fetch, the call looks like this:
const resp = await fetch(`/api/users/${id}?notify=push`, {
method: 'POST',
body: JSON.stringify({
/* ... */
}),
});
const updatedUser = await resp.json();An internal function, createRPC, creates the RPC module. It uses the default imports, or the imports set in the vovk.config file. See imports customization for details.
import type { VovkFetcher } from "vovk/fetcher";
import { createRPC } from "vovk/create-rpc";
import { schema } from "./schema";
import type { Controllers as Controllers0 } from "../app/api/[[...vovk]]/route.ts";
// The arguments are: schema, segmentName, rpcModuleName, fetcher and options
export const UserRPC = createRPC<
Controllers0["UserRPC"],
typeof import("vovk/fetcher").fetcher extends VovkFetcher<infer U> ? U : never
>(schema, "", "UserRPC", import("vovk/fetcher"), {
validateOnClient: import("vovk-ajv"),
});The generated client imports vovk subpaths, such as vovk/fetcher, and JSON files with import attributes. It compiles with "moduleResolution" set to "bundler" (the Next.js default), "node16" or "nodenext". With "node", TypeScript can’t find vovk/fetcher. The client alone compiles on TypeScript 5.3+, but a service or a handler that takes its types from its own procedure, as in these docs and the files vovk new writes, needs TypeScript 5.5+.
Under "node16" or "nodenext", the client imports its own files by their .js names, such as ./schema.js (by their .ts names when allowImportingTsExtensions is on). Turbopack resolves a .js name to the .ts file, but next build --webpack doesn’t, unless next.config sets experimental: { extensionAlias: { '.js': ['.ts', '.tsx', '.js'] } }.
RPC Method Options
Besides body, params and query, every RPC method accepts the options below. You can extend the list with a custom fetcher.
apiRoot
Overrides the API root. The default is /api. You can also configure it with rootEntry, origin or both.
init
Passes RequestInit options (the fetch options), such as headers and credentials, and Next.js-specific options such as next: { revalidate: number }.
const user = await UserRPC.updateUser({
body: {
/* ... */
},
params: {
/* ... */
},
query: {
/* ... */
},
init: {
headers: {
'X-Custom-Header': 'value',
},
credentials: 'include',
next: { revalidate: 60 },
},
});transform
Post-processes the result. The function receives the parsed response data and the original Response, and returns a new value.
const user = await UserRPC.updateUser({
body: {
/* ... */
},
params: {
/* ... */
},
query: {
/* ... */
},
transform: (data, response) => {
// Modify the response here
return value;
},
});It can also return the Response together with the data:
const [user, response] = await UserRPC.updateUser({
body: {
/* ... */
},
params: {
/* ... */
},
query: {
/* ... */
},
transform: (data, response) => [data, response] as const,
});
response satisfies Response;disableClientValidation
Turns off client-side validation for this call. Use it when debugging, to see the server’s validation errors instead.
await UserRPC.updateUser({
// ...
disableClientValidation: true,
});interpretAs
Sets the content type that the client uses to read the response. For example, use it when the server returns JSON Lines without setting content-type to application/jsonl.
const user = await UserRPC.updateUser({
body: {
/* ... */
},
params: {
/* ... */
},
query: {
/* ... */
},
interpretAs: 'application/jsonl',
});validateOnClient
Overrides the validateOnClient setting from imports. It takes the function or a module that exports it, such as import('vovk-ajv').
fetcher
Overrides the fetcher for this call, for example one made with createFetcher.
withDefaults
The withDefaults method returns a new RPC module with default options for every call. The defaults are deep-merged with each call’s options. Headers merge by name, ignoring case, whether a layer gives them as an object, a Headers instance or entries. A header given in the call replaces a default one.
import { UserRPC } from '@/client';
const WithDefaultsUserRPC = UserRPC.withDefaults({
apiRoot: 'https://api.example.com/v1',
init: {
headers: {
'x-hello': 'world',
},
},
});
const user = await WithDefaultsUserRPC.updateUser({
// ...
});Customization
You can change the client’s fetch function and its option types. See the fetcher customization docs.
await UserRPC.updateUser({
// ...
successMessage: 'Successfully updated the user',
someOtherCustomFlag: true,
});Type Override
If type inference can’t find the return type, pass it as a type argument. You don’t need to cast to unknown first.
import { UserRPC } from '@/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 RPC method resolves to a disposable async iterator that reads the JSON Lines stream.
import { StreamRPC } from '@/client';
using stream = await StreamRPC.getJSONLines();
for await (const { message } of stream) {
console.log('Received message:', message);
}Access to Schema
Every RPC method has the emitted schema in these properties:
schema- the method’s schema;controllerSchema- the schema of the method’s controller;segmentSchema- the schema of the segment;fullSchema- the full schema, of typeVovkSchema, with all segments and the emitted config (by default, only thelibsandrootEntryoptions; see config documentation).
For the type of one of them, use typeof, such as typeof UserRPC.updateUser.segmentSchema.
console.log(UserRPC.updateUser.schema.validation?.body); // the JSON schema of the body
console.log(UserRPC.updateUser.schema.operationObject); // the OpenAPI operation object of this method
console.log(UserRPC.updateUser.fullSchema.meta?.config.libs?.ajv); // a config optionLLM tools can use this schema to define their parameters.
getURL method
Every RPC method has a type-safe getURL function. It returns the method’s URL, with params and query in it.
import { UserRPC } from '@/client';
const url = UserRPC.updateUser.getURL({
params: { id: '69' },
query: { notify: 'push' },
apiRoot: 'https://api.example.com/v1', // optional
});
console.log(url); // "https://api.example.com/v1/users/69?notify=push"Use it to call fetch directly:
const response = await fetch(
UserRPC.updateUser.getURL({
/* ... */
}),
{
method: 'POST',
// ... other fetch options
}
);React Query
Every RPC method has a queryKey function. It returns a globally unique key for @tanstack/react-query . The key is an array: [segmentName, controllerPrefix, rpcModuleName, decoratorPath, httpMethod, ...key]. ...key holds optional extra values that tell similar queries apart.
import { useQuery } from '@tanstack/react-query';
import { UserRPC } from '@/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>;
};View live example on examples.vovk.dev »
Use the key to invalidate the cache, to refetch, and with other React Query features.
queryClient.invalidateQueries({
queryKey: UserRPC.getUser.queryKey().slice(0, 3), // Invalidate all queries for the `UserRPC` module
});For streamed responses, streamedQuery reads JSON Lines as an array.
import { useQuery, experimental_streamedQuery as streamedQuery } from '@tanstack/react-query';
import { JSONLinesRPC } from '@/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>
);
};RPC methods also work as mutation functions.
import { useMutation } from '@tanstack/react-query';
import { UserRPC } from '@/client';
const MyComponent = () => {
const mutation = useMutation({
mutationFn: UserRPC.updateUser,
});
return (
<div>
<button
onClick={() =>
mutation.mutate({
body: { name: 'John Doe', age: 30, email: 'john@example.com' },
params: { id: '123e4567-e89b-12d3-a456-426614174000' },
query: { notify: 'push' },
})
}
>
Update User
</button>
{mutation.isPending ? 'Loading...' : JSON.stringify(mutation.data)}
</div>
);
};openapi and schema
The generated client also exports the OpenAPI spec as openapi and the Vovk Schema as schema, each from a module of the same name.
import { openapi } from '@/client/openapi';
import { schema } from '@/client/schema';The main module exports schema too.
import { schema } from '@/client';The segmented client has the same exports, but openapi and schema hold only the data of their segment:
import { openapi } from '@/client/admin/openapi';
import { schema } from '@/client/admin'; // or from '@/client/admin/schema'Used Templates
The TypeScript RPC client is generated from these templates:
- ts - the uncompiled TypeScript module with its types; the default for both the composed client and the segmented client;
- mixins - the
.d.tstypes and.jsonfiles for OpenAPI mixins; - readme, packageJson - a
README.mdthat documents the RPC modules, and apackage.jsonfor publishing the client as an npm package.
For more, see the client templates documentation.