@fitfak/grpc
v2.0.0
Published
Zero-dependency gRPC server, client, reflection and health checking for Node.js (part of the FITFAK stack)
Maintainers
Readme
@fitfak/grpc
A gRPC stack for Node.js with zero npm dependencies — every module uses only
node:http2, node:tls, node:zlib, node:crypto and node:events.
protobuf.js proto3 wire codec (varint/zigzag/packed/nested/repeated)
wire.js gRPC framing, compression negotiation, grpc-timeout, grpc-web trailers
status.js status codes, GrpcError, grpc-message percent encoding
peer.js TLS peer identity, security levels, RFC 9266 channel binding
grpc-server.js HTTP/2 server: unary, server/client/bidi streaming, deadlines, mTLS gating
client.js HTTP/2 client: all four call kinds, retries, keepalive, bootstrap→mTLS upgrade
application.js GrpcApplication + registerController: schema generation, auth, RBAC
reflection.js grpc.reflection.v1 and v1alpha (grpcurl, Postman)
health.js grpc.health.v1.Health (Check + Watch)
descriptor.js FileDescriptorProto construction for reflection
logger.js component-tagged structured logger with hex dump
smtp-service.js minimal SMTP-over-TLS sendernpm test runs four suites (107 checks) covering framing, all call kinds, mTLS and the
application layer.
1. Quick start
const { GrpcApplication } = require('@fitfak/grpc');
const app = new GrpcApplication({ packageName: 'custom.network' });
app.setAuthenticator(async (call) => verifyJwt(call.metadata.authorization));
app.registerController('VaultService', {
GetRecord: {
requiresAuth: true,
requiredPermissions: PERMISSIONS.READ_DATA,
request: [{ no: 1, name: 'id', type: 'string' }],
response: [{ no: 1, name: 'payloadJson', type: 'string' }],
handler: async (req, call) => ({ payloadJson: await load(req.id, call.user) }),
},
});
app.listen(8443, { tls: { key, cert, ca, requestCert: true, rejectUnauthorized: true } });Message types are generated as VaultService_GetRecordReq / VaultService_GetRecordRes,
which is the naming the hand-written schema maps in existing client code already use — so a
controller written this way is wire-compatible with them without a .proto file anywhere.
Client:
const { GrpcClient } = require('@fitfak/grpc');
const client = new GrpcClient('https://vault.internal:8443', {
credentials: { key: clientKeyPem, cert: clientCertPem, ca: caPem },
getMetadata: async () => ({ authorization: `Bearer ${await tokens.current()}` }),
});
const res = await client.unary('/custom.network.VaultService/GetRecord',
schemas, 'VaultService_GetRecordReq', 'VaultService_GetRecordRes', { id: '42' });2. Security levels, and why they exist
Every connection is classified from its TLS state, and every method can declare the minimum it will accept:
| Level | Meaning |
|---|---|
| none | plaintext h2c — no confidentiality, no peer identity |
| tls | the server proved its identity; the client did not |
| mtls | both sides presented certificates and the peer's chain validated |
app.registerController('EnrollmentService', {
Enroll: { minSecurityLevel: 'tls', /* ... */ }, // reachable before the client has a cert
});
app.registerController('DatabaseService', {
Insert: { minSecurityLevel: 'mtls', /* ... */ }, // data plane, mTLS only
});The check runs before the handler and before any middleware that might authenticate the caller some other way, so "this method is bootstrap-reachable" is a property of the method definition rather than something each handler has to remember.
To serve both channels on one port, configure the listener with requestCert: true and
rejectUnauthorized: false: every client is asked for a certificate, a client without one
still completes the handshake, and the per-stream classification does the rest. With
rejectUnauthorized: true the refusal happens during the handshake instead — earlier and
cheaper, but then no bootstrap endpoint can live on that port.
Verified vs. presented certificates
On a listener that accepts unauthenticated clients, a peer can present a certificate signed by
any CA — including its own — and Node returns it from getPeerCertificate() exactly as it
returns a valid one. call.peer.certificate is therefore populated only when the chain
validated, so reading call.peer.certificate.commonName can never hand you an
attacker-chosen string. The unverified form is available as call.peer.presentedCertificate
for logging, under a name that cannot be mistaken for a trusted one.
Channel binding
call.peer.channelBinding is the RFC 9266 tls-exporter value for the connection, and
client.channelBinding() is the same value on the other side. A bootstrap request signed over
it cannot be replayed onto a different TLS connection, which is what makes an enrolment
exchange safe on a channel where the client is not yet authenticated. It is null when the
negotiated TLS version cannot export keying material — an exchange that depends on it must
say so rather than silently proceeding unbound.
Upgrading a live client
TLS client certificates are chosen during the handshake, so a certificate cannot be added to
an existing connection. client.upgrade(credentials) establishes a new session with the new
credentials, waits until it is usable, and only then discards the old one — a failed upgrade
leaves the client on its previous working channel rather than disconnected.
const bootstrap = new GrpcClient(target, { credentials: { ca: trustAnchorPem } });
const identity = await enroll(bootstrap); // over the tls-only channel
await bootstrap.upgrade({ ...identity, ca: trustAnchorPem }); // now mTLS3. Protocol conformance notes
Things that are easy to get wrong, and what this implementation does about them:
- A DATA frame boundary is not a message boundary. Both directions accumulate and only emit complete messages. The naive version works until a message crosses a frame boundary.
grpc-timeouthas six units —H M S m u n. Omittingnsilently drops the deadline of any client that uses it, turning a bounded call into an unbounded one.- Trailers-only responses. A call that fails before producing a message replies with a
single HEADERS frame carrying
grpc-status. A client that reads the status only from thetrailersevent misses every fast failure, so this client reads both. - Message size limits are enforced from the declared length prefix, before the body is
buffered — otherwise a peer can name a 4 GiB length and force the allocation the limit was
meant to prevent. Over-limit is
RESOURCE_EXHAUSTED. - Compression is per message. A payload that grows under gzip is sent raw; a message
compressed with an unnegotiated algorithm is
UNIMPLEMENTED, not a decode error. grpc-messagepercent-encoding follows the spec grammar (%x20-%x24/%x26-%x7Eunescaped).encodeURIComponentover-escapes; it round trips, but it is not the grammar.- Unexpected handler throws become
INTERNAL. Passing an arbitrary runtime error through as, say,NOT_FOUNDwould make the caller's retry logic actively wrong. - Health checks report an unknown service as
NOT_FOUNDfromCheck(so a probe can tell "misconfigured" from "down") but asSERVICE_UNKNOWNfromWatch, per the spec. - Reflection answers
list_serviceson presence of the field, not on it being non-empty — clients legitimately send""— and lists the well-known services alongside user services.
4. Streaming
All four kinds are supported. Bidi handlers own their own termination — the framework does not end the call when the handler's promise resolves, because a bidi handler typically returns as soon as it has attached its listeners, and ending there would close the stream before the first message was exchanged.
app.registerController('DatabaseService', {
Watch: {
kind: 'server_stream', requiresAuth: true, minSecurityLevel: 'mtls',
request: [{ no: 1, name: 'collection', type: 'string' }],
response: [{ no: 1, name: 'payloadJson', type: 'string' }],
handler: async (req, call) => {
const unsubscribe = hub.subscribe(req.collection, (e) => {
if (call.isActive()) call.write({ payloadJson: JSON.stringify(e) });
});
await new Promise((resolve) => call.on('cancelled', resolve));
unsubscribe();
},
},
});call.isActive() is how a producing handler notices cancellation without waiting for a write
to fail.
5. Graceful shutdown
app.health.shutdown(); // report NOT_SERVING so load balancers drain us
await app.close({ graceMs: 10000 });close() stops accepting, lets in-flight streams finish within the grace period, then sends
GOAWAY and closes the sessions. Draining first is what makes it graceful; closing the sessions
afterwards is what makes it actually terminate — http2.Server#close() on its own waits
forever for a keep-alive client that never disconnects.
6. Relationship to @fitfak/database
@fitfak/database ships its own small gRPC layer so that it installs with zero dependencies
and can be used standalone. When both packages are present, mount the database services onto
an existing application instead of running a second listener:
const { createDatabaseServer } = require('@fitfak/database');
const dbServer = createDatabaseServer({ /* ... */ });
dbServer.attachTo(app); // shares this process's port, auth and middleware