TypeScript Client Customization
You can replace the lower-level modules that the generated TypeScript client imports: the fetcher and validateOnClient. Set them in the outputConfig.imports object of the config file. The object also takes createRPC: the module the client imports createRPC from, vovk/create-rpc by default (see config).
With vovk-ajv (described below) for client-side validation, the generated index.ts that creates the UserRPC module looks roughly like this:
import { createRPC } from 'vovk/create-rpc';
import { schema } from './schema';
export const UserRPC = createRPC(schema, '', 'UserRPC', import('vovk/fetcher'), {
validateOnClient: import('vovk-ajv'),
apiRoot: 'http://localhost:3000/api',
});With custom outputConfig.imports:
/** @type {import('vovk').VovkConfig} */
const config = {
outputConfig: {
imports: {
fetcher: './src/lib/fetcher',
validateOnClient: './src/lib/validate-on-client',
},
},
};
export default config;The generated index.ts imports them, with relative paths adjusted to its folder. A path to a .ts file is written the way tsconfig.json resolves it: without the extension under bundler resolution, by its .js name under node16 or nodenext.
import { createRPC } from 'vovk/create-rpc';
import { schema } from './schema';
export const UserRPC = createRPC(schema, '', 'UserRPC', import('../lib/fetcher'), {
validateOnClient: import('../lib/validate-on-client'),
apiRoot: 'http://localhost:3000/api',
});You can also set fetcher and validateOnClient per segment, OpenAPI mixins included. This way each segment can have its own options, auth and validation library.
/** @type {import('vovk').VovkConfig} */
const config = {
outputConfig: {
imports: {
// for all segments
fetcher: './src/lib/fetcher',
validateOnClient: './src/lib/validate-on-client',
},
segments: {
admin: {
imports: {
// for the "admin" segment only
fetcher: './src/lib/admin-fetcher',
validateOnClient: './src/lib/admin-validate-on-client',
},
},
},
},
};
export default config;fetcher
The fetcher validates the input on the client, sends the HTTP request, and returns the data by content type:
- For
application/jsonand any+jsontype, such asapplication/problem+json, it returns the parsed JSON. A response without a body, such as a204or the answer to aHEADrequest, givesnull. An error status throws anHttpExceptionwith the body’smessage, or with thedetailortitleof a problem details document. - For JSON Lines (
application/jsonl,application/jsonlinesorapplication/x-ndjson), it returns a disposable async iterable. - For other content types, it returns the
Responseobject as is, so you can read text or binary data. A status of 400 or above throws anHttpExceptioninstead, with the response text as its message.
The comparison ignores the content type’s parameters and letter case, so Application/JSON; charset=utf-8 is JSON.
The fetcher can also take custom options, passed to an RPC method on each call.
import { UserRPC } from '@/client';
const user = await UserRPC.updateUser({
// ...
successMessage: 'Successfully updated the user',
someOtherCustomFlag: true,
});createFetcher
The module at imports.fetcher must export a fetcher variable. createFetcher from vovk/fetcher makes one.
import { createFetcher } from 'vovk/fetcher';
export const fetcher = createFetcher<{
successMessage?: string; // "Successfully created a new user"
useAuth?: boolean; // true sets the Authorization header
someOtherCustomFlag?: boolean; // any other option for the RPC method
}>({
prepareRequestInit: async (init, { useAuth, someOtherCustomFlag }) => {
// ...
return {
...init,
headers: {
...init.headers,
...(useAuth ? { Authorization: 'Bearer token' } : {}),
},
};
},
transformResponse: async (data, { someOtherCustomFlag }) => {
// ...
return data;
},
onSuccess: async (data, { successMessage }) => {
if (successMessage) {
alert(successMessage);
}
},
onError: async (error) => {
alert(error.message);
},
});Every RPC method then accepts these options:
import { UserRPC } from '@/client';
await UserRPC.updateUser({
params: { param: 'value' },
query: { id: 'value' },
body: { email: 'value' },
successMessage: 'Successfully updated the user',
useAuth: true,
someOtherCustomFlag: true,
});createFetcher takes an object with:
prepareRequestInit(init: RequestInit, options: T)
Runs before the request. It receives the prepared init and the call’s custom options, and must return a RequestInit object, usually based on init. Use it for auth headers, the Next.js next options, logging, or other work before the request.
transformResponse(data: unknown, options: T, info: { response: Response, init: RequestInit, schema })
Changes the response data before the caller gets it. data is JSON, a disposable async iterator , or a Response, depending on the content type. Return the new value. The info argument holds the original Response, the RequestInit used, and the method’s schema: the same object as UserRPC.updateUser.schema, with fields such as schema.operationObject.
onError(error: HttpException, options: T)
Runs when a request fails. Use it for error messages, logging, or custom handling. Its third argument, info, holds the response, init, respData and schema; the first three are null when the call failed before it had them.
onSuccess(data: unknown, options: T)
Runs when a request succeeds. Use it for success messages, logging, or post-processing. Its third argument, info, is the same as in transformResponse.
Event-style callbacks
You can also add onSuccess and onError callbacks after you create the fetcher. Use this when a callback needs something that doesn’t exist at creation time, such as a React context value or a Zustand store. Both methods return a function that removes the callback. Several callbacks run in the order you add them.
const unsubSuccess = fetcher.onSuccess((data, { successMessage }) => {
console.log('Success:', successMessage);
});
const unsubError = fetcher.onError((error) => {
console.error('Error:', error.message);
});
// Later, remove the callbacks:
unsubSuccess();
unsubError();validateOnClient
validateOnClient validates the input of an RPC or API method (params, query, body) on the client. Create it with createValidateOnClient from vovk/create-validate-on-client. It takes a validate function, which receives the input data, its JSON schema and metadata, and returns the validated data or throws. Validation runs only when both the input and its schema exist.
import { validateData } from 'some-json-validation-library';
import { createValidateOnClient, HttpException, HttpStatus } from 'vovk/create-validate-on-client';
export const validateOnClient = createValidateOnClient({
validate: async (input, schema, meta) => {
const isValid = validateData(input, schema);
if (!isValid) {
throw new HttpException(HttpStatus.NULL, 'Validation failed', {
// ... optional cause
});
}
return input;
},
});vovk-ajv
vovk-ajv is the main client-side validation library, built on Ajv . npx vovk-cli init installs and configures it. You can set Ajv options and the target JSON Schema draft under config.libs.ajv in vovk.config.
- Ajv runs with
strict: falseunless your options say otherwise, so it ignores keywords JSON Schema doesn’t define, such as Zod’sexampleor OpenAPI’sdiscriminatorandx-*. - A
patternis read with theuflag, which\p{…}needs, as inz.emoji(). A pattern theuflag refuses, such as one with a\-escape that Zod’s regexes allow, is read without it. - OpenAPI 3.0’s boolean
exclusiveMinimumandexclusiveMaximumare read as the bounds they mark. - A
FormDataorURLSearchParamsbody holds strings, and so doparamsandquery, which the URL carries. So they are validated withcoerceTypes: true:"5"passes as a number. Ajv never edits theparamsandqueryit checks, so they are sent as given. - When Ajv can’t compile a schema, the client skips validation for it and logs a warning. Where Ajv can’t generate code at all, as on a page whose Content Security Policy has no
'unsafe-eval', the client skips every check and warns once. The server still validates the request.
npm install vovk-ajv/** @type {import('vovk').VovkConfig} */
const config = {
outputConfig: {
imports: {
validateOnClient: 'vovk-ajv',
},
},
libs: {
/** @type {import('vovk-ajv').VovkAjvConfig} */
ajv: {
options: {
// Ajv options
strict: false,
},
target: 'draft-2020-12', // detected from $schema unless you set it
},
},
};
export default config;