Skip to content

lucaconlaq/grape-cola

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

7 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Grape Cola

Grape Cola

npm biome vitest

What it is β€” a thin wrapper that gives grpc-js a better DX.

What it isn't β€” a new protocol. Any gRPC client connects as usual.

Quick start

1. Create a handler

A handler is a factory that carries shared context and middleware. Every RPC you define goes through it.

import {
  createHandler,
  GrpcCall,
  injectGRPCLoggingContext,
  withClock,
  withGRPCLogging,
  withLogger,
} from "@lucaconlaq/grape-cola";

// inject functions extract values from the raw gRPC call into context
const injectAuthContext = (call?: GrpcCall) => ({
  authorization: call?.metadata.get("authorization")[0]?.toString(),
});

const handler = createHandler({
  injectInContext: [injectGRPCLoggingContext, injectAuthContext],
  defaultMiddleware: [withLogger(), withClock(), withGRPCLogging()],
});

2. Define RPCs

Use the builder chain to set request/reply types and pick the RPC kind:

const sayHello = handler()
  .request(HelloRequest)
  .reply(HelloReply)
  .unary(async ({ req }) => {
    const reply = new HelloReply();
    reply.setMessage(`hello, ${req.getName()}!`);
    return reply;
  });

const chatHello = handler()
  .request(HelloRequest)
  .reply(HelloReply)
  .bidiStream(async ({ ctx, call }) => {
    for await (const req of call) {
      const reply = new HelloReply();
      reply.setMessage(`${ctx.message}, ${req.getName()}!`);
      call.write(reply);
    }
  });

All four gRPC patterns are supported: .unary(), .serverStream(), .clientStream(), .bidiStream().

3. Group RPCs into services

createService validates your handler map against the generated ServiceDefinition at compile time β€” missing methods, wrong message types, or mismatched streaming kinds are type errors.

Given a proto file like:

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply);
  rpc SayHelloReversed (HelloRequest) returns (HelloReply);
  rpc StreamHello (HelloRequest) returns (stream HelloReply);
  rpc CollectHellos (stream HelloRequest) returns (HelloReply);
  rpc ChatHello (stream HelloRequest) returns (stream HelloReply);
}

You wire up all the handlers:

import { createService } from "@lucaconlaq/grape-cola";
import { GreeterService } from "./gen/greeter_grpc_pb";

const greeterService = createService<typeof GreeterService>({
  sayHello,
  sayHelloReversed,
  streamHello,
  collectHellos,
  chatHello,
});

4. Start the server

import { createServer } from "@lucaconlaq/grape-cola";

const server = createServer({
  handler,
  onStart: ({ ctx, error }) => {
    if (error) throw error;
    ctx.logger.info("πŸš€ listening on 0.0.0.0:50051");
  },
});

server.addService(GreeterService, greeterService);
server.addService(AuthService, authService);
await server.listen("0.0.0.0", 50051);

πŸ”Œ Context & middleware

Grape Cola builds a typed context that flows through every request. The context is assembled in two layers:

Inject functions

injectInContext functions run first. They receive the raw gRPC call and extract values (metadata headers, path, etc.) into the base context:

const injectAuthContext = (call?: GrpcCall) => {
  const authorization = call?.metadata.get("authorization")[0]?.toString();
  return { authorization };
};

Middleware

Default middleware runs next, sequentially. Each middleware receives the accumulated context and returns new fields to merge into it. TypeScript tracks the context shape at every step β€” if a middleware requires { logger: Logger }, the compiler ensures a previous middleware provides it.

import { middleware } from "@lucaconlaq/grape-cola";

const withLogger = () =>
  middleware<{ logger?: Logger }>()<{ logger: Logger }>(async ({ ctx }) => ({
    logger: ctx.logger ?? pino(),
  }));

Per-handler middleware

Individual RPCs can add extra middleware with .use(). This is useful for capabilities only some endpoints need (e.g. auth, database):

