• English
  • 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:

    ScenarioFit
    Service-to-service procedure callsYes
    Shared input/output contracts and type inferenceYes
    Business code should not care about URL and HTTP methodYes
    Unary, server stream, client stream, or bidirectional stream callsYes
    Propagating requestId, traceparent, and tenantId consistentlyYes
    Raw HTTP Response semanticsUse Request
    Long-lived bidirectional messagesUse Socket

    Core Concepts

    ConceptUser model
    routerOrganizes business modules into one RPC contract tree.
    procedureA remote method, such as query, mutation, or a streaming procedure.
    clientCalls remote procedures through a typed client.
    handler / adapterExposes a router through VextJS, Express, Koa, Fastify, or Node HTTP.
    resolverDecides which URL a service or procedure should call.
    loadBalancerSelects one endpoint from multiple resolved endpoints.
    metadataPropagates requestId, traceparent, tenantId, and related context per call.

    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:

    PatternExample
    service.methoduser.getById
    resource.operationorder.cancel
    domain.actionbilling.createInvoice

    Streaming RPC

    RPC should not be limited to unary calls. Following the vextjs-rpc user model, commflow RPC distinguishes three streaming procedure styles:

    TypeUse case
    serverStreamServer continuously returns logs, progress, or subscription results.
    clientStreamClient uploads chunks and the server returns one final result.
    bidiStreamBoth sides continuously send and receive chunks, such as sessions or collaboration.
    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.

    ScenarioRecommendation
    Existing service meshResolve to stable Service DNS or ClusterIP.
    DevelopmentUse staticResolver with a fixed list of URLs.
    Multiple instances without meshUse staticResolver + roundRobin as a lightweight fallback.
    Registry neededImplement a resolver adapter for Nacos, Consul, or an internal platform.

    Retry Boundaries

    RPC retry decisions must consider the procedure kind, not only network errors:

    KindDefault retry guidance
    queryConservative retry for transient transport errors is acceptable.
    mutationDo not retry automatically unless the user provides idempotency and an explicit retry policy.
    serverStreamRetry only during connection setup before the first chunk arrives.
    clientStream / bidiStreamDo not replay automatically by default, to avoid duplicate writes or ordering issues.

    Error Handling

    RPC errors should be layered:

    ErrorMeaning
    transport errortimeout, network, aborted.
    protocol errorunknown procedure or invalid arguments.
    business errorservice returned a business failure.
    contract errortype contract mismatch.

    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

    DimensionrequestRPC
    User modelSend HTTP requestCall service procedure
    Entrymethod + pathtyped procedure + input
    Server sideusually an existing HTTP APIexposes router/handler
    ReturnResponse or wrapped responsebusiness result, stream chunk, or RPC error
    Type contractoptionalone of the core capabilities
    retrymostly method/error basedalso depends on query/mutation/streaming kind