npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

materpc

v1.5.0

Published

Microservice governance for gRPC in Node.js: discovery, load balancing, retries, circuit breakers, NestJS and OpenTelemetry.

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-es

Generate 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.

  1. 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.

  2. "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.

  3. Re-sending is only safe when this hop refused the request. server_refused, get_connection and instance_circuit_break mean no user code ran here, so materpc may move the call to another instance without any retry policy. That includes an HTTP/2 REFUSED_STREAM, which the protocol defines as "not processed" and which every GOAWAY produces for the streams a client had already opened — the few milliseconds of a rolling restart. server_draining (a handler the shutdown aborted), connection_lost and 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 reports remote_or_network upward, 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 to remote_or_network when 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. Explicit retry / backup policies stay entirely the caller's decision, per method.

  4. Sharing an object is not sharing a policy. A RetryContainer, a CircuitBreakerSuite or 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".

  5. 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 finally that runs after the response has already gone out.

  6. A capacity limit must bound the resource it names. A parameter existing is not a resource being bounded: grpc-node.max_session_memory bounds what Node accounts for a session, not grpc-js's own partial-message buffers, which is why limits.maxReceiving and limits.receiveTimeoutMs exist. Health probes keep a budget the long-lived management streams cannot spend.

  7. 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, List and Watch cannot disagree, and no lifecycle step overwrites a status the application set on purpose.

  8. 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() and close() 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.

  9. A duration that cannot be expressed is not a duration of zero. Above 2^31-1 ms 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.

  10. 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.address always wins. In the same spirit, closeWaitMs and connection.drainGraceMs end 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.

  11. 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.

  12. 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.

  13. 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.

  14. 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

Repository

pnpm install
pnpm test        # framework behavior and integration tests
pnpm typecheck
pnpm lint
pnpm build
pnpm bench

Examples 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.