materpc
v1.5.0
Published
Microservice governance for gRPC in Node.js: discovery, load balancing, retries, circuit breakers, NestJS and OpenTelemetry.
Maintainers
Readme
materpc
Kitex-class microservice governance for gRPC in Node.js, on top of @grpc/grpc-js, with NestJS as the integration target.
materpc is for services that talk to each other over gRPC. It gives every call the
governance envelope that CloudWeGo Kitex
made standard in Go — a per-call RpcInfo, a fixed middleware order, service
discovery and load balancing with instance-level circuit breakers, explicit
retry and backup requests with chain-stop, rate limiting, timeouts with
Kitex's classification, three-bucket metadata propagation, stats events and
tracers, graceful lifecycle, generic calls — and leaves the protocol and the
transport to @grpc/grpc-js, the reference gRPC implementation for Node.js.
materpc owns no protocol code: two runtime dependencies (@grpc/grpc-js and
@bufbuild/protobuf), no connect-es.
Status:
1.0.0, implementing DESIGN.md v0.19. Any gRPC client, server or tool (grpcurl, grpc_health_probe, Kitex's gRPC transport, plain@grpc/grpc-js) is a first-class peer; the governance data materpc adds travels as gRPC custom metadata that other peers ignore.
Modules
Install one package: npm install materpc @bufbuild/protobuf. Integrations are
subpath exports, not separate npm packages. NestJS and OpenTelemetry dependencies
are optional; install them only when using those integrations.
For native ESM applications using materpc/nestjs, use NestJS 12 (verified with
12.0.1) and Node.js 22.12 or later. Keep the Nest core and platform packages on
matching versions. The published [email protected] declares Nest peers as >=11,
but its NestJS 11 ESM entry fails to resolve extensionless Nest constant imports.
NestJS 12 provides the required export mappings; NestJS 11 CommonJS loading is
unaffected. These restrictions apply to the Nest adapter, not the plain RPC core.
| Import path | What it is |
|---|---|
| materpc | the framework: governance client and server over @grpc/grpc-js, in-process endpoint, generic calls, the standard gRPC reflection and health services. Runtime dependencies: @grpc/grpc-js, @bufbuild/protobuf (peer). |
| materpc/nestjs | MaterpcServerStrategy, @RpcService, @RpcMethod, @InjectRpcClient, MaterpcModule, MaterpcClientModule, exception filter, terminus health indicator |
| materpc/otel | OpenTelemetry tracer, metrics, context propagation, log correlation |
| materpc/testing | in-process harness, mock resolver, fault injection, chaos server, fake clock |
| materpc/cli | materpc new / gen / doctor / dump / call |
| materpc/contrib-consul, -etcd, -nacos, -k8s | resolvers, registries and config sources, on the shared materpc/contrib HTTP client (keep-alive, deadlines, token refresh, TLS) |
Quick start
pnpm add materpc @bufbuild/protobuf
pnpm add -D @bufbuild/buf @bufbuild/protoc-gen-esGenerate message and service descriptors with protoc-gen-es (v2, target=ts,
import_extension=js). materpc generates no code of its own: Client<T> and
ServiceImpl<T> are mapped types over the protobuf-es DescService.
// server.ts
import { createServer, registerHealth, registerReflection } from "materpc";
import { EchoService } from "./gen/echo/v1/echo_pb.js";
const server = createServer({
serviceName: "echo",
address: { host: "0.0.0.0", port: 9000 },
limits: { maxQps: 5000, maxConcurrency: 1000 },
shutdown: { exitWaitMs: 5000, handleSignals: true },
// With a `registry`, a wildcard bind is registered as a local interface
// address, because `0.0.0.0` resolves to the caller's own host. Pin it where
// that choice matters: `registryInfo: { address: `${process.env.POD_IP}:9000` }`.
});
server.register(EchoService, {
say: (req, ctx) => ({ sentence: req.sentence, peer: ctx.peer ?? "" }),
async *count(req, ctx) {
for (let i = 1; i <= req.upto && !ctx.signal.aborted; i++) yield { n: i };
},
// ... client-streaming and bidi methods take/return AsyncIterables
});
registerReflection(server); // grpc.reflection.v1.ServerReflection (+ v1alpha): grpcurl works
registerHealth(server); // grpc.health.v1.Health: grpc_health_probe / Kubernetes gRPC probes work
await server.listen();grpcurl -plaintext -d '{"sentence":"hi"}' localhost:9000 echo.v1.EchoService/Say # any gRPC tool or client// client.ts
import { createClient, closeClient, BizStatusError } from "materpc";
import { EchoService } from "./gen/echo/v1/echo_pb.js";
const client = createClient(EchoService, {
destService: "echo",
target: "dns://echo.default.svc:9000", // static://, dns+srv://, unix://, or a Resolver
timeouts: { rpcTimeoutMs: 1000, connectTimeoutMs: 500 },
retry: { type: "failure", stopPolicy: { maxRetryTimes: 2 } }, // retry is always explicit
circuitBreaker: { service: { errRate: 0.5, minSample: 200 }, instance: {} },
});
const res = await client.say({ sentence: "hi" }, { rpcTimeoutMs: 500 });
for await (const m of client.count({ upto: 3 })) console.log(m.n);
await closeClient(client); // or: await using client = createClient(...)With NestJS:
@Controller()
@RpcService(EchoService)
export class EchoController {
@UseGuards(AuthGuard)
say(@Payload() req: SayRequest, @Ctx() ctx: RpcContext) {
return { sentence: req.sentence };
}
}
const strategy = new MaterpcServerStrategy({ serviceName: "echo", address: { port: 9000 } });
const app = await NestFactory.create(AppModule); // AppModule imports MaterpcModule.forRoot({ strategies: [strategy] })
app.connectMicroservice<MicroserviceOptions>({ strategy }, { inheritAppConfig: true });
app.enableShutdownHooks();
await app.startAllMicroservices(); // RPC on :9000
await app.listen(3000); // HTTP on :3000 (optional, dual ports)Invariants
These are settled. Each one was established by a defect that shipped, and
several of them were re-broken by a later change that looked local and correct —
so they are written down here rather than only in the code that implements them.
A change that reverses one of them is a regression, whatever else it fixes; the
regression suites under packages/*/test/review-*.test.ts enforce them.
A terminal state does not come back. A deregistered registration generation, a retired circuit breaker, a released subscription: a result that arrives late for any of them changes no governance state and publishes no event. The object a late result holds is not the object the pool answers with.
"Failed" and "did not happen" are different answers. An HTTP error, a timeout or a dropped connection does not prove the remote write did not commit — etcd says so itself. An operation whose result is unknown stays unconfirmed and is resolved by reading the state back, never by assuming the cheaper of the two.
Re-sending is only safe when this hop refused the request.
server_refused,get_connectionandinstance_circuit_breakmean no user code ran here, so materpc may move the call to another instance without any retry policy. That includes an HTTP/2REFUSED_STREAM, which the protocol defines as "not processed" and which everyGOAWAYproduces for the streams a client had already opened — the few milliseconds of a rolling restart.server_draining(a handler the shutdown aborted),connection_lostand every remote failure mean the callee may have run all, part or none of it, and are never re-sent on the caller's behalf. This safety is hop-local, and structurally so: only the request gate's own refusal may put a re-pickable kind on the wire, so a service relaying a downstream's refusal reportsremote_or_networkupward, because its own handler may have run. Blame is hop-local in the same way: a kind the caller charges to its instance breaker — an overload rejection, a drain — is degraded toremote_or_networkwhen this hop is only relaying a peer's status, because a relay forwards its callee's overload identically from every replica and would otherwise have all of them ejected. Explicitretry/backuppolicies stay entirely the caller's decision, per method.Sharing an object is not sharing a policy. A
RetryContainer, aCircuitBreakerSuiteor a resolver may be shared between clients — that is what preserving their identity is for — but dynamic configuration is published per(config source, from, to)and is kept per that triple. Where it cannot be separated, the conflict is reported, not resolved by "last callback wins".Observability follows the real lifecycle, not the wire. An RPC is finished exactly once, and every admission failure — rate limit, ACL, metadata, deadline policy, draining — is a complete RPC with a start, a status and a duration. The concurrency budget is separate from that: it is released when the actual work has settled, including an asynchronous
finallythat runs after the response has already gone out.A capacity limit must bound the resource it names. A parameter existing is not a resource being bounded:
grpc-node.max_session_memorybounds what Node accounts for a session, not grpc-js's own partial-message buffers, which is whylimits.maxReceivingandlimits.receiveTimeoutMsexist. Health probes keep a budget the long-lived management streams cannot spend.Health is one derived state. What the application set, bounded by the lifecycle (listening, registered, draining), computed in one place and published to every subscribed service name on every transition.
Check,ListandWatchcannot disagree, and no lifecycle step overwrites a status the application set on purpose.Start-up and shutdown are one ordered sequence each. A failed
listen()leaves the process as a never-started one: no port, no watches, no probes, no registration intent left uncompensated.drain()andclose()run the same steps in the same order, and the propagation window opens when the deregistration has finished, not when it was sent. The exit wait belongs to the business handlers: the built-in health and reflection streams end with the GOAWAY, one step earlier, so a single health watcher cannot make every shutdown sit out the whole budget.A duration that cannot be expressed is not a duration of zero. Above
2^31-1ms a Node timer fires after one millisecond, so "a very large number means unlimited" — including a value the control plane publishes — must be read as no deadline, never as an immediate one; waits are clamped instead. A deadline this process treats as unlimited is not announced to the peer either. Durations are measured on a monotonic clock, so a backward step of the wall clock cannot make a limiter shed every request.What you publish is not what you bind, and a bound is a bound. A wildcard bind (
0.0.0.0,::) is the default and has no address a peer can dial — it resolves to the caller's own host — so a registration substitutes a local interface address;registryInfo.addressalways wins. In the same spirit,closeWaitMsandconnection.drainGraceMsend the calls that outlast them: grpc-js's own close is graceful, so a number the framework documents as an upper bound has to be enforced rather than assumed.Unknown is not withdrawn. A control plane that cannot be read has said nothing, so the code configuration stands and the read is retried; a chained source folds in the members that answered rather than failing as one. The other half of the same rule: whatever the control plane can configure exists before it does — a client with a config source has a breaker suite, a server has its limiters — or the policy it publishes has nothing to reach.
Code you do not own cannot end your loop. A background worker — a heartbeat, a debounce, a watch, a config subscriber — reports through a user hook, a logger and a serializer it did not write, usually from a promise nobody awaits. Every one of those calls is guarded, or one bad hook becomes an unhandled rejection and ends the process during the very outage that made it run. In the same spirit, a generation that fails to register never takes the working one's heartbeat with it.
A release a call did not reach is a counted event, not a quiet success. Version routing picks a track first and load-balances inside it, and every way a call can end up somewhere else — no tracks published, an empty track, zero weights, a track whose every instance failed inside this call, a custom picker that ignored the subset — is an enumerated reason with a metric and a diagnostic behind it. A canary that looks healthy because every call quietly fell back to stable would be promoted on evidence gathered from the code it was supposed to be compared against, and nothing else in the system would say so. The counting is unconditional; the logging is rate-limited, because a persisting fallback is true of every call.
Which release a call belongs to is decided once, and is not the caller's to assert. The cohort is fixed on the first attempt and every retry keeps it — re-rolling would produce a sample that counts for both tracks in opposite directions. It crosses at most one hop, only when propagation is explicitly enabled, and a callee adopts it only from a caller configured as trusted: otherwise one header from outside would place a request on a pre-release build.
Documentation
- DESIGN.md — the design document (architecture, the grpc-js adapter, governance semantics, decision log)
- docs/guides/getting-started.md
- docs/guides/governance.md — discovery, load balancing, timeouts, retry, circuit breaking, limits, ACL, fallback, metadata
- docs/guides/grpc.md — the grpc-js transport: what it owns, option mapping, custom metadata, business errors, interoperability
- docs/guides/nestjs.md
- docs/guides/observability.md
- docs/guides/performance.md — per-call cost, the three limits a single instance runs into, running on more than one core
- docs/guides/release-routing.md — release tracks, cohorts, the trust boundary, the control-plane config source, and the Kubernetes shutdown budget
- docs/guides/generic-and-reflection.md
- docs/guides/testing.md
- docs/guides/cli.md
- docs/guides/contrib.md
Repository
pnpm install
pnpm test # framework behavior and integration tests
pnpm typecheck
pnpm lint
pnpm build
pnpm benchExamples live in examples/: a plain Node server and client (with a
file-based ConfigSource and Consul discovery behind an environment variable),
a NestJS service (dual ports) and a NestJS HTTP client that injects a materpc
client. Each example owns its proto/ and regenerates src/gen/ with pnpm gen.
License
Apache-2.0. See NOTICE for the design sources acknowledged.
