Local Procedure Call (LPC)
Every procedure made with the procedure function can be called as a regular function through its fn property. It takes { params, query, body }, the same shape as the generated RPC client. The RPC client mirrors fn, not the other way around.
Take this controller:
import { z } from 'zod';
import { procedure, prefix, get, operation } from 'vovk';
import UserService from './user-service';
@prefix('users')
export default class UserController {
@operation({
summary: 'Get user',
description: 'Get user by ID with Zod validation',
})
@get('{id}')
static getUser = procedure({
params: z.object({
id: z.string().uuid(),
}),
}).handle((req) => {
// ...
});
}When the controller is in a segment, the generated RPC client calls the same procedure over HTTP, with the same call shape:
import { UserRPC } from '@/client';
const user = await UserRPC.getUser({
params: { id: '123e4567-e89b-12d3-a456-426614174000' },
disableClientValidation: true, // disables client-side validation
meta: { hello: 'world' }, // available as xMetaHeader metadata
});
console.log('User:', user);fn runs the procedure in the current context, with full validation and no HTTP or network:
import UserController from '@/modules/user/user-controller';
const user = await UserController.getUser.fn({
params: { id: '123e4567-e89b-12d3-a456-426614174000' },
disableClientValidation: true, // disables validation
meta: { hello: 'world' }, // available as root metadata
});
console.log('User:', user);Local procedures have these main uses:
For SSR, SSG, PPR and server actions: call the method in a server component:
import UserController from '@/modules/user/user-controller';
export default async function UserPage() {
const user = await UserController.getUser.fn({
params: { id: '123e4567-e89b-12d3-a456-426614174000' },
});
return (
<p>User: {JSON.stringify(user)}</p>
);
}For Next.js server actions: call fn in a server action to run the procedure from a form. Set the procedure’s contentType to 'multipart/form-data' so it takes FormData as the body:
import UserController from '@/modules/user/user-controller';
export default function CreateUserPage() {
async function handleCreate(body: FormData) {
'use server';
await UserController.createUser.fn({ body });
}
return (
<form action={handleCreate}>
<input name="name" required />
<button type="submit">Create User</button>
</form>
);
}For AI tool execution: pass a controller with procedure() procedures to deriveTools as a “module”. Its tools call the procedures in the current context, without HTTP requests.
import { deriveTools } from 'vovk';
const tools = deriveTools({
modules: { UserController },
});
console.log(tools); // [{ name, description, inputSchema, execute, ... }, ...]Rules of Locally Called Procedures
fn doesn’t imitate the Request object. Its req has only the custom vovk property, with async vovk.body(), vovk.query(), vovk.params() and vovk.meta(), so properties such as req.url or req.headers are not defined in a local call. A fitting req type is Pick<VovkRequest<TBody, TQuery, TParams>, 'vovk'>. vovk.body() reads a FormData, URLSearchParams, Blob, ArrayBuffer or typed array body as the server reads a request that carries it, by the content type the RPC client would send. Any other body is passed as it is.
So that an HTTP handler also works with fn, destructure the request and use only its vovk property:
export default class UserController {
@post('{id}')
static updateUser = procedure({
body: z.object({
/* ... */
}),
params: z.object({
/* ... */
}),
query: z.object({
/* ... */
}),
output: z.object({
/* ... */
}),
}).handle(async ({ vovk }) => {
const body = await vovk.body();
const query = vovk.query();
const params = vovk.params();
const meta = vovk.meta<{ hello: string }>();
// ...
});
}A local call runs the same decorators as the HTTP handler. In a decorator for a handler you call with fn, don’t use NextRequest properties such as req.headers or req.nextUrl.
import { createDecorator } from 'vovk';
const myDecorator = createDecorator(({ vovk }, next) => {
const meta = vovk.meta<{ foo: string }>();
// ...
return next();
});To detect a local call, check req.url: it is undefined in a call through fn:
import { createDecorator } from 'vovk';
const myDecorator = createDecorator((req, next) => {
if (typeof req.url === 'undefined') {
console.log('Local context');
} else {
console.log('HTTP request context');
}
// ...
return next();
});req is not a Request, but a local call made during a Next.js request, as in a server component or a server action, can still use Next.js functions such as headers or cookies from next/headers. Outside a request, as in a unit test, they throw, so mock next/headers there.
import { createDecorator } from 'vovk';
import { headers } from 'next/headers';
const myDecorator = createDecorator(async ({ vovk }, next) => {
const reqHeaders = await headers();
// ...
return next();
});A local procedure needs no HTTP decorator such as @get or @post. It only needs to be made with the procedure function.
import { z } from 'zod';
import { procedure } from 'vovk';
export default class UserProcedures {
static updateUser = procedure({
body: z.object({
/* ... */
}),
// ...
}).handle(({ vovk }) => {
// ...
});
}
UserProcedures.updateUser.fn({
/* ... */
});A class written this way works as a “validated service”. Attach its procedures to a controller later, or use it on its own as a set of validated functions.
import { prefix, post } from 'vovk';
import UserProcedures from './user-procedures';
@prefix('users')
export default class UserController {
@post('{id}')
static updateUser = UserProcedures.updateUser;
}Assign the procedure itself. .bind() returns a plain function: it has no fn, and the emitted schema loses the procedure’s validation, which client-side validation, OpenAPI, the Python and Rust clients and AI tools read.