Skip to content
CommunicationDocs 1.3.x

Remote Resolvers ​

Overview

A remote resolver is the service-catalog routing layer: it maps a proto service identity to the Transport used to reach that service. Three APIs consult it: the unified client factory (server.client(Desc)), the catalog primitive (ctx.call(...)), and the standalone catalog client (createCatalogClient(...)). For the first two, locally-mounted services dispatch in-process and never touch the resolver; everything else is resolved through it. createCatalogClient has no local server, so every call goes through the resolver unconditionally. The server's outgoingInterceptors run on every route the resolver returns; a standalone client applies its own outgoingInterceptors option.

You pass a resolver to createServer({ remoteResolver }) for server-side routing, or directly to createCatalogClient({ resolver }) for out-of-process workers, schedulers, and CLIs. The framework consults it when a remote route is first needed and caches returned transports. Creating a standalone catalog client does not resolve its entries; requesting a non-local server.client(Desc) does.

The diagram follows one call from each entry point to the transport that carries it:

server.client() also fails before the resolver is consulted when no remoteResolver is configured at all: that is a CatalogConfigError. The Code.Internal branch applies to ctx.call and createCatalogClient; a resolver that throws inside server.client() propagates its own error instead.

The RemoteResolver contract ​

A resolver is a plain function:

typescript
import type { RemoteResolver } from '@connectum/core';
import { createGrpcTransport } from '@connectrpc/connect-node';

// (ctx: { typeName: string; endpoint?: string }) => Transport | null
const resolver: RemoteResolver = ({ typeName, endpoint }) => {
  if (typeName !== 'orders.v1.OrdersService' || endpoint !== 'eu-west') return null;
  return createGrpcTransport({ baseUrl: 'https://orders.example.com:8443' });
};

ResolverContext carries the proto typeName (e.g. "orders.v1.OrdersService") and an optional endpoint hint (see Endpoint hints).

The contract is strict:

  • Synchronous. The signature returns Transport | null directly — never a Promise. The framework caches per (typeName, endpoint) and cannot await a resolver. A result that is neither null nor a Transport (a string, undefined, a Promise from an async resolver) fails the call with Code.Internal and a message that names the service; it is not cached.
  • No network I/O. A resolver must not dial TCP or perform a DNS lookup. It only maps an identity to a lazily-connecting transport. ConnectRPC transports (e.g. createGrpcTransport({ baseUrl })) do not open a socket until the first RPC, which is exactly what makes a synchronous, I/O-free resolver safe — startup validation never blocks on DNS or a dial.
  • null means "no route." Returning null is an operational miss: the call fails with Code.Unavailable — at dispatch time for ctx.call, and eagerly at client construction for server.client(). (A missing remoteResolver is a different failure, because retrying cannot fix it: server.client() throws CatalogConfigError for a non-local service, and ctx.call / ctx.stream fail with Code.FailedPrecondition and the same explanation.)
  • Successful resolutions are cached per (typeName, endpoint). Once a route returns a transport, subsequent calls reuse it. A null result or a thrown error is not cached, so the next attempt consults the resolver again.

Route cache lifetime ​

The cache belongs to each Server or standalone catalog client. It stores a transport, not an RPC result or a health check. Once a transport is returned, Connectum keeps using it for that key even if a later RPC fails. There is no automatic route expiry or re-resolution after a transport error.

Consequently, changing a mapResolver entry or an environment URL does not replace a route already cached by that caller. A route that previously returned null can become available on a later call because misses are not cached. This is a lookup on the next call, not automatic RPC retry or failover. Use a new server/client instance to adopt a replacement mapping, or put changing backends behind a stable address handled by your transport or infrastructure.

Built-in resolvers ​

@connectum/core ships four resolver factories covering the common deployment shapes.

singleTransportResolver(transport) ​

Routes every remote service to the same transport. Ideal for a single upstream — a sidecar or an API gateway — that fronts all remote services, and for local development where one process holds everything.

