RPC
Planned APIs: This page describes the final user workflow for routers, procedures, typed clients, streaming, metadata, and resolvers. The model follows mature vextjs-rpc patterns. commflow@0.0.2 does not yet publish an RPC runtime; see Current Release Quick Start for runnable exports.
When To Use It
RPC fits service-to-service communication organized as method calls:
Core Concepts
Define A Shared Contract
The recommended RPC model is not hand-written string paths. Define a shared router first, then let server and client code consume the same contract.
import {
createCommflowRpcRouter,
mutation,
query
} from 'commflow';
export const userRpc = createCommflowRpcRouter({
user: {
getById: query({
input: { id: 'string!' },
output: { id: 'string!', name: 'string!' },
resolve: async ({ input, ctx }) => {
return ctx.services.user.findById(input.id);
}
}),
create: mutation({
input: { name: 'string:1-64!', email: 'email!' },
output: { id: 'string!' },
resolve: async ({ input, ctx }) => {
return ctx.services.user.create(input);
}
})
}
});
export type UserRpc = typeof userRpc;
The schema syntax above expresses the user contract: input, output, context, and procedure kind live in one shared router. The concrete schema adapter may change without changing this user model.
Call A Unary Procedure
import {
createCommflowRpcClient,
staticResolver
} from 'commflow';
import { userRpc, type UserRpc } from './contracts/user-rpc';
const rpc = createCommflowRpcClient<UserRpc>({
router: userRpc,
resolver: staticResolver({
user: ['https://user-service.internal/rpc']
}),
timeout: 5000,
retry: 1
});
const user = await rpc.user.getById({ id: '42' });
Group procedures by business domain:
Streaming RPC
RPC should not be limited to unary calls. Following the vextjs-rpc user model, commflow RPC distinguishes three streaming procedure styles:
import {
bidiStream,
createCommflowRpcRouter,
serverStream
} from 'commflow';
export const streamRpc = createCommflowRpcRouter({
log: {
tail: serverStream({
input: { service: 'string!' },
chunk: { line: 'string!' },
resolve: async function* ({ input, ctx, signal }) {
for await (const line of ctx.logs.tail(input.service, { signal })) {
yield { line };
}
}
})
},
chat: {
echo: bidiStream({
input: { roomId: 'string!' },
chunkIn: { text: 'string!' },
chunkOut: { text: 'string!' },
resolve: async function* ({ stream }) {
for await (const message of stream) {
yield { text: `echo:${message.text}` };
}
}
})
}
});
Client-side consumption:
for await (const chunk of rpc.log.tail({ service: 'user' })) {
console.log(chunk.line);
}
async function* messages() {
yield { text: 'hello' };
}
for await (const reply of rpc.chat.echo({ roomId: 'room-1' }, messages())) {
console.log(reply.text);
}
VextJS Integration
VextJS is the first target consumer for commflow. The host route is responsible for the RPC path. commflow RPC provides the handler, context bridge, and typed client; it should not hide server entry registration inside a plugin.
Planned adapter API: The modules below show the VextJS adoption shape after the runtime ships. They are not imports you can copy into commflow@0.0.2. The stable rule is that the host route owns the /rpc path, auth, middleware, OpenAPI, and hot reload; commflow only provides a handler, context bridge, and client.
Keep the server entry in a route file. The src/routes/rpc.ts filename maps to the /rpc prefix, so the subpath is /:
// src/routes/rpc.ts
import { defineRoutes } from 'vextjs';
import { appRpcHandler } from '../lib/commflow-rpc-handler';
export default defineRoutes((app) => {
app.post('/', async (request, response) => {
return appRpcHandler.handleVext(request, response);
});
});
Create appRpcHandler in a normal application module such as src/lib/commflow-rpc-handler.ts with the future createCommflowRpcHandler() API. It is not a plugin side effect.
Mount the in-process RPC client in a separate plugin file. The plugin only extends the application; it does not register the server entry:
// src/plugins/commflow-rpc.ts
import { createCommflowRpcClient, staticResolver } from 'commflow';
import { definePlugin } from 'vextjs';
import { appRpc } from '../contracts/rpc';
export default definePlugin({
name: 'commflow-rpc',
setup(app) {
app.extend('rpc', createCommflowRpcClient({
router: appRpc,
resolver: staticResolver({
user: ['http://user-service:3000/rpc'],
order: ['http://order-service:3000/rpc']
}),
metadata: () => ({ source: 'vext' })
}));
}
});
If you only need an in-process VextJS client, mount just the client; this is still a planned API:
export default definePlugin({
name: 'commflow-rpc-client',
setup(app) {
app.extend('rpc', createCommflowRpcClient({
router: appRpc,
metadata: () => ({
source: 'order-service'
}),
resolver: staticResolver({
user: ['http://user-service:3000/rpc']
})
}));
}
});
Generic Node, Express, Koa, and Fastify users follow the same rule: create a handler first, then let the host framework choose path, middleware, auth, and rate limiting.
const rpcHandler = createCommflowRpcHandler({
router: appRpc,
createContext: ({ request }) => ({
requestId: request.headers.get('x-request-id') ?? undefined
})
});
The host route owns the RPC path; commflow owns the RPC handler.
const rpc = createCommflowRpcClient({
router: appRpc,
resolver: staticResolver({
user: ['http://user-service:3000/rpc'],
order: ['http://order-service:3000/rpc']
})
});
Business code:
export class OrderService {
constructor(private app: VextApp) {}
async createOrder(userId: string, items: OrderItem[]) {
const user = await this.app.rpc.user.getById({ id: userId });
return this.app.rpc.order.create({ userId: user.id, items });
}
}
Default guidance:
- Use
/rpc as the default path, and switch to /internal/rpc only when the entry must be isolated.
- Reuse VextJS
app.services, request.auth, request.requestId, x-request-id, and traceparent.
- Use the future
commflow request core for outbound transport instead of reimplementing fetch orchestration in RPC.
- Create only the handler or only the client when one side is needed.
Generic Client
Non-VextJS users should be able to create a standalone RPC client:
import {
createCommflowRpcClient,
roundRobin,
staticResolver
} from 'commflow';
import { appRpc, type AppRpc } from './contracts/rpc';
export const rpc = createCommflowRpcClient<AppRpc>({
router: appRpc,
resolver: staticResolver({
user: [
'http://user-1.internal/rpc',
'http://user-2.internal/rpc'
],
order: ['http://order.internal/rpc']
}),
loadBalancer: roundRobin(),
timeout: 3000,
retry: 1,
metadata: {
source: 'gateway'
}
});
Metadata And Context Propagation
metadata carries platform context with every RPC call:
const rpc = createCommflowRpcClient<AppRpc>({
router: appRpc,
resolver,
metadata: ({ procedure }) => ({
requestId: getCurrentRequestId(),
traceparent: getCurrentTraceparent(),
tenantId: getCurrentTenantId(),
source: 'order-service',
procedure
})
});
Boundaries:
- requestId and traceparent are usually generated by a gateway or entry middleware.
commflow should propagate and expose these values, not invent platform tracing.
- Server procedures can read them from
meta or ctx.
Service Discovery And Load Balancing
Production deployments usually rely on Kubernetes Services, a service mesh, or a gateway for stable addresses. commflow RPC should not force an internal registry on users.
Retry Boundaries
RPC retry decisions must consider the procedure kind, not only network errors:
Error Handling
RPC errors should be layered:
The unified error shape should let users decide what happened:
{
code: 'NOT_FOUND',
message: 'User not found',
status: 404,
retryable: false,
details: { id: '42' }
}
Server procedures can throw structured business errors:
throw new CommflowRpcError('NOT_FOUND', 'User not found', {
status: 404,
retryable: false,
details: { id: input.id }
});
See Errors And Retries for recovery strategy.
Request Versus RPC