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-clientis gone. The client is generated intosrc/clientand imported from@/client. Thejstemplates are removed.- deriveTools returns an array of
StandardToolV0.createTool,VovkToolandtoolsByNameare removed. fetcherandcreateFetchercome fromvovk/fetcher,createValidateOnClientfromvovk/create-validate-on-client. The camelCase subpaths are removed.decorate()andstatic prefixare removed: decorators and@prefix()only.- Only an
HttpExceptionsets 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-Typelist answers 415. - A JSON Lines stream can be read once.
vovk-ajv:validateOnClient.configure()is removed. The Ajv options go inlibs.ajvinvovk.config.- Python returns
bytesfor 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’sstyleandexplode. - The CLI needs Node.js 24+ and TypeScript 5.5+. The vovk packages take
vovk4 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 initAn existing project:
npm i vovk@latest vovk-ajv@latest
npm i -D vovk-cli@latestIf 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 newwrites, needs 5.5. TypeScript 7 works. "experimentalDecorators": trueintsconfig.jsonif you build with webpack. Turbopack builds don’t need it.vovk initasks whether to add it.
The client is generated into your project
- The composed client is TypeScript in
src/client(orclient/when the app isn’t insrc/app), imported as@/client. Thevovk-clientpackage and thenode_modules/.vovk-clientfolder are retired, which also ends the pnpm hoisting issues.
// v3
import { UserRPC } from 'vovk-client';
// v4
import { UserRPC } from '@/client';- The
jstemplates (js,jsBase,schemaJs,openapiJs) are removed. ts is the default for both client modes. prettifyClientdefaults totrueand uses the Prettier installed in your project. The CLI no longer ships its own copy and warns once when it’s missing.vovk initadds the client folder to.gitignore, and theprebuild: vovk generatescript regenerates it on every build.vovk generaterefuses to overwrite a file it didn’t generate.--forceoverrides it.- The client takes
libsandrootEntryfrom the currentvovk.config, sovovk generatealone picks up a config change. - A relative path in
outputConfig.importsmay keep the.tsextension the v3 client needed: the CLI writes it the way yourtsconfig.jsonresolves it. - A segment’s
rootEntry: ''serves it from the root of its origin. vovk init,--helpand--versionrun withoutvovkinstalled.vovk initaddsopenapi3-ts, which the types ofvovkimport, 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
StandardToolV0interface:name,title,description,inputSchema,outputSchema,metaandexecute. Import the type from thestandard-toolpackage: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’smeta.- Removed:
createTool(use the standard-tool package or plain objects of the same shape), theVovkTooltype, thetoolsByNamereturn value, and theparameters,typeandinputSchemastool 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.
| v3 | v4 |
|---|---|
fetcher, createFetcher, VovkFetcher from vovk | from vovk/fetcher |
createValidateOnClient, VovkValidateOnClient from vovk | from vovk/create-validate-on-client |
vovk/createRPC, vovk/createValidateOnClient | vovk/create-rpc, vovk/create-validate-on-client |
validateOnClient.configure() in vovk-ajv | removed: 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 aniterationschema.- 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: falsekeeps 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 importnext. - The declarations of
vovkare 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
definitionsare 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-Typemust name one media type. A list answers 415. A declaredcontentTypeis 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 inURLSearchParams. A literal+comes as%2B, as the vovk clients send it. - Repeated query keys collect into an array:
tag=a&tag=bgives['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_REQUESTSis spelled right; it wasTOO_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.
onErrorstill receives the full error. - Only an
HttpExceptionsets 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
outputoriterationvalidation 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.
onErrorstill gets the library’s own issues incause.issues.
JSON Lines
- The server sends as fast as the client reads, and returns the generator when the client goes away.
notFound(),forbidden()andunauthorized()thrown mid-stream send 404, 403 and 401 in the error line. Error lines carrystatusCode.- 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 inlibs.ajvinvovk.config. They reach the client through.vovk-schema/_meta.json, so they must be JSON.vovk-ajvturns Ajv’s strict mode off, soexample,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, notstr. - One
requestssession 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 abodyparameter. - No
urllib3pin and no GPLrfc3987dependency. 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_factorybuilds thereqwest::Clientthe 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-Typelist is refused, so a cross-origin request can’t reach a JSON handler without a preflight. x-tsTypeis stripped from third-party OpenAPI specs, and a plaintsTypekey 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 generateandvovk bundle. vovk-clino 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
- Remove
vovk-clientand import from@/client. ReplacefromTemplates: ['js']with['ts']. Installprettier, or setprettifyClient: false. Add the client folder to.gitignore. A stalenode_modules/.vovk-clientcan be deleted. If your config setscomposedClient.outDir: './node_modules/.vovk-client', the v3 default, remove it. - Run
vovk devonce, andvovk bundleif you keep the prebundle folder.vovk generateand the build read the schema files thatvovk devwrites. - Add the client folder to your linter’s ignores, for example
globalIgnores(['src/client/**']). ESLint doesn’t read.gitignore. - If the client’s
outDirwas outsidenode_modules, delete the old.jsand.d.tsfiles there. Webpack picksindex.jsoverindex.ts. - Outside a bundler, as in a script or
node --test, run the client with tsx:@/clientneeds a loader that reads the tsconfigpaths. Or setmodule: nodenextandallowImportingTsExtensions(withnoEmit) and import the client by a relative path. To use the client in another project, build it withvovk bundle. - Move the imports in the table above.
deriveToolsreturns an array. A name map is one line:Object.fromEntries(tools.map((t) => [t.name, t])). MovecreateTool,VovkToolandinputSchemasto theStandardToolV0shape.parametersistool.inputSchema['~standard'].jsonSchema.input({ target: 'draft-2020-12' })without$schema, andtypewas always'function'. For createTool’s{ error }results, usewithFormattedOutput(standardTool(def))from standard-tool. For MCP output, usewithFormattedOutput(tool, (result) => ToModelOutput.MCP(result, tool, null)).- Replace
decorate(...)with decorators, andstatic prefix = 'users'with@prefix('users'). .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.- A controller that extends another one serves the parent’s routes only when the parent is in the same segment. Otherwise add
@cloneControllerMetadata(). - A function attached to several members is copied for each, so
UserController.getUser !== UserProcedures.getUser. Don’t compare members by identity. - Throw
HttpExceptionfor expected errors. Read internal messages inonError. - A handler that read raw strings from its second argument now gets the validated params.
- A repeated query key gives an array, so a plain string query field answers 400 for
tag=a&tag=b. - A
+in the query is a space. Send a literal+as%2B. Under Next.js, v3 kept a raw+and turned every space into+. - 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 astext/plain.req.vovk.body()still gives the string;req.text()now gives it quoted. To keep raw text, use a procedure with a textcontentType. - Read a JSON Lines stream once, or call
asPromise(). - In a custom fetcher, a call without a body has
body: undefined, notnull. - Use Node.js 24+ and TypeScript 5.5+.
- Under Yarn 2+, move
vovk generatefromprebuildinto thebuildscript: Yarn 2+ runs noprescripts. - Python: a file response is
bytes. Rust: a JSON or form procedure without files takes its typed body. - The vovk packages peer
vovk4 only: updatevovk-cli,vovk-ajv,vovk-pythonandvovk-rustwith it. - Python and Rust mixin calls send queries in the document’s
styleandexplode; without them, arrays repeat their key.