typescript
import { createServer, singleTransportResolver } from '@connectum/core';
import { createGrpcTransport } from '@connectrpc/connect-node';

const gateway = createGrpcTransport({ baseUrl: 'http://gateway:50051' });

const server = createServer({
  services: [myRoutes],
  remoteResolver: singleTransportResolver(gateway),
});

mapResolver({ [typeName]: transport }) ​

An explicit per-service map. Use it when each remote service has its own transport and you want an exact allow-list — any typeName not in the map resolves to null (→ Code.Unavailable). Only the keys the map owns count: a name that exists on Object.prototype (constructor, toString, valueOf, __proto__) is not in the map unless you put it there, and resolves to null. The resolver reads the map when consulted, but a caller's already-cached transport takes precedence over later map changes; see route cache lifetime.

typescript
import { createServer, mapResolver } from '@connectum/core';
import { createGrpcTransport } from '@connectrpc/connect-node';
import { OrdersService } from '#gen/orders/v1/orders_pb.ts';
import { InventoryService } from '#gen/inventory/v1/inventory_pb.ts';

const server = createServer({
  services: [myRoutes],
  remoteResolver: mapResolver({
    [OrdersService.typeName]: createGrpcTransport({ baseUrl: 'http://orders:50051' }),
    [InventoryService.typeName]: createGrpcTransport({ baseUrl: 'http://inventory:50051' }),
  }),
});

dnsResolver(options) ​

Derives a base URL per service from a DNS-style template, then builds a transport for it. This mirrors container/Kubernetes service-name routing, where the service identity is its DNS name.

DnsResolverOptions:

FieldTypeDescription
templatestringURL template with {shortName} (alias {name}) placeholders.
createTransport?(baseUrl: string) => TransportBuilds a transport from the resolved URL. Defaults to a gRPC (HTTP/2) transport via createGrpcTransport({ baseUrl }).

The short name is the last dot-segment of the typeName, lower-cased, with a trailing Service stripped — orders.v1.OrdersService becomes orders. Both {shortName} and {name} expand to the same value.

typescript
import { createServer, dnsResolver } from '@connectum/core';

const server = createServer({
  services: [myRoutes],
  remoteResolver: dnsResolver({
    template: 'http://{shortName}.prod.svc.cluster.local:50051',
  }),
});

dnsResolver always resolves — it never returns null, because the template is assumed to cover every remote service. If you need an explicit allow-list (unknown services rejected as Unavailable), use mapResolver instead.

This does not prove that the hostname exists or that the service is healthy: no network probe happens during resolution. Transport construction can still throw, and DNS/connection failures can occur when the RPC runs. Different full typeNames with the same short name expand to the same URL; use an explicit map if their deployments differ.

perServiceEnvResolver(map, options?) ​

Reads each service's base URL from an environment variable. map pairs each typeName with the name of the env var holding its URL. This replaces hand-rolled env registries in boot code.

PerServiceEnvResolverOptions:

FieldTypeDescription
createTransport?(baseUrl: string) => TransportBuilds a transport from the resolved URL. Defaults to a gRPC (HTTP/2) transport.

A service with no mapping, or whose env var is unset, empty or whitespace-only, resolves to null (→ Code.Unavailable). URLs are trimmed. The variable is read when the resolver is consulted, not on every RPC after a route has been cached.

typescript
import { createServer, perServiceEnvResolver } from '@connectum/core';
import { OrdersService } from '#gen/orders/v1/orders_pb.ts';

// Reads process.env.ORDERS_URL at resolve time.
const server = createServer({
  services: [myRoutes],
  remoteResolver: perServiceEnvResolver({
    [OrdersService.typeName]: 'ORDERS_URL',
  }),
});

Endpoint hints ​

For services reachable at several endpoints (multi-region, blue/green, or a tenant-specific upstream), pass an opaque endpoint hint. It is forwarded to the resolver as ctx.endpoint and is part of the cache key. Different non-empty hints use separate cache entries; an absent hint and '' share an entry. An empty string counts as no endpoint: the resolver receives { typeName } without an endpoint property. Separate entries do not guarantee different transports: your resolver decides what to return. The hint is ignored for locally-mounted services and cannot force a remote route.

