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:
// 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
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.
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: 4194304The 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
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
| Protobuf | JavaScript |
|---|---|
field user_id | property userId (encoding also takes user_id) |
int32, uint32, sint32, fixed32, sfixed32, float, double | number |
int64, uint64, sint64, fixed64, sfixed64 | decimal string (encoding also takes number and bigint) |
bool, string | boolean, string |
bytes | Uint8Array (encoding also takes base64) |
| enum | value name (encoding also takes the number; unknown numbers decode as numbers) |
| message | object; google.protobuf.Timestamp is { seconds: '…', nanos: 0 } |
repeated / map<K, V> | array / plain object with string keys |
oneof kind | the 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:
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 andextendare read and ignored; groups and editions are rejected. No server reflection, health service or gRPC-Web.