const login = handler()
  .use(withDb())                    // adds { db } to context
  .request(LoginRequest)
  .reply(LoginReply)
  .unary(async ({ req, ctx }) => {
    const user = ctx.db.findUser(req.getUsername());
    // ...
  });

const saySecretGreeting = handler()
  .use(withDb())                    // adds { db }
  .use(withAuth())                  // adds { user } β€” requires { db, authorization }
  .request(SecretRequest)
  .reply(SecretReply)
  .unary(async ({ req, ctx }) => {
    // ctx.user is fully typed here
  });

After functions ♻️

middlewareWithAfter lets a middleware return a function that runs after the handler completes, in reverse middleware order. Perfect for logging, tracing, or resource cleanup:

import { middlewareWithAfter, withAfter } from "@lucaconlaq/grape-cola";

const withLogging = () =>
  middlewareWithAfter<{ clock: Clock; logger: Logger }>()(async ({ ctx }) => {
    const startedAt = ctx.clock.now();
    return withAfter({}, () => {
      const duration = ctx.clock.now() - startedAt;
      ctx.logger.info(`completed in ${duration}ms`);
    });
  });

🧩 Built-in middleware

Middleware Adds to context Peer deps
withClock() clock β€” injectable sleep + now abstraction. Use immediateClock in tests. β€”
withLogger() logger β€” pino logger instance pino, pino-pretty
withGRPCLogging() logs request duration and path pino

All three preserve existing context values, so tests can inject fakes. They compose together like this:

import {
  createHandler,
  injectGRPCLoggingContext,
  withClock,
  withGRPCLogging,
  withLogger,
} from "@lucaconlaq/grape-cola";

const handler = createHandler({
  injectInContext: [injectGRPCLoggingContext],
  defaultMiddleware: [withLogger(), withClock(), withGRPCLogging()],
});

πŸ§ͺ Testing

Grape Cola ships two utilities that let you run handlers and middleware without starting a gRPC server.

testHandler

Runs a resolved handler in isolation. Pass a baseCtx to satisfy middleware requirements, and a req (or streamInput for streaming RPCs):

import { testHandler } from "@lucaconlaq/grape-cola";

// unary
const { reply } = await testHandler(greeterService.sayHello, {
  baseCtx: createBaseCtx(),
  req: makeHelloRequest("world"),
});
expect(reply.getMessage()).toBe("hello, world!");

// server stream
const { output } = await testHandler(greeterService.streamHello, {
  baseCtx: createBaseCtx(),
  req: makeHelloRequest("world"),
});
expect(output).toHaveLength(3);

Since middleware reads from context, you can swap in test doubles by overriding baseCtx. For example, an immediateClock that resolves sleep() instantly:

const { output } = await testHandler(greeterService.streamHello, {
  baseCtx: createBaseCtx({ clock: immediateClock }),
  req: makeHelloRequest("world"),
});
// no actual waiting β€” the handler runs instantly

testMiddleware

Tests a single middleware in isolation. Returns the merged ctx and, for middlewareWithAfter, the after cleanup function:

import { testMiddleware } from "@lucaconlaq/grape-cola";

const { ctx } = await testMiddleware(withLogger(), {
  baseCtx: {},
});
expect(ctx.logger).toBeDefined();

// with after function
const { after } = await testMiddleware(withLogging(), {
  baseCtx: createBaseCtx({ logger: spyLogger }),
});
after!();
expect(spyLogger.messages).toHaveLength(1);

Typical test helper

A small helper that pre-fills the base context keeps tests concise:

const createBaseCtx = (overrides?: Record<string, unknown>) => ({
  logger: pino({ level: "silent" }),
  clock: immediateClock,
  message: "hello",
  request: { id: "test-123", path: "/TestService/testMethod" },
  ...overrides,
});

About

A thin wrapper that gives `grpc-js` a better DX.

Topics

Resources

Stars

Watchers

Forks

Contributors