Skip to Content
BlogVovk.ts v4

Vovk.ts v4

Vovk.ts v4 is a breaking release. It generates the RPC client into your project, returns tools in the standard-tool  shape, trims the entry points and fixes several hundred bugs.

TL;DR

Breaking changes:

  • vovk-client is gone. The client is generated into src/client and imported from @/client. The js templates are removed.
  • deriveTools returns an array of StandardToolV0. createTool, VovkTool and toolsByName are removed.
  • fetcher and createFetcher come from vovk/fetcher, createValidateOnClient from vovk/create-validate-on-client. The camelCase subpaths are removed.
  • decorate() and static prefix are removed: decorators and @prefix() only.
  • Only an HttpException sets the status. In production, any other error answers 500 with no detail.
  • Repeated query keys become arrays, and a handler’s second argument is the validated params.
  • A wrong method on a known path answers 405, and a Content-Type list answers 415.
  • A JSON Lines stream can be read once.
  • vovk-ajv: validateOnClient.configure() is removed. The Ajv options go in libs.ajv in vovk.config.
  • Python returns bytes for files. Rust sends a typed JSON body where it sent a multipart form. Python and Rust mixin calls send the query in the OpenAPI document’s style and explode.
  • The CLI needs Node.js 24+ and TypeScript 5.5+. The vovk packages take vovk 4 only as a peer.

Fixes: several hundred bugs in routing, validation, streaming, the clients, code generation and the CLI, and security hardening of query parsing, content types and generated code. Five audits found them, and each code fix comes with a test that failed before it.

The sections below cover each change and how to upgrade. Every package ships its own CHANGELOG.md with the full list; the links are at the end.

Install

A new project:

npx vovk-cli init

An existing project:

npm i vovk@latest vovk-ajv@latest npm i -D vovk-cli@latest

If you generate Python or Rust clients, update vovk-python and vovk-rust too.

Requirements:

  • Node.js 24+ for the CLI.
  • TypeScript 5.5+. The generated client alone compiles on 5.3+, but a service or handler that takes its types from its own procedure, as in the docs and the files vovk new writes, needs 5.5. TypeScript 7 works.
  • "experimentalDecorators": true in tsconfig.json if you build with webpack. Turbopack builds don’t need it. vovk init asks whether to add it.

The client is generated into your project

  • The composed client is TypeScript in src/client (or client/ when the app isn’t in src/app), imported as @/client. The vovk-client package and the node_modules/.vovk-client folder are retired, which also ends the pnpm hoisting issues.
// v3 import { UserRPC } from 'vovk-client'; // v4 import { UserRPC } from '@/client';
  • The js templates (js, jsBase, schemaJs, openapiJs) are removed. ts is the default for both client modes.
  • prettifyClient defaults to true and uses the Prettier installed in your project. The CLI no longer ships its own copy and warns once when it’s missing.
  • vovk init adds the client folder to .gitignore, and the prebuild: vovk generate script regenerates it on every build.
  • vovk generate refuses to overwrite a file it didn’t generate. --force overrides it.
  • The client takes libs and rootEntry from the current vovk.config, so vovk generate alone picks up a config change.
  • A relative path in outputConfig.imports may keep the .ts extension the v3 client needed: the CLI writes it the way your tsconfig.json resolves it.
  • A segment’s rootEntry: '' serves it from the root of its origin.
  • vovk init, --help and --version run without vovk installed. vovk init adds openapi3-ts, which the types of vovk import, since not every package manager installs a peer dependency.

Tools follow the standard-tool  convention

standard-tool  is an open convention for LLM tools.

  • deriveTools returns an array of tools that implement its StandardToolV0 interface: name, title, description, inputSchema, outputSchema, meta and execute. Import the type from the standard-tool package: import type { StandardToolV0 } from 'standard-tool'.
// v3 const { tools, toolsByName } = deriveTools({ modules: { UserRPC, TaskController }, }); const { parameters, execute } = toolsByName.UserRPC_findUsers;
// v4 const tools = deriveTools({ modules: { UserRPC, TaskController } }); const toolsByName = Object.fromEntries(tools.map((t) => [t.name, t])); const { inputSchema, execute } = toolsByName.UserRPC_findUsers; // v3's parameters const parameters = inputSchema?.['~standard'].jsonSchema.input({ target: 'draft-2020-12', }); await execute({ query: { search: 'Jane' } });
  • Tools derived from RPC modules and OpenAPI mixins carry schemas too, rebuilt from the generated JSON Schemas.
  • @operation.tool({ meta }) sets a tool’s meta.
  • Removed: createTool (use the standard-tool  package or plain objects of the same shape), the VovkTool type, the toolsByName return value, and the parameters, type and inputSchemas tool fields.