All four built-in resolvers ignore the hint. To select a region or tenant, supply a custom resolver with an explicit policy. If a hint comes from user input, validate it against allowed destinations rather than interpolating it into a URL.

From the unified client factory (ServerClientOptions):

typescript
const ordersEu = server.client(OrdersService, { endpoint: 'eu-west' });
const ordersUs = server.client(OrdersService, { endpoint: 'us-east' });

From inside a handler (CallOptions):

typescript
const inner = await ctx.call(
  'orders.v1.OrdersService/GetOrder',
  create(GetOrderRequestSchema, { id }),
  { endpoint: 'eu-west' },
);

Your resolver decides what the hint means:

typescript
import type { RemoteResolver } from '@connectum/core';
import { createGrpcTransport } from '@connectrpc/connect-node';

const regional: RemoteResolver = ({ typeName, endpoint }) => {
  if (typeName !== 'orders.v1.OrdersService') return null;
  const region = endpoint ?? 'eu-west';
  if (region !== 'eu-west' && region !== 'us-east') return null;
  const baseUrl = region === 'eu-west'
    ? 'https://orders.eu-west.example.com:8443'
    : 'https://orders.us-east.example.com:8443';
  return createGrpcTransport({ baseUrl });
};

The allow-list controls routing only; it does not replace authentication or authorization at the destination. Use non-empty, bounded hint values: every distinct hint that resolves to a transport adds another entry to the caller's cache. If your policy distinguishes '' from an absent hint, reject it before calling: their shared cache entry can bypass a second resolver lookup.

Composing resolvers ​

A resolver returning null is the natural delegation signal: write a composite that tries each resolver in order and takes the first non-null result. Because resolvers are synchronous, the composite is a plain loop.

typescript
import type { RemoteResolver } from '@connectum/core';

/** Try each resolver in order; first non-null wins, null if all miss. */
function fallback(...resolvers: RemoteResolver[]): RemoteResolver {
  return (ctx) => {
    for (const resolve of resolvers) {
      const transport = resolve(ctx);
      if (transport) return transport;
    }
    return null;
  };
}

// Explicit overrides first, DNS convention as the catch-all.
const server = createServer({
  services: [myRoutes],
  remoteResolver: fallback(
    mapResolver({ [OrdersService.typeName]: ordersOverride }),
    dnsResolver({ template: 'http://{shortName}.prod.svc.cluster.local:50051' }),
  ),
});

Order dnsResolver last in such a chain — when its transport factory succeeds, it returns a transport, so any resolver after it is unreachable. The composite falls through on null, not on exceptions or failed RPCs: neither is automatic failover to the next resolver.

Testing with mocks ​

@connectum/testing (not @connectum/core) provides a resolver and a context helper for serving canned, in-process responses with no network hop.

mockResolver([mockService(Service, impl)]) builds a RemoteResolver that serves each mocked service in-process and returns null for anything not mocked — so it composes with real resolvers via the null-fallback pattern above. Every mock response carries the response header MOCK_RESPONSE_HEADER ("x-connectum-mock") set to "true", so a test can prove the call was served by a mock rather than a real transport.

typescript
import { create } from '@bufbuild/protobuf';
import { createServer } from '@connectum/core';
import { mockResolver, mockService, MOCK_RESPONSE_HEADER } from '@connectum/testing';
import { InventoryService, GetStockRequestSchema, StockSchema } from '#gen/inventory/v1/inventory_pb.ts';

const server = createServer({
  services: [],
  remoteResolver: mockResolver([
    mockService(InventoryService, {
      getStock: () => create(StockSchema, { units: 7 }),
    }),
  ]),
});

