Replies: 3 comments
-
@timostamm I was actually just about to create a new discussion but maybe it belongs here. I've been "struggling" with creating a simple/modular/reusable/typed gRPC server and I've gone through many iterations and options (Mali/Protocat/NestJS/gRPC-JS) and keep coming back to just wrapping the protobuf-ts generated grpc-js server. Really the main thing I need (which this adds) is typed middleware support. So far it seems to work well and is only 1 file. Thoughts on integrating this (or something similar, could prob be optimized) into the generator? EDIT: After looking at this some more there is probably a better way to do this via the generator, so im not sure this implementation applies but it would still be nice to support typed middleware. Thanks. Serverimport {
ChannelOptions,
Server,
ServerCredentials,
ServiceDefinition,
UntypedHandleCall,
UntypedServiceImplementation,
} from '@grpc/grpc-js';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type BaseHandleCall = (call: any, callback: any) => void;
export type BaseContext<
T extends BaseHandleCall = UntypedHandleCall,
K = unknown
> = {
readonly call: Parameters<T>[0];
readonly callback: Parameters<T>[1];
} & K;
export type Middleware<
T extends BaseHandleCall = UntypedHandleCall,
K = unknown
> = (
context: BaseContext<T, K>,
next: (nextContext?: Partial<K>) => Promise<void>
) => Promise<void> | void;
export type CreateContextFn<
T extends BaseHandleCall = UntypedHandleCall,
K = unknown
> = (
call: Parameters<T>[0],
callback: Parameters<T>[1]
) => Promise<BaseContext<T, K>>;
export type Handler<
T extends BaseHandleCall = UntypedHandleCall,
K = unknown
> = (context: BaseContext<T, K>) => Promise<void> | void;
type ServiceExtended<
K = unknown,
T extends UntypedServiceImplementation = UntypedServiceImplementation
> = {
[P in keyof T]:
| [...Middleware<T[P], K>[], Handler<T[P], K>]
| Middleware<T[P], K>[]
| Handler<T[P], K>;
};
export type GrpcServerOptions<
T extends BaseHandleCall = UntypedHandleCall,
K = unknown
> = {
createContext?: CreateContextFn<T, K>;
channelOptions?: ChannelOptions;
};
export type ExtractServiceInterface<T> = T extends ServiceDefinition<infer I>
? I
: UntypedServiceImplementation;
export class GrpcServer<
K = unknown,
T extends BaseHandleCall = UntypedHandleCall
> {
constructor({
createContext = async (call, callback) =>
({ call, callback } as BaseContext<T, K>),
channelOptions = {},
}: GrpcServerOptions<T, K> = {}) {
this.server = new Server(channelOptions);
this.address = '0.0.0.0:50051';
this.serverStack = [];
this.createContext = createContext;
}
private server: Server;
private serverStack: Middleware<T, K>[];
private address: string;
private createContext: CreateContextFn<T, K>;
public get port() {
return this.address.split(':').pop();
}
forceShutdown() {
return this.server.forceShutdown();
}
addService<SD extends ServiceDefinition = never>(
serviceDefinition: SD,
implementation: ServiceExtended<K, ExtractServiceInterface<SD>>
) {
const wrappedImplementation = Object.fromEntries(
Object.entries(implementation).map(([key, handlers]) => {
const stack = Array.isArray(handlers) ? handlers : [handlers];
const value = this.compose([...this.serverStack, ...stack]);
return [key, value];
})
);
this.server.addService(serviceDefinition, wrappedImplementation);
}
use(...newMiddleware: Middleware<T, K>[]) {
this.serverStack.push(...newMiddleware);
}
private compose(
handlers: [...Middleware<T, K>[], Handler<T, K>] | Middleware<T, K>[]
) {
return async (call: Parameters<T>[0], callback: Parameters<T>[1]) => {
const baseContext = await this.createContext(call, callback);
const dispatch = async (i: number, context: BaseContext<T, K>) => {
if (i === handlers.length - 1) {
await (handlers[i] as Handler<T, K>)({ ...context });
} else if (i < handlers.length - 1) {
await handlers[i]({ ...context }, (newContext?: Partial<K>) => {
return dispatch(i + 1, { ...context, ...newContext });
});
}
};
await dispatch(0, { ...baseContext });
};
}
// private handleError<T extends UntypedHandleCall = UntypedHandleCall>(
// error: Error,
// call: Parameters<T>[0],
// callback?: Parameters<T>[1]
// ) {
// if (callback) {
// callback(error);
// } else if (call instanceof Writable && call.writable) {
// call.destroy(error);
// }
// }
async start(
address: string = this.address,
credentials: ServerCredentials = ServerCredentials.createInsecure()
) {
this.address = address;
await new Promise<number>((resolve, reject) => {
this.server.bindAsync(address, credentials, (error, port) =>
error ? reject(error) : resolve(port)
);
});
this.server.start();
}
} Simple Exampleimport { UntypedHandleCall } from '@grpc/grpc-js';
import { PingResponse } from '../generated/grpc/main/v1/main';
import {
IMainService,
mainServiceDefinition,
} from '../generated/grpc/main/v1/main.grpc-server';
import { CallHandler, CreateContextFn, GrpcServer, Middleware } from './server';
type CustomContext = {
claims: { uid?: string; role?: string };
};
const createContext: CreateContextFn = async (call, callback) => {
return { call, callback, claims: {} };
};
const logger = (): Middleware => {
return async ({ call }, next) => {
const callHandlerPath = (call as any).call.handler.path;
console.log(`<- ${callHandlerPath}`);
await next();
console.log(`-> ${callHandlerPath}`);
};
};
const isUser: Middleware<UntypedHandleCall, CustomContext> = async (
{ claims },
next
) => {
if (claims.role === 'user') await next();
};
const ping: CallHandler<IMainService['ping'], CustomContext> = async ({
callback,
}) => {
callback(null, PingResponse.create({ message: 'OK' }));
};
async function main() {
const server = new GrpcServer({ createContext });
// global middleware
server.use(logger());
server.addService(mainServiceDefinition, {
// call specific middleware
ping: [isUser, ping],
});
await server.start(`0.0.0.0:50051`);
}
main(); Real-World Exampleimport exitHook from 'exit-hook';
import { config } from '../config';
import { mainServiceDefinition } from '../generated/grpc/main/v1/main.grpc-server';
import { createContext } from './context';
import { authentication } from './middlewares/authentication';
import { grpcLogger, telemetryLogger } from './middlewares/loggers';
import { canPushTelemetry, isUser } from './middlewares/rules';
import { handleTelemetry } from './rpcs/handle-telemetry';
import { listLocations } from './rpcs/list-locations';
import { ping } from './rpcs/ping';
import {
subscribeToLocation,
unsubscribeFromLocation,
} from './rpcs/subscriptions';
import { GrpcServer } from './server';
export async function createGrpcServer() {
const server = new GrpcServer({ createContext });
server.use(grpcLogger({ ignore: ['handleTelemetry'] }));
server.use(authentication);
server.addService(mainServiceDefinition, {
ping: [ping],
listLocations: [isUser, listLocations],
handleTelemetry: [telemetryLogger, canPushTelemetry, handleTelemetry],
subscribeToLocation: [isUser, subscribeToLocation],
unsubscribeFromLocation: [isUser, unsubscribeFromLocation],
});
exitHook(() => {
server.forceShutdown();
});
await server.start(`0.0.0.0:${config.PORT}`);
return server;
} |
Beta Was this translation helpful? Give feedback.
-
Thanks for the input, @danielmahon. I agree that In my mind, gRPC services should simply plug into express (being the de-facto standard server for Node.js). But since express does not yet support Node.js native http2, this will probably not be possible for quite a while... That said, your server looks really nice, thanks for sharing! For At Buf, we're mainly focused on the frontend developer story for now, but hearing from people serving gRPC with Node.js is very interesting for future plans. |
Beta Was this translation helpful? Give feedback.
-
First and foremost, it's the developer experience with generation. Even with Buf, you still need a TS plugin, a JS plugin, and a GRPC TS plugin. With protobuf-ts it's just one easy to configure plugin. Native promises and strongly typed async iterators is another huge win for developer experience. We had custom wrappers for the default plugin, which we never quite got perfectly typed. Being able to use plain JS objects, even if using I'm sure there are many other things that don't come to mind right away, but it's overall a significantly better experience, and I can't think of anything from the default plugin that I prefer. |
Beta Was this translation helpful? Give feedback.
-
Earlier this year, I started working for Buf. If you haven't tried it yet, you might want to give it a look :) As you are reading this discussion, you would probably agree that the JavaScript output of
protoc
leaves some things to be desired.I'm interested in hearing specifically what you dislike about
protoc
's JS generator, as well asprotoc-gen-grpcweb
.I'm also interested in hearing what you would want to see. If you like a feature of
ts-proto
orprotobuf-ts
(or any other alternative plugin), let me know.Beta Was this translation helpful? Give feedback.
All reactions