// v3: import { createTool } from 'vovk'; import { standardTool, withFormattedOutput } from 'standard-tool'; import type { StandardToolV0 } from 'standard-tool'; import { z } from 'zod'; // v3: createTool() with the same fields const sumNumbers = standardTool({ name: 'sum_numbers', description: 'Returns the sum of two numbers.', inputSchema: z.object({ a: z.number(), b: z.number() }), outputSchema: z.number(), execute: ({ a, b }) => a + b, }); // on failure, createTool returned { error: message }; standardTool throws. // withFormattedOutput returns { error: message }, as derived tools do. const allTools: StandardToolV0[] = [ ...tools, withFormattedOutput(sumNumbers), ];
  • Tool names are sanitized, capped at 64 characters and deduplicated. The input is checked once under the AI SDK, so a .transform() no longer runs twice.

The AI SDK and mcp-handler 2 take inputSchema as is. The full examples are on the Tools and MCP pages.

import { tool } from 'ai'; import { z } from 'zod'; const toolSet = Object.fromEntries( tools.map(({ name, execute, description, inputSchema }) => [ name, // v3: inputSchema: jsonSchema(parameters) // a procedure without input has no inputSchema tool({ execute, description, inputSchema: inputSchema ?? z.object({}), }), ]) );
import { createMcpHandler } from 'mcp-handler'; import { deriveTools, ToModelOutput } from 'vovk'; const mcpTools = deriveTools({ modules: { UserController, TaskController }, toModelOutput: ToModelOutput.MCP, }); const handler = createMcpHandler((server) => { mcpTools.forEach(({ title, name, execute, description, inputSchema }) => { // v3 passed inputSchemas, cast to Zod types server.registerTool(name, { title, description, inputSchema }, execute); }); });

Smaller entry points

Each entry exports what your code writes against.

v3v4
fetcher, createFetcher, VovkFetcher from vovkfrom vovk/fetcher
createValidateOnClient, VovkValidateOnClient from vovkfrom vovk/create-validate-on-client
vovk/createRPC, vovk/createValidateOnClientvovk/create-rpc, vovk/create-validate-on-client
validateOnClient.configure() in vovk-ajvremoved: the Ajv options go in libs.ajv in vovk.config
// v3 import { fetcher, createFetcher } from 'vovk'; // v4 import { fetcher, createFetcher } from 'vovk/fetcher';

vovk/internal is not public API. vovk-cli has no library entry: importing it ran the CLI. The vovk command is unchanged.

One controller syntax

decorate() and the static prefix class property are removed; use decorators and @prefix(). A static prefix left in a class is ignored without an error, so its routes lose the prefix.

// v3 import { decorate, get, procedure } from 'vovk'; export default class UserController { static prefix = 'users'; static getUser = decorate( get('{id}'), procedure({ params: z.object({ id: z.string() }) }) ).handle(async (req, { id }) => UserService.getUser(id)); }
// v4 import { get, prefix, procedure } from 'vovk'; @prefix('users') export default class UserController { @get('{id}') static getUser = procedure({ params: z.object({ id: z.string() }), }).handle(async (req, { id }) => UserService.getUser(id)); }

A procedure attached to several controller members runs each member’s own decorators and route options, over HTTP, in .fn() and in tools. The procedure’s own .fn() runs no controller’s decorators. Each member holds its own copy of the procedure, so UserController.getUser !== UserProcedures.getUser, and a member reused from another controller carries the schema its decorators added. A controller serves the routes of the class it extends only when that class is in the same segment, or with @cloneControllerMetadata(), and its schema lists only the routes it serves.

Types that match what runs

  • .fn() requires the body, query and params the server requires. It returns a promise of the handler’s result, or of an async generator of the checked items when the procedure has an iteration schema.
  • Client input types come from the schemas’ input types, so .default() and .transform() work.
  • Output and iteration JSON Schemas describe what the server sends, after defaults and transforms. preferTransformed: false keeps the old behavior. OpenAPI, the Python and Rust types and the tools’ output schemas follow.
  • The handler’s second argument is the validated params, coerced types included.
  • req.nextUrl.searchParams.get() is typed as the string it returns. req.vovk.query() gives the validated values.
  • None of it needs strictNullChecks, and the types no longer import next.
  • The declarations of vovk are about half the size of 3.7.0’s, and so are those of a controller that holds procedures.
  • A Swagger 2.0 mixin is read as OpenAPI 3.0, so its parameters, bodies, responses and definitions are typed.

HTTP behavior

  • A known path called with another method answers 405 with Allow, not 404.
  • HEAD requests fall back to the GET route.
  • Content-Type must name one media type. A list answers 415. A declared contentType is enforced even without a body schema.
  • Malformed JSON, form and query input answers 400. An optional body can be left out; a missing required body answers 400.
  • A + in the query is a space, as in URLSearchParams. A literal + comes as %2B, as the vovk clients send it.
  • Repeated query keys collect into an array: tag=a&tag=b gives ['a', 'b']. A key whose schema takes an array is a one-item array when sent once, as OpenAPI clients send it.
  • 204, 205 and 304 responses have no body. A status outside 200–599 becomes 500.
  • HttpStatus.TOO_MANY_REQUESTS is spelled right; it was TOO_MANY_TRequestS.
  • The route match cache is kept per method and segment, so a match for one method no longer reroutes another (#33 ).

Errors

  • In production, error responses carry no internal detail. onError still receives the full error.
  • Only an HttpException sets the status and keeps its message. Any other error answers 500, also in derived tools.
@prefix('users') export default class UserController { @get('{id}') static getUser = procedure({ params: z.object({ id: z.string() }), }).handle(async (req, { id }) => { // a Prisma error answers 500; v4 hides its message in production const user = await prisma.user.findUnique({ where: { id } }); // 404 with this message, in v3 and v4 if (!user) { throw new HttpException(HttpStatus.NOT_FOUND, 'User not found'); } return user; }); }

In production, v4 answers the Prisma error with this body and logs the error:

{ "statusCode": 500, "message": "Internal server error", "isError": true }
  • Failed output or iteration validation is a server error: 500, with the issues kept on the server.
  • A 400 lists at most 20 issues. A 1 MB body could produce a 91 MB response before. onError still gets the library’s own issues in cause.issues.

JSON Lines

  • The server sends as fast as the client reads, and returns the generator when the client goes away.
  • notFound(), forbidden() and unauthorized() thrown mid-stream send 404, 403 and 401 in the error line. Error lines carry statusCode.
  • The client reads only as fast as you take items, so a consumed stream iterated again yields nothing. Read it once, or call asPromise().
using stream = await StreamRPC.streamTokens(); for await (const { message } of stream) console.log(message); // a second loop: v3 gave every item again, v4 gives nothing for await (const { message } of stream) console.log(message);
// to use the items more than once, collect them using stream = await StreamRPC.streamTokens(); const tokens = await stream.asPromise();

Static segments

A static segment pre-renders with no dynamic export, also with cacheComponents on, which create-next-app 16.4+ sets. Next.js refuses dynamic = 'force-static' there. A static export still needs cacheComponents off.

Client-side validation

  • validateOnClient.configure() is removed. The Ajv options go in libs.ajv in vovk.config. They reach the client through .vovk-schema/_meta.json, so they must be JSON.
  • vovk-ajv turns Ajv’s strict mode off, so example, x-* and other annotations no longer block a request.
  • Where Ajv can’t generate code, as under a Content Security Policy without 'unsafe-eval', the client skips validation and warns once. The server still validates. Before, every validated call failed without sending a request.

Python and Rust clients

Both clients escape all schema text, so a spec can’t inject code into the generated package. Path params and query keys are percent-encoded, and .. is refused.

Mixin calls send the query and an urlencoded body in the OpenAPI document’s style and explode, as the TypeScript client does; without them, an array repeats its key. The error message is read where a mixin’s errorMessageKey points.

Python:

  • Breaking: a response that is neither JSON nor text, such as a file, comes back as bytes, not str.
  • One requests session per client with a default timeout. An upload fails only when the server takes no data for the connect timeout.
  • Every 4xx and 5xx raises HttpException. Every JSON body kind gets a body parameter.
  • No urllib3 pin and no GPL rfc3987 dependency. Python 3.9+.

Rust:

  • Breaking: a procedure that takes JSON or a form, with no file field, takes its typed body and sends JSON. It took a reqwest::multipart::Form, so typed fields reached the server as text.
  • The crate compiles for every tested schema shape, and the schema is compiled into the crate.
  • Validation follows JSON Schema 2020-12. Rust 1.85+, tokio 1.49+.
  • set_client_factory builds the reqwest::Client the calls use, as with a timeout, a proxy or default headers.

Security

  • Query parsing drops prototype-polluting keys, caps nesting at 32 levels and can’t be made to allocate a huge array.
  • A Content-Type list is refused, so a cross-origin request can’t reach a JSON handler without a preflight.
  • x-tsType is stripped from third-party OpenAPI specs, and a plain tsType key is ignored. Either could put code into the generated client.
  • A form body that repeats one field name is parsed in linear time. A 500 KB body could stall the server for seconds.
  • Multitenant hosts match case-insensitively, and only a DNS label is a wildcard value.
  • A crafted OpenAPI spec no longer makes code samples grow exponentially in vovk generate and vovk bundle.
  • vovk-cli no longer depends on gray-matter, whose dependency chain ends in an advisory with no fix.

Performance

Measured on Node.js 24.21 and an Apple M4 Pro: about 2.1 µs of routing overhead per request up to 1,000 controllers (1.58 µs on Node.js 24.1), 3.2 µs at 10,000 and 2.1 µs at 50,000 (100,000 endpoints); about 11 ms of cold start for 1,000 controllers with two endpoints each. Details and how to run the suite: Performance.

Upgrading from v3

  1. Remove vovk-client and import from @/client. Replace fromTemplates: ['js'] with ['ts']. Install prettier, or set prettifyClient: false. Add the client folder to .gitignore. A stale node_modules/.vovk-client can be deleted. If your config sets composedClient.outDir: './node_modules/.vovk-client', the v3 default, remove it.
  2. Run vovk dev once, and vovk bundle if you keep the prebundle folder. vovk generate and the build read the schema files that vovk dev writes.
  3. Add the client folder to your linter’s ignores, for example globalIgnores(['src/client/**']). ESLint doesn’t read .gitignore.
  4. If the client’s outDir was outside node_modules, delete the old .js and .d.ts files there. Webpack picks index.js over index.ts.
  5. Outside a bundler, as in a script or node --test, run the client with tsx: @/client needs a loader that reads the tsconfig paths. Or set module: nodenext and allowImportingTsExtensions (with noEmit) and import the client by a relative path. To use the client in another project, build it with vovk bundle.
  6. Move the imports in the table above.
  7. deriveTools returns an array. A name map is one line: Object.fromEntries(tools.map((t) => [t.name, t])). Move createTool, VovkTool and inputSchemas to the StandardToolV0 shape. parameters is tool.inputSchema['~standard'].jsonSchema.input({ target: 'draft-2020-12' }) without $schema, and type was always 'function'. For createTool’s { error } results, use withFormattedOutput(standardTool(def)) from standard-tool. For MCP output, use withFormattedOutput(tool, (result) => ToModelOutput.MCP(result, tool, null)).
  8. Replace decorate(...) with decorators, and static prefix = 'users' with @prefix('users').
  9. .fn() of a bare procedure, and tools derived from a module of bare procedures, run no controller’s decorators. Call .fn() on the controller member, or derive the tools from the controller.
  10. A controller that extends another one serves the parent’s routes only when the parent is in the same segment. Otherwise add @cloneControllerMetadata().
  11. A function attached to several members is copied for each, so UserController.getUser !== UserProcedures.getUser. Don’t compare members by identity.
  12. Throw HttpException for expected errors. Read internal messages in onError.
  13. A handler that read raw strings from its second argument now gets the validated params.
  14. A repeated query key gives an array, so a plain string query field answers 400 for tag=a&tag=b.
  15. A + in the query is a space. Send a literal + as %2B. Under Next.js, v3 kept a raw + and turned every space into +.
  16. A string body to a method that declares no content type, such as a plain handler typed VovkRequest<string>, goes out as JSON. v3 sent it raw as text/plain. req.vovk.body() still gives the string; req.text() now gives it quoted. To keep raw text, use a procedure with a text contentType.
  17. Read a JSON Lines stream once, or call asPromise().
  18. In a custom fetcher, a call without a body has body: undefined, not null.
  19. Use Node.js 24+ and TypeScript 5.5+.
  20. Under Yarn 2+, move vovk generate from prebuild into the build script: Yarn 2+ runs no pre scripts.
  21. Python: a file response is bytes. Rust: a JSON or form procedure without files takes its typed body.
  22. The vovk packages peer vovk 4 only: update vovk-cli, vovk-ajv, vovk-python and vovk-rust with it.
  23. Python and Rust mixin calls send queries in the document’s style and explode; without them, arrays repeat their key.

Full changelogs

Last updated on