// The mock tag is a *response header*. Read it via the connect client's
// header hook — ctx.call surfaces only the message, not headers.
const client = server.client(InventoryService);
let servedByMock: string | null = null;
const stock = await client.getStock(
  create(GetStockRequestSchema, { sku: 'x' }),
  { onHeader: (h) => { servedByMock = h.get(MOCK_RESPONSE_HEADER); } },
);
// servedByMock === 'true'; stock.units === 7

To unit-test a handler's ctx.call / ctx.stream logic in isolation, createMockContext({ catalog, mocks }) builds a Context that drives the same dispatch path as a live request (a real Server is constructed with a mockResolver), so resolver lookup, cascade injection, interceptor composition, and error semantics all match production.

typescript
import { create } from '@bufbuild/protobuf';
import { defineCatalog } from '@connectum/core';
import { createMockContext, mockService } from '@connectum/testing';
import { InventoryService, StockSchema } from '#gen/inventory/v1/inventory_pb.ts';
import { CreateOrderSchema } from '#gen/orders/v1/orders_pb.ts';

const ctx = createMockContext({
  catalog: defineCatalog({ [InventoryService.typeName]: InventoryService }),
  mocks: [
    mockService(InventoryService, {
      getStock: () => create(StockSchema, { units: 7 }),
    }),
  ],
});

// Drive the handler directly with the mock context.
const res = await orderHandler(create(CreateOrderSchema, { sku: 'x' }), ctx);

CreateMockContextOptions also accepts outgoingInterceptors, requestHeader, timeoutMs, and propagateHeaders to reproduce production header propagation and the deadline cascade. The context describes the first method of the first service of the catalog as ctx.service, ctx.method and ctx.url; for a handler of another method pass method: 'orders.v1.OrderService/CreateOrder' (the ctx.call key format), and pass signal to give the handler an abort signal that cascades into its outgoing calls. A mock that throws a ConnectError answers with it unchanged; any other exception reaches the handler as a ConnectError with code Internal, the exception's message and the exception as cause. The chain runs on mock routes exactly as on resolver routes; an interceptor that tags the transport kind (such as the OpenTelemetry client interceptor) observes a mock route as http.

Transport-owned vs application-owned interceptors ​

The server's outgoingInterceptors own application policy: identity (a bearer signer), tracing (the OpenTelemetry client interceptor), and resilience (retry). They run once per call on every route, outside the interceptors of the transport the resolver returns, so the call's deadline budget starts before them and the transport receives the remaining budget.

The transport the resolver returns owns transport-specific middleware only: TLS and client certificates, compression, and a header that only one upstream understands. The framework cannot look inside a Transport, so it cannot detect or remove a policy you configured in both places; the policy simply runs twice (two client spans, two token-factory calls, retry amplification). If you decorated resolver transports with a signer or tracing interceptor to make remote routes work before 1.3.0, remove that copy — see section 6 of the migration guide.

Two things differ from a chain mounted on a transport. An interceptor in outgoingInterceptors sees req.url as https://catalog/<typeName>/<Method>, requestMethod: "POST", and none of the protocol headers (content-type, connect-timeout-ms), because a Transport does not expose its address and adds those itself; it also cannot see the transport's defaultTimeoutMs when it passes no timeoutMs. Policy that needs the wire request — signing over headers, an audience derived from the host — belongs on the resolver's transport, whose own interceptors see the real request.

Kubernetes, Istio, and service meshes ​

dnsResolver covers Docker Compose and Kubernetes service discovery directly: the template points at the service's DNS name (http://{shortName}.<namespace>.svc.cluster.local:<port>), and Kubernetes resolves it to the service's cluster IP. No external service registry is required.

When a mesh (Istio, Linkerd) or an Envoy sidecar is configured to intercept the connection, it can handle routing and mTLS according to its policies. The resolver can still point at the service DNS name; the resolver itself does not configure mesh identity, retries, or certificates.

For direct TLS, resolve an https:// URL and configure client trust and certificates on the transport returned by your createTransport factory. createServer({ tls }) configures inbound connections and does not configure the resolver's outgoing transport. Follow TLS and mTLS for the client and server settings.