Skip to content

Cell - gRPC Component ​

gRPC servers and clients for Cell on node:http2, with a built-in .proto parser and protobuf codec: no third-party dependency, no code generation. Unary, server-streaming, client-streaming and bidirectional methods; interoperable with other gRPC implementations (tested against @grpc/grpc-js in both directions).

Define the service ​

Keep the .proto file in the sources and parse it once. With Vite (cell build, cell dev, createTestApp()), a ?raw import bundles its text:

ts
// src/greeter.ts
import { Proto, type UnaryCall, type ServerStreamCall } from '@celljs/grpc';
import source from './greeter.proto?raw';   // declare module '*?raw' once, see examples/grpc-app/src/raw.d.ts

export const Greeter = Proto.parse(source).service('helloworld.Greeter');

Proto.parse(...sources) takes every file the service needs (imports are resolved by type name across the sources); the google/protobuf well-known types (empty, timestamp, duration, wrappers, struct, any, field_mask) are built in. Names are full (helloworld.Greeter) or short when unambiguous. proto.message('HelloRequest') gives a message type with encode() / decode() for protobuf without gRPC.

Serve it ​

ts
import { GrpcError, GrpcService, GrpcStatus, type ServerCall } from '@celljs/grpc';
import { Greeter } from './greeter';

@GrpcService(Greeter)
export class GreeterService {

    @Autowired(UserService)                       // a component like any other
    protected readonly users: UserService;

    // rpc SayHello (HelloRequest) returns (HelloReply)
    sayHello({ name }: HelloRequest, call: ServerCall): HelloReply {
        if (!name) {
            throw new GrpcError(GrpcStatus.INVALID_ARGUMENT, 'name is required');
        }
        return { message: `Hello ${name}` };
    }

    // rpc SayHelloStream (HelloRequest) returns (stream HelloReply)
    async *sayHelloStream({ name }: HelloRequest): AsyncIterable<HelloReply> {
        yield { message: `Hello ${name}` };
    }

    // rpc SayHelloToAll (stream HelloRequest) returns (HelloReply)
    async sayHelloToAll(requests: AsyncIterable<HelloRequest>): Promise<HelloReply> { ... }

    // rpc Chat (stream HelloRequest) returns (stream HelloReply)
    async *chat(requests: AsyncIterable<HelloRequest>): AsyncIterable<HelloReply> { ... }
}

Import the file from a DI module (src/module.ts). When the application starts with at least one @GrpcService, GrpcServerLifecycle starts the gRPC server (gRPC serve 127.0.0.1:50051 in the log); it closes when the application stops. Methods are the .proto methods in lowerCamelCase (SayHello → sayHello); a method without implementation answers UNIMPLEMENTED. The types UnaryHandler, ServerStreamHandler, ClientStreamHandler and BidiStreamHandler describe the four signatures.

The last argument, call: ServerCall, has the request metadata (lowercase keys), a signal aborted when the client cancels or the deadline passes, the deadline, setHeader(key, value) (before the first response) and setTrailer(key, value).

Errors: throw a GrpcError(code, details, metadata?) to answer that status. Any other error answers UNKNOWN with the error's message and is logged.

yaml
grpc:
  server:
    enabled: true                 # false: no server even with @GrpcService components
    port: 50051                   # CELL_GRPC__SERVER__PORT; 0 takes a free port
    host: 127.0.0.1               # CELL_GRPC__SERVER__HOST=0.0.0.0 in containers
    maxReceiveMessageSize: 4194304

The gRPC server is a second port next to the HTTP one of @celljs/express-adapter; an application without HTTP needs no adapter (cell build then warns that no component provides the http entry, which is expected). Outside a Cell application, new GrpcServer(options).addService(Greeter, impl).listen(port, host) does the same, and server.handleStream(stream, headers) serves the streams of an HTTP/2 server of your own (e.g. http2.createSecureServer() for TLS).

Call it ​

ts
import { GrpcChannel } from '@celljs/grpc';

export interface GreeterClient {
    sayHello: UnaryCall<Partial<HelloRequest>, HelloReply>;
    sayHelloStream: ServerStreamCall<Partial<HelloRequest>, HelloReply>;
}

const channel = new GrpcChannel('localhost:50051', { metadata: { authorization: 'Bearer ...' }, timeout: 5000 });
const greeter = channel.createClient<GreeterClient>(Greeter);

const { message } = await greeter.sayHello({ name: 'Cell' });
for await (const reply of greeter.sayHelloStream({ name: 'Cell' })) { ... }   // break cancels the call
await greeter.sayHelloToAll([{ name: 'a' }, { name: 'b' }]);                 // any (async) iterable
await greeter.sayHello({ name: 'Cell' }, { timeout: 1000, signal, metadata: { 'x-trace-bin': bytes } });
channel.close();

A channel is one HTTP/2 connection, opened on the first call and reopened when lost; share it between clients of the same server. An idle channel does not keep the process alive. Failed calls reject with a GrpcError (code, details, trailing metadata): UNAVAILABLE when the server cannot be reached, DEADLINE_EXCEEDED, CANCELLED... A request that cannot be encoded rejects with a TypeError naming the field, before anything is sent.

Values ​

ProtobufJavaScript
field user_idproperty userId (encoding also takes user_id)
int32, uint32, sint32, fixed32, sfixed32, float, doublenumber
int64, uint64, sint64, fixed64, sfixed64decimal string (encoding also takes number and bigint)
bool, stringboolean, string
bytesUint8Array (encoding also takes base64)
enumvalue name (encoding also takes the number; unknown numbers decode as numbers)
messageobject; google.protobuf.Timestamp is { seconds: '…', nanos: 0 }
repeated / map<K, V>array / plain object with string keys
oneof kindthe set member, and kind: 'memberName'

Decoded messages have every field without explicit presence at its default (0, '', false, first enum value, [], {}); message fields, optional fields and oneof members are left out when not set. Unknown fields are skipped.

Testing ​

In cell.test.yml, backend.grpc.server.port: 0 gives the test application a free port; read it from the lifecycle the application loaded:

ts
const app = await createTestApp();
const { GrpcServerLifecycle } = await app.import('@celljs/grpc');
const channel = new GrpcChannel(`127.0.0.1:${app.get(GrpcServerLifecycle).port}`);

Limits ​

  • Runtimes: Node.js >= 18 and Deno 2 for servers and clients; Bun as a client, and as a server from 1.4 (the HTTP/2 cleartext server of earlier versions answers Bun clients only). Not on Cloudflare Workers or FaaS presets: the server needs a long-running process with its own port.
  • The server speaks cleartext HTTP/2 (h2c); terminate TLS in front of it, or serve handleStream() from your own TLS server.
  • Received messages may be compressed with gzip or deflate; sent messages are not compressed.
  • .proto: proto2 and proto3. Options, reserved, extensions and extend are read and ignored; groups and editions are rejected. No server reflection, health service or gRPC-Web.