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

better-grpc

v0.3.2

Published

Simple, typed gRPC for TypeScript

Readme

Banner

better-grpc

Simple, typed gRPC for TypeScript

TypeScript License Discord

better-grpc is a TypeScript-first gRPC library that focuses on developer experience and type safety. It eliminates the need for .proto files and code generation, allowing you to define your services entirely in TypeScript.

It enables seamless, bidirectional communication between a client and a server, allowing developers to call server-side functions from the client and client-side functions from the server, as if they were local.

Features

  • Type-Safe: Define your services in TypeScript and get full type safety and autocompletion for your clients and servers.
  • No .proto files: No need to write .proto files or use protoc to generate code.
  • Simple API: The API is designed to be simple and intuitive.
  • Symmetric Experience: Call client-side functions from the server with the same syntax as calling server-side functions from the client.
  • Multi-Client Support: Target specific clients from the server using client IDs, enabling per-client communication patterns.

Installation

bun add better-grpc
# or
npm install better-grpc
# or
yarn add better-grpc

Usage

1. Define a Service

Create an abstract class that extends Service to define your service. Use the server and client helpers to define where your function is implemented and executed.

import { Service, client, server, bidi } from 'better-grpc';

abstract class MyService extends Service('MyService') {
    // This function is implemented and executed on the server.
    sayHello = server<(name: string) => string>();

    // This function is implemented and executed on the client.
    log = client<(message: string) => void>();

    // This function supports bidirectional streaming between client and server.
    chat = bidi<(message: string) => void>();
}

2. Implement the Service

Provide the implementations for the functions you defined for both the server and the client.

// Server-side implementation
const myServiceImpl = MyService.Server({
    async sayHello(name: string) {
        return `Hello, ${name}!`;
    },
});

// Client-side implementation
const myClientImpl = MyService.Client({
    async log(message: string) {
        console.log(`[Server]: ${message}`);
    }
});

3. Create a Server

Create and start the server, passing in your service implementation.

import { createGrpcServer } from 'better-grpc';

const server = await createGrpcServer(50051, myServiceImpl);
console.log('Server listening on port 50051');

4. Create a Client

Create a client for your service.

import { createGrpcClient } from 'better-grpc';

const client = await createGrpcClient('localhost:50051', myClientImpl);

By default, SSL/TLS is auto-detected based on the address (SSL for non-localhost addresses). You can explicitly specify credentials:

// Force SSL/TLS connection
const client = await createGrpcClient('my-server.com:50051', 'ssl', myClientImpl);

// Force insecure (plaintext) connection
const client = await createGrpcClient('my-server.com:50051', 'insecure', myClientImpl);

You can also override gRPC channel options (defaults are exported as DEFAULT_OPTIONS):

import { createGrpcClient, DEFAULT_OPTIONS } from 'better-grpc';

const client = await createGrpcClient(
    'localhost:50051',
    {
        ...DEFAULT_OPTIONS,
        'grpc.keepalive_time_ms': 15000,
    },
    myClientImpl
);

5. Make remote calls

Now you can call remote functions from both the client and the server.

// On the client, call the server's `sayHello` function
const response = await client.MyService.sayHello('world');
console.log(response); // Outputs: 'Hello, world!'

// On the server, call client's `log` function
await server.MyService.log('Greeting from server');
// The client's console will show: '[Server]: Greeting from server'

6. Use bidirectional streams

Bidirectional gRPCs expose a function that both emits values (when you invoke it) and acts as an async iterator so you can consume the opposite side's messages.

// Client usage
await client.MyService.chat('hello from client'); // emit to the server

for await (const [message] of client.MyService.chat) {
    console.log('Server replied:', message);
    break;
}

// Server usage mirrors the client
await server.MyService.chat('hello from server'); // emit to the client

for await (const [message] of server.MyService.chat) {
    console.log('Client replied:', message);
    break;
}

7. Listen for bidi connections on the server

The server can use the .listen() API to handle incoming bidi stream connections. This is useful for setting up handlers that respond to each client connection:

server.MyService.chat.listen(({ context, messages, send }) => {
    console.log(`New client connected ${context.client.id}`);
    
    (async () => {
        for await (const [message] of messages) {
            console.log('Received:', message);
            await send(`Echo: ${message}`);
        }
    })();
});

The listen handler receives:

  • context: A promise that resolves to the connection context (including metadata if defined)
  • messages: An async generator yielding incoming messages from the client
  • send: A function to send messages back to the client

8. Target specific clients

When multiple clients are connected, the server can target a specific client using its client ID. Each client is automatically assigned a unique ID.

Getting the client ID on the client side:

const client = await createGrpcClient('localhost:50051', myClientImpl);

// Access the client's unique ID
console.log(client.clientID); // e.g., 'abc123-def456-...'

Getting the client ID on the server side:

In server handlers, the client ID is available via context.client.id:

const GreeterServerImpl = GreeterService.Server({
    greet: (name) => async (context) => {
        console.log('Client ID:', context.client.id);
        return `Hello, ${name}!`;
    },
});

Targeting a specific client from the server:

// Call a specific client by ID
const clientId = 'some-client-id';
await server.MyService(clientId).log('Message for specific client');

