go get github.com/AlexGalhardo/vukem/contrib/grpccontrib/grpc serves connect-go services from the app router. connect-go handlers are plain net/http, so gRPC, gRPC-Web and Connect share one port, the router's middleware (request id, logging, rate limiting, auth guards) and the app; there is no separate gRPC server.
func routes(r *router.Router, app *vukem.App) error {
opts := grpc.HandlerOptions(grpc.Options{
Rules: []grpc.Rule{grpc.Validate(z.Struct[greetv1.GreetRequest](z.Shape{"name": z.String().Min(1)}))},
})
path, h := greetv1connect.NewGreetServiceHandler(&greeter{}, opts...)
grpc.Mount(r, path, h).Use(grpc.Required(apiGuard), ratelimit.Middleware(limiter, ratelimit.ByToken))
grpc.Health(r, grpchealth.NewStaticChecker(greetv1connect.GreetServiceName))
grpc.Reflection(app, r, greetv1connect.GreetServiceName) // not in production
return nil
}
func (g *greeter) Greet(ctx context.Context, req *connect.Request[greetv1.GreetRequest]) (*connect.Response[greetv1.GreetResponse], error) {
user, err := auth.UserFrom[*models.User](ctx, "api")
if err != nil {
return nil, err
}
return connect.NewResponse(&greetv1.GreetResponse{Greeting: "Hello, " + user.Name}), nil
}Mounting and middleware#
grpc.Mount(r, path, h)registers the generated handler on a router or group and returns the route. Middleware added with.Useruns inside the error bridge: rejections (401, 403, 429 withRetry-After, 413, 503) reach the client as RPC errors with the matching code. Addgrpc.Errors()to a group to get the same mapping for group middleware.grpc.Required(guards...)isauth.Requiredfor RPCs:Unauthenticatedinstead of a login redirect. Methods read the user withauth.UserFrom(ctx, guard).grpc.HandlerOptions(grpc.Options{...})bundles request validation (Rules, plus messages with aValidate() errormethod), error mapping, onerpclog line per call, panic recovery and a 4 MiB message limit (MaxMessageBytes). YourInterceptorsrun inside these.- Avoid
middleware.TimeoutandGzipon RPC routes; use deadlines and connect-go compression.
Errors#
grpc.Error(err) maps errors to codes: context.Canceled/DeadlineExceeded as is; z.Issues and *errs.ValidationError → InvalidArgument with a google.rpc.BadRequest detail; *errs.HTTPError by status (400/422 InvalidArgument, 401 Unauthenticated, 403 PermissionDenied, 404 NotFound, 409 AlreadyExists, 429 ResourceExhausted, 503 Unavailable, …), with "internal error" as the message for any 5xx (the cause is logged, and only Retry-After becomes metadata); anything else → Internal "internal error", with the cause only in the log. A *connect.Error you return with a non-server-fault code keeps its text, so keep internals out of it. Validate() error gives InvalidArgument only for z.Issues, *errs.ValidationError and Field()/Reason() errors (protoc-gen-validate); other errors are masked and logged like handler errors. grpc.ErrorContext(ctx, err) also translates keyed errors (errs.NewKey) into the request locale; Mount and HandlerOptions use it.
Health, reflection and HTTP/2#
grpc.Health(r, checker)mountsgrpc.health.v1.Healthfor load balancers and probes.grpc.Reflection(app, r, services...)mounts server reflection forgrpcurlandbuf curl. It publishes the whole API, so in production it registers nothing unlessGRPC_INSECURE_REFLECTION_IN_PRODUCTION=true.- Native gRPC needs HTTP/2. Outside production the app server also speaks h2c, so
grpcurl -plaintext localhost:3000 listworks withvukem serve. In production terminate TLS at a proxy that speaks HTTP/2 to the app (HTTP_H2C=true), or serve TLS yourself. gRPC-Web and Connect also work over HTTP/1.1, from browsers too.
Own port#
grpc.Serve(app, addr, h, grpc.ServerOptions{TLS: cfg}) adds a grpc runner serving h on its own address (for example GRPC_PORT); it starts and stops with the app and drains in-flight RPCs within SHUTDOWN_TIMEOUT. Build h as a router holding only the RPC services, so RPC paths stay off the HTTP port:
rpc := router.New(app.Router.Services())
if err := vukem.APIMiddleware(app, rpc); err != nil {
return err
}
// MaxMessageBytes sets connect.WithReadMaxBytes (4 MiB by default).
opts := grpc.HandlerOptions(grpc.Options{Log: app.Log, MaxMessageBytes: 1 << 20})
path, h := greetv1connect.NewGreetServiceHandler(svc, opts...)
grpc.Mount(rpc, path, h).Use(grpc.Required(apiGuard), ratelimit.Middleware(limiter, ratelimit.ByToken))
return grpc.Serve(app, ":"+app.Source.Get("GRPC_PORT"), rpc, grpc.ServerOptions{})- With
TLSset the port speaks HTTP/2 and HTTP/1.1 over TLS; without it, h2c and HTTP/1.1. In production a cleartext port is refused (grpc.ErrCleartextInProduction) unlessHTTP_H2C=true. - The port does not inherit the HTTP router's middleware (rate limiting, headers, body limits, guards): install them on
has above.vukem.APIMiddlewareadds recover, request id, logging, headers and a body limit;HandlerOptionscaps messages withconnect.WithReadMaxBytes(4 MiB unlessMaxMessageBytessays otherwise). The server bounds headers and timeouts, not bodies. grpc.Server(&app.Config, addr, h, opts)is the same server as a plainvukem.Runner, with the same production rule (a nil config counts as production). Both use the secure timeouts ofvukem.NewHTTPServer(60s write timeout), so long-lived streams need a server of their own.
Code generation#
The framework never needs protoc itself; your app pins the generators as Go tools:
vukem make:proto Invoice # proto/invoice/v1/invoice.proto, buf.yaml, buf.gen.yaml
go get -tool google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.12
go get -tool connectrpc.com/connect/cmd/protoc-gen-connect-go@v1.21.1
vukem codegen:rpc # gen/invoice/v1/*.pb.go and invoicev1connect/*.connect.go- With buf (recommended;
go get -tool github.com/bufbuild/buf/cmd/buf@v1.74.0or buf onPATH),codegen:rpcrunsbuf generatewith the generatedbuf.gen.yaml.buf lintandbuf breakingusebuf.yaml. - Without buf (or with
--protoc), it callsprotoc -I protowith the two plugins built bygo tool. - Commit the generated code; regenerate after editing a
.proto.
Testing#
h, _ := app.Handler()
srv := grpctest.NewServer(t, h) // TLS + HTTP/2; NewH2CServer for cleartext
for _, p := range grpctest.Protocols() { // grpc, grpc-web, connect, connect+json
client := greetv1connect.NewGreetServiceClient(srv.Client, srv.URL, p.Options...)
res, err := client.Greet(t.Context(), connect.NewRequest(&greetv1.GreetRequest{Name: "Ada"}))
// ...
}