// For bidi streams, targeting a specific client
await server.MyService(clientId).chat('hello to specific client');

By default, server calls target the first connected client. When you need to communicate with a specific client (e.g., in a multi-client scenario), use the client ID selector.

9. Attach typed metadata

Define metadata requirements with Zod schemas, and better-grpc will automatically type the context on both sides and marshal the payload into gRPC metadata.

import { Service, server, bidi } from 'better-grpc';
import { z } from 'zod';

abstract class GreeterService extends Service('GreeterService') {
    greet = server<(name: string) => string>()({
        metadata: z.object({ requestId: z.string() }),
    });

    chat = bidi<(message: string) => void>()({
        metadata: z.object({ room: z.string() }),
    });
}

Server implementations can optionally return a context-aware function. The outer function receives the request args, and the returned function receives the typed context:

const GreeterServerImpl = GreeterService.Server({
    greet: (name) => async (context) => {
        console.log('Request', context.metadata.requestId);
        return `Hello, ${name}!`;
    },
});

On the client, unary calls that require metadata expose a .withMeta() helper, and bidi streams provide a .context() helper that must be awaited and called before sending messages (the bidi stream will be established after calling .context()):

await client.GreeterService.greet('Ada').withMeta({ requestId: crypto.randomUUID() });

await client.GreeterService.chat.context({
    metadata: { room: 'general' },
});
// you must provide the context before calling the bidi function;
// otherwise, it will continue to wait.
await client.GreeterService.chat('hello from client');

On the server side, the bidi function expose a .context value that can be used to access metadata:

const chatContext = await server.GreeterService.chat.context;
console.log(chatContext.metadata.room); // 'general'

Why better-grpc?

The traditional workflow for creating gRPC services with TypeScript involves writing .proto files, using protoc to generate TypeScript code, and then using that generated code. This process can be cumbersome and result in a disconnect between your service definition and your code.

better-grpc solves this problem by allowing you to define your services entirely in TypeScript. This has several advantages:

  • Single Source of Truth: Your service definition lives in your TypeScript code, right next to your implementation.
  • Improved Type Safety: Leverage TypeScript's powerful type system for excellent autocompletion and type safety across your client and server.
  • Simplified Workflow: No more .proto files, no more code generation. Just write TypeScript.
  • Symmetric Communication: The server can invoke client functions with the same ease that the client invokes server functions, enabling powerful, bidirectional communication patterns.

API

  • Service(name: string)

A factory function that creates an abstract service class.

  • server<T>()

Defines a server-side unary function signature. T should be a function type. Call the returned descriptor with ({ metadata: z.object({...}) }) to require typed metadata for that RPC. Client code then calls client.MyService.fn(...args).withMeta({...}), and server handlers can return a function to receive the context (or just return a value if they don't need it).

  • client<T>()

Defines a client-side unary function signature. T should be a function type.

  • bidi<T>()

Defines a bidirectional stream signature. T should be a function type that returns void. Like server(), you can pass ({ metadata: schema }) to type the attached metadata; client stubs expose bidiFn.context({ metadata }) and server stubs expose await bidiFn.context to read it.

  • createGrpcServer(port: number, ...services: ServiceImpl[])

Creates and starts a gRPC server. Returns service callables that can be invoked directly or with a client ID selector: server.MyService.method() or server.MyService(clientId).method().

  • createGrpcClient(address: string, ...services: ServiceImpl[])

Creates and starts a gRPC client using DEFAULT_OPTIONS. SSL/TLS is auto-detected based on the address (SSL for non-localhost addresses).

  • createGrpcClient(address: string, credentials: "ssl" | "insecure", ...services: ServiceImpl[])

Creates and starts a gRPC client with explicit credential mode. Use "ssl" for TLS or "insecure" for plaintext connections.

  • createGrpcClient(address: string, options: ChannelOptions, ...services: ServiceImpl[])

Creates and starts a gRPC client with custom gRPC channel options. DEFAULT_OPTIONS is exported for easy overrides.

  • createGrpcClient(address: string, credentials: "ssl" | "insecure", options: ChannelOptions, ...services: ServiceImpl[])

Creates and starts a gRPC client with both explicit credentials and custom channel options.

Server-side bidi listen

For bidi streams, the server exposes a .listen() method to handle incoming connections:

server.MyService.bidiFn.listen((connection) => {
    // connection.context: Promise<Context> - resolves to typed context/metadata
    // connection.messages: AsyncGenerator - incoming messages from client
    // connection.send: Function - send messages to the client
});

Deployment

If you deploy behind Traefik (including Dokploy), make sure the entrypoint timeouts allow long-lived HTTP/2 streams. Otherwise, bidi streams can be cancelled around the default timeout window.

This is a static Traefik setting (not the dynamic http: config). Add this to your Traefik config and reload:

entryPoints:
  websecure:
    address: :443
    transport:
      respondingTimeouts:
        readTimeout: 0s
        writeTimeout: 0s
        idleTimeout: 0s

Benchmarks

Simple "Hello World"

[!NOTE] This benchmark's server and client were run on same local machine.

tRPC

tRPC: 1543.021833ms

Elysia

Elysia: 128.935791ms

better-grpc

better-grpc: 126.681042ms

License

This project is licensed under the MIT License. See the LICENSE file for details.