Deep Dive: gRPC Controllers
Technical reference for gRPC controller classes -- the foundation for building gRPC services in Ignis, powered by ConnectRPC.
Ignis gRPC controllers follow the same patterns as REST controllers (decorator-based routing, binding() method, DI integration) while bridging to ConnectRPC's universal handler system. REST and gRPC controllers coexist in the same application, sharing the same DI container, middleware pipeline, and lifecycle.
Files:
packages/core/src/base/controllers/grpc/abstract.tspackages/core/src/base/controllers/grpc/base.tspackages/core/src/base/controllers/grpc/adapter.tspackages/core/src/base/controllers/grpc/common/types.tspackages/core/src/base/metadata/routes/rpc.tspackages/core/src/components/controller/grpc/grpc.component.tspackages/core/src/components/controller/grpc/common/types.ts
Quick Reference
| Item | Description |
|---|---|
| AbstractGrpcController | Abstract base class with RPC registration, ConnectRPC adapter mounting, idempotent configure() |
| BaseGrpcController | Recommended concrete base class with bindRoute() and defineRoute() implementations |
| GrpcRequestAdapter | Internal bridge from Ignis handlers to ConnectRPC universal handlers via AsyncLocalStorage |
| GrpcComponent | Auto-discovers gRPC controllers and mounts them on the application router |
| @controller | Class decorator with transport: ControllerTransports.GRPC and service field |
| @unary | Method decorator for unary RPCs |
| @serverStream | Method decorator for server-streaming RPCs (unsupported -- throws at boot) |
| @clientStream | Method decorator for client-streaming RPCs (unsupported -- throws at boot) |
| @bidiStream | Method decorator for bidirectional-streaming RPCs (unsupported -- throws at boot) |
| @rpc | Generic method decorator (requires explicit method in configs) |
WARNING
Current version supports unary RPCs only. The @serverStream, @clientStream, and @bidiStream decorators still exist and set metadata correctly, but BaseGrpcController.registerRoute() will throw a clear error at boot time if a non-unary RPC is registered. This is because the Connect protocol over HTTP/1.1 cannot support streaming. The decorators are preserved for forward compatibility.
Prerequisites
gRPC support requires the following peer dependencies:
bun add @connectrpc/connect @bufbuild/protobuf| Package | Purpose |
|---|---|
@connectrpc/connect | ConnectRPC router, universal handlers, protocol bridge |
@bufbuild/protobuf | Protobuf code generation, create() for constructing response messages |
For client-side usage (e.g., test clients), you also need a transport package:
bun add @connectrpc/connect-webNOTE
These are optional peer dependencies. They are only loaded at runtime when a gRPC controller is configured, via createRequire from the application's node_modules. If the deps are missing, GrpcRequestAdapter.build() throws a clear error at startup via validateModule().
Protobuf Code Generation
Use buf or protoc-gen-es to generate TypeScript code from .proto files:
# buf.gen.yaml
version: v2
plugins:
- local: protoc-gen-es
out: generated
opt: target=tsbuf generate proto/greeter.protoThe generated output includes:
- Service descriptors (e.g.,
GreeterService) -- passed to@controller({ service }) - Message schemas (e.g.,
SayHelloResponseSchema) -- used withcreate()to build responses - TypeScript types (e.g.,
SayHelloRequest,SayHelloResponse) -- for handler signatures
BaseGrpcController
The recommended base class for gRPC controllers. Extends AbstractGrpcController with concrete bindRoute() and defineRoute() implementations.
Constructor Options
interface IGrpcControllerOptions {
scope: string;
path?: string; // Falls back to @controller decorator path if not provided
}The scope is used for scoped logging (this.logger.for('methodName')). The path defines the HTTP mount point for the ConnectRPC handlers; when both the constructor and @controller decorator specify a path, the decorator takes precedence.
Generic Parameters
BaseGrpcController accepts five generic parameters:
class BaseGrpcController<
RouteEnv extends Env = Env,
RouteSchema extends Schema = {},
BasePath extends string = '/',
ServiceType = unknown,
ConfigurableOptions extends object = {},
>| Parameter | Default | Description |
|---|---|---|
RouteEnv | Env | Hono environment type for typed context access |
RouteSchema | {} | Hono schema type |
BasePath | '/' | Base path string literal type |
ServiceType | unknown | ConnectRPC service descriptor type |
ConfigurableOptions | {} | Extra options passed to configure() |
AbstractGrpcController differs by defaulting ServiceType to Parameters<ConnectRouter['service']>[0] (the actual ConnectRPC service descriptor type), providing stricter type checking on the service field.
The @controller Decorator
gRPC controllers use the same @controller decorator as REST controllers, with two additional fields:
@controller({
path: '/grpc',
transport: ControllerTransports.GRPC,
service: GreeterServiceDef, // Generated ConnectRPC service descriptor
})
export class GreeterController extends BaseGrpcController {
// ...
}| Field | Type | Required | Description |
|---|---|---|---|
path | string | Yes | HTTP base path for this controller's RPC endpoints |
transport | ControllerTransports.GRPC | Yes | Marks this controller for gRPC transport (picked up by GrpcComponent) |
service | ServiceType | Yes | ConnectRPC service descriptor from generated protobuf code |
tags | string[] | No | Metadata tags (inherited from base controller metadata) |
description | string | No | Controller description (inherited from base controller metadata) |
NOTE
If service is missing or falsy at configure time, the GrpcComponent logs a warning and skips the controller entirely -- no routes are mounted.
Route Definition Patterns
Like REST controllers, gRPC controllers support three route definition patterns:
1. Decorator-Based (Recommended)
@controller({
path: '/grpc',
transport: ControllerTransports.GRPC,
service: GreeterServiceDef,
})
export class GreeterController extends BaseGrpcController {
override binding() {}
@unary({ configs: { name: 'sayHello' } })
async sayHello(opts: { request: SayHelloRequest }): Promise<SayHelloResponse> {
return create(SayHelloResponseSchema, { message: `Hello, ${opts.request.name}!` });
}
}Decorator-based RPCs are auto-discovered during configure() via registerRpcsFromRegistry(). The binding() method can be left empty if all routes use decorators.
2. defineRoute() -- Imperative
override binding() {
this.defineRoute({
configs: { name: 'sayHello', method: GRPC.Methods.UNARY },
handler: async (opts) => {
return create(SayHelloResponseSchema, { message: `Hello!` });
},
});
}3. bindRoute().to() -- Fluent
override binding() {
this.bindRoute({
configs: { name: 'sayHello', method: GRPC.Methods.UNARY },
}).to({
handler: async (opts) => {
return create(SayHelloResponseSchema, { message: `Hello!` });
},
});
}The binding() Method
An abstract method you override to register RPCs using defineRoute() or bindRoute(). Called during configure() before decorator-based RPCs are registered. If you only use decorators, provide an empty implementation:
override binding() {}The definitions Property
A Record<string, IRpcRegistration> that stores all registered RPC handlers keyed by their proto method name. Populated by both decorator-based and imperative registration. The GrpcRequestAdapter reads this to build ConnectRPC handlers.
interface IRpcRegistration<RouteEnv extends Env = Env> {
configs: IRpcMetadata;
handler: TRpcHandler<unknown, unknown, RouteEnv>;
middlewares: TRpcMiddleware<RouteEnv>[]; // Pre-built auth middleware
}If you register a handler with the same name as an existing one, it overwrites the previous handler with a warning.
The configure() Lifecycle
The configure() method on AbstractGrpcController is idempotent (guarded by isConfigured flag). It runs the following steps in order:
binding()-- Your override, registers imperative/fluent routesregisterRpcsFromRegistry()-- Discovers decorator-based RPCs fromMetadataRegistryand callsbindRoute()for eachGrpcRequestAdapter.build()-- Creates the ConnectRPC adapter and mounts it as Hono middleware onthis.router
RPC Decorators
All RPC decorators live in packages/core/src/base/metadata/routes/rpc.ts. They register metadata in the MetadataRegistry, which is read during configure().
@rpc -- Generic
The base decorator. Requires the full IRpcMetadata config including method:
@rpc({ configs: { name: 'sayHello', method: GRPC.Methods.UNARY } })
async sayHello(opts: { request: SayHelloRequest }): Promise<SayHelloResponse> {
// ...
}@unary
Shorthand for @rpc with method: 'unary'. Single request, single response.
@unary({ configs: { name: 'sayHello' } })
async sayHello(opts: { request: SayHelloRequest }): Promise<SayHelloResponse> {
return create(SayHelloResponseSchema, { message: `Hello, ${opts.request.name}!` });
}@serverStream (unsupported)
Shorthand for @rpc with method: 'server_streaming'. Throws at boot time in the current version -- streaming is not supported over HTTP/1.1 Connect protocol. Decorator preserved for forward compatibility.
@clientStream (unsupported)
Shorthand for @rpc with method: 'client_streaming'. Throws at boot time in the current version.
@bidiStream (unsupported)
Shorthand for @rpc with method: 'bidi_streaming'. Throws at boot time in the current version.
Decorator Config
All decorators accept { configs: ... } where configs extends IRpcMetadata (with method omitted for the shorthand variants):
// @unary, @serverStream, @clientStream, @bidiStream
{ configs: Omit<IRpcMetadata, 'method'> }
// @rpc (generic)
{ configs: IRpcMetadata }Type Definitions
IRpcMetadata
Metadata stored per RPC method in the MetadataRegistry.
interface IRpcMetadata {
/** Proto method name -- must match the RPC name in your .proto service definition. */
name: string;
/** RPC method type. */
method: TGrpcMethod; // 'unary' | 'server_streaming' | 'client_streaming' | 'bidi_streaming'
/** Per-RPC authentication config. */
authenticate?: { strategies?: TAuthStrategy[]; mode?: TAuthMode };
/** Per-RPC authorization spec(s). */
authorize?: IAuthorizationSpec | IAuthorizationSpec[];
}IRpcRegistration
Unified entry stored in the controller's definitions map. Combines metadata, handler function, and pre-built auth middleware.
interface IRpcRegistration<RouteEnv extends Env = Env> {
configs: IRpcMetadata;
handler: TRpcHandler<unknown, unknown, RouteEnv>;
middlewares: TRpcMiddleware<RouteEnv>[];
}TRpcMiddleware
Pre-built middleware function for gRPC auth enforcement, created by AbstractGrpcController.buildRpcMiddlewares().
type TRpcMiddleware<RouteEnv extends Env = Env> = (
context: TRouteContext<RouteEnv>,
next: Next,
) => ValueOrPromise<void | Response>;TRpcHandler
The handler signature for gRPC RPC methods. Receives the deserialized protobuf request and the Hono context (via AsyncLocalStorage).
type TRpcHandler<
RequestType = unknown,
ResponseType = unknown,
RouteEnv extends Env = Env,
> = (opts: {
request: RequestType;
context: TRouteContext<RouteEnv>;
}) => ValueOrPromise<ResponseType>;NOTE
When using decorator-based RPCs, the handler method signature is (opts: { request: RequestType }) => Promise<ResponseType>. The context parameter is injected internally by the adapter and is not passed to the decorator-based handler method directly. The full TRpcHandler signature (with context) applies when using defineRoute() or bindRoute().
IGrpcControllerOptions
Constructor options for gRPC controllers.
interface IGrpcControllerOptions {
scope: string;
path?: string;
}IGrpcBindRouteOptions
Fluent binding returned by bindRoute().
interface IGrpcBindRouteOptions<RouteEnv extends Env = Env> {
configs: IRpcMetadata;
to: (opts: { handler: TRpcHandler<unknown, unknown, RouteEnv> }) => IGrpcDefineRouteOptions;
}IGrpcDefineRouteOptions
Return type from both defineRoute() and bindRoute().to().
interface IGrpcDefineRouteOptions {
configs: IRpcMetadata;
}IGrpcController
The full interface that gRPC controllers implement. Extends IConfigurable.
interface IGrpcController<
RouteEnv extends Env = Env,
RouteSchema extends Schema = {},
BasePath extends string = '/',
ServiceType = unknown,
ConfigurableOptions extends object = {},
> extends IConfigurable<ConfigurableOptions> {
service: ServiceType;
router: Hono<RouteEnv, RouteSchema, BasePath>;
definitions: Record<string, IRpcRegistration<RouteEnv>>;
getRouter(): Hono<RouteEnv, RouteSchema, BasePath>;
bindRoute(opts: { configs: IRpcMetadata }): IGrpcBindRouteOptions<RouteEnv>;
defineRoute(opts: {
configs: IRpcMetadata;
handler: TRpcHandler<unknown, unknown, RouteEnv>;
}): IGrpcDefineRouteOptions;
}IConnectAdapterResult
Return type from GrpcRequestAdapter.build().
interface IConnectAdapterResult<
RouteEnv extends Env = Env,
BasePath extends string = '/',
RouteInput extends Input = {},
> {
paths: string[];
middleware: MiddlewareHandler<RouteEnv, BasePath, RouteInput>;
}GrpcRequestAdapter
Internal bridge between Ignis gRPC controllers and ConnectRPC's universal handler system. You do not interact with this class directly -- it is created automatically during configure().
Architecture
The adapter solves a key challenge: ConnectRPC handlers have their own (request, context) => response signature, but Ignis controllers need access to the Hono Context for middleware, auth, and request-scoped state. The adapter uses AsyncLocalStorage to provide request-scoped context isolation, ensuring concurrent requests never share state.
Hono Request
-> GrpcRequestAdapter middleware (path matching via basePath + controllerPath)
-> AsyncLocalStorage.run(honoContext, ...)
-> Pre-built auth middlewares (authenticate -> authorize)
-> ConnectRPC universal handler
-> Ignis TRpcHandler (reads context from AsyncLocalStorage)
-> ResponseStatic build() Method
The only public API. Validates peer deps via validateModule(), creates the adapter, and returns the middleware + registered paths:
static async build(opts: {
controller: AbstractGrpcController<...>;
interceptors?: unknown[];
}): Promise<IConnectAdapterResult<RouteEnv, BasePath>>Called internally by AbstractGrpcController.configure():
const adapter = await GrpcRequestAdapter.build({ controller: this });
this.router.use('*', adapter.middleware);The optional interceptors array is passed to ConnectRPC's createConnectRouter() for request/response interception at the protocol level.
Internal Flow
buildConnectHandlers()-- Wraps each IgnisTRpcHandlerinto ConnectRPC's(request, context) => responsesignature. The wrapper reads the Hono context fromAsyncLocalStorage, runs pre-built auth middlewares (built byAbstractGrpcController.buildRpcMiddlewares()), then passes{ request, context }to the Ignis handler.registerService()-- Bridges the opaqueServiceTypefrom@controllermetadata to ConnectRPC'srouter.service()call, registering all handlers for the service.buildMiddleware()-- Creates a Hono middleware that:- Strips the full mount prefix (
basePath + controllerPath) from the request URL to derive the ConnectRPC handler path (e.g.,/package.Service/Method) - Looks up the ConnectRPC handler by path from the handler map
- Runs the handler inside
AsyncLocalStorage.run()with the current Hono context - Converts between Fetch API
Request/Responseand ConnectRPC'sUniversalServerRequest/UniversalServerResponseformats - Returns proper gRPC error responses on failure (with
grpc-statusandgrpc-messageheaders)
- Strips the full mount prefix (
Peer Dependency Loading
The adapter loads ConnectRPC modules at runtime using createRequire from the application's node_modules:
@connectrpc/connect-- forcreateConnectRouter@connectrpc/connect/protocol-- foruniversalServerRequestFromFetchanduniversalServerResponseToFetch
This approach supports single-file builds where the peer deps may not be resolvable via standard import.
Error Handling
On handler errors, the adapter returns a JSON response with:
- HTTP status:
200if gRPC status isOK,500otherwise grpc-statusheader: Preserved fromConnectError.codeif available (duck-type check onerror.codebeing a number), otherwise13(INTERNAL)grpc-messageheader: URL-encoded error message- Body: JSON
{ message, code }
The adapter uses a duck-type check on error.code to preserve gRPC status codes from ConnectRPC errors without importing ConnectError directly, avoiding tight coupling to the peer dependency.
GrpcComponent
Auto-discovers and configures gRPC controllers during the application lifecycle.
Configuration
interface IGrpcComponentConfig {
interceptors?: unknown[];
}The component registers a default (empty) config binding under the key '@app/grpc/options' (GrpcBindingKeys.GRPC_COMPONENT_OPTIONS).
Behavior
- Finds all bindings tagged with the
controllersnamespace - Filters to controllers whose metadata has
transport: 'grpc' - Validates each gRPC controller:
- If
pathis missing, throws an error - If
serviceis missing, logs a warning and skips the controller
- If
- Sets
instance.basePathfrom the application'spath.baseconfig (needed for correct path stripping in the adapter) - Calls
configure()on each controller instance - Mounts the controller's router on the application's root router at the controller's path via
router.route(metadata.path, instance.getRouter())
Dynamic Discovery
The component uses a re-fetch loop with Set tracking. After configuring each controller, it re-queries the container for new controller bindings (excluding already-configured ones). This handles controllers registered dynamically during component composition (e.g., a component that registers another component that registers a gRPC controller).
Automatic Registration
GrpcComponent is instantiated and configured automatically by BaseApplication when appConfigs.transports includes ControllerTransports.GRPC. You do not need to register it manually. The relevant code in BaseApplication:
case ControllerTransports.GRPC: {
const grpcComponent = new GrpcComponent(this);
await grpcComponent.configure();
break;
}GRPC Constants
The GRPC class from @venizia/ignis-helpers provides all gRPC protocol constants:
Methods
GRPC.Methods.UNARY // 'unary'
GRPC.Methods.SERVER_STREAMING // 'server_streaming'
GRPC.Methods.CLIENT_STREAMING // 'client_streaming'
GRPC.Methods.BIDI_STREAMING // 'bidi_streaming'Result Codes
Standard gRPC status codes (matching google.rpc.Code):
GRPC.ResultCodes.OK // 0
GRPC.ResultCodes.CANCELLED // 1
GRPC.ResultCodes.UNKNOWN // 2
GRPC.ResultCodes.INVALID_ARGUMENT // 3
GRPC.ResultCodes.DEADLINE_EXCEEDED // 4
GRPC.ResultCodes.NOT_FOUND // 5
GRPC.ResultCodes.ALREADY_EXISTS // 6
GRPC.ResultCodes.PERMISSION_DENIED // 7
GRPC.ResultCodes.RESOURCE_EXHAUSTED // 8
GRPC.ResultCodes.FAILED_PRECONDITION // 9
GRPC.ResultCodes.ABORTED // 10
GRPC.ResultCodes.OUT_OF_RANGE // 11
GRPC.ResultCodes.UNIMPLEMENTED // 12
GRPC.ResultCodes.INTERNAL // 13
GRPC.ResultCodes.UNAVAILABLE // 14
GRPC.ResultCodes.DATA_LOSS // 15
GRPC.ResultCodes.UNAUTHENTICATED // 16Headers
Standard gRPC protocol headers:
GRPC.Headers.GRPC_STATUS // 'grpc-status'
GRPC.Headers.GRPC_MESSAGE // 'grpc-message'
GRPC.Headers.GRPC_TIMEOUT // 'grpc-timeout'
GRPC.Headers.GRPC_ENCODING // 'grpc-encoding'
// ... and more (see packages/helpers/src/common/constants/grpc.ts)Content Types
GRPC.HeaderValues.GRPC // 'application/grpc'
GRPC.HeaderValues.GRPC_PROTO // 'application/grpc+proto'
GRPC.HeaderValues.GRPC_JSON // 'application/grpc+json'
GRPC.HeaderValues.GRPC_WEB // 'application/grpc-web'
GRPC.HeaderValues.GRPC_WEB_PROTO // 'application/grpc-web+proto'
GRPC.HeaderValues.GRPC_WEB_JSON // 'application/grpc-web+json'
GRPC.HeaderValues.GRPC_WEB_TEXT // 'application/grpc-web-text'Application Setup
Enabling gRPC Transport
Add ControllerTransports.GRPC to the transports array in your application configs:
import {
BaseApplication,
ControllerTransports,
IApplicationConfigs,
IApplicationInfo,
} from '@venizia/ignis';
import { ValueOrPromise } from '@venizia/ignis-helpers';
import { GreeterController } from './controllers/greeter';
import { GreeterService } from './services/greeter.service';
export const appConfigs: IApplicationConfigs = {
host: '0.0.0.0',
port: 3000,
path: { base: '/', isStrict: false },
transports: [ControllerTransports.REST, ControllerTransports.GRPC],
};
export class Application extends BaseApplication {
getAppInfo(): ValueOrPromise<IApplicationInfo> {
return { name: 'my-app', version: '1.0.0', description: 'gRPC + REST app' };
}
staticConfigure() {}
preConfigure() {
this.service(GreeterService);
this.controller(GreeterController);
}
postConfigure() {}
setupMiddlewares() {}
}WARNING
If transports does not include ControllerTransports.GRPC, gRPC controllers are still registered in the DI container but the GrpcComponent is never mounted -- their configure() is never called and no routes are served.
Dual Transport
REST and gRPC controllers coexist in the same application. Each controller declares its own transport via the @controller decorator. A single application can serve both:
preConfigure() {
// gRPC controller
this.controller(GreeterController);
// REST controller
this.controller(StatusController);
}REST controllers are handled by the RestComponent (active when transports includes ControllerTransports.REST, which is the default); gRPC controllers are handled by the GrpcComponent (active when transport is enabled). They share the same DI container and lifecycle.
Complete Example
1. Proto File
// proto/greeter.proto
syntax = "proto3";
package greeter.v1;
message SayHelloRequest {
string name = 1;
}
message SayHelloResponse {
string message = 1;
}
message ListUsersRequest {}
message ListUsersResponse {
repeated string users = 1;
}
service GreeterService {
rpc SayHello (SayHelloRequest) returns (SayHelloResponse);
rpc ListUsers (ListUsersRequest) returns (ListUsersResponse);
}2. Generate TypeScript Code
buf generate proto/greeter.proto3. Definition File (Stable Import Boundary)
// controllers/greeter/definition.ts
export {
GreeterService,
ListUsersResponseSchema,
SayHelloResponseSchema,
type ListUsersRequest,
type ListUsersResponse,
type SayHelloRequest,
type SayHelloResponse,
} from './generated/greeter_pb';TIP
Always re-export generated code through a definition.ts file. This acts as a stable import boundary -- controller code and external consumers import from here, never from the generated/ directory directly. When you regenerate protos, only this file needs updating.
4. Controller
// controllers/greeter/controller.ts
import { GreeterService } from '@/services';
import { create } from '@bufbuild/protobuf';
import {
BaseGrpcController,
ControllerTransports,
controller,
inject,
unary,
} from '@venizia/ignis';
import {
GreeterService as GreeterServiceDef,
ListUsersResponseSchema,
SayHelloResponseSchema,
type ListUsersRequest,
type ListUsersResponse,
type SayHelloRequest,
type SayHelloResponse,
} from './definition';
@controller({
path: '/grpc',
transport: ControllerTransports.GRPC,
service: GreeterServiceDef,
})
export class GreeterController extends BaseGrpcController {
constructor(
@inject({ key: 'services.GreeterService' })
private readonly greeterService: GreeterService,
) {
super({ scope: 'GreeterController', path: '/grpc' });
}
override binding() {}
@unary({ configs: { name: 'sayHello' } })
async sayHello(opts: { request: SayHelloRequest }): Promise<SayHelloResponse> {
const message = await this.greeterService.sayHello(opts);
return create(SayHelloResponseSchema, { message });
}
@unary({ configs: { name: 'listUsers' } })
async listUsers(opts: { request: ListUsersRequest }): Promise<ListUsersResponse> {
const users = await this.greeterService.listUsers(opts);
return create(ListUsersResponseSchema, { users });
}
}5. Minimal Controller (No DI)
A controller with no injected dependencies:
// controllers/echo/controller.ts
import { create } from '@bufbuild/protobuf';
import {
BaseGrpcController,
ControllerTransports,
controller,
unary,
} from '@venizia/ignis';
import {
EchoResponseSchema,
EchoService as EchoServiceDef,
type EchoRequest,
type EchoResponse,
} from './definition';
@controller({
path: '/grpc',
transport: ControllerTransports.GRPC,
service: EchoServiceDef,
})
export class EchoController extends BaseGrpcController {
constructor() {
super({ scope: 'EchoController', path: '/grpc' });
}
override binding() {}
@unary({ configs: { name: 'echo' } })
async echo(opts: { request: EchoRequest }): Promise<EchoResponse> {
return create(EchoResponseSchema, {
message: `Echo: ${opts.request.message}`,
});
}
}6. Application
// application.ts
import {
BaseApplication,
ControllerTransports,
IApplicationConfigs,
IApplicationInfo,
} from '@venizia/ignis';
import { ValueOrPromise } from '@venizia/ignis-helpers';
import { GreeterController } from './controllers/greeter';
import { EchoController } from './controllers/echo';
import { GreeterService } from './services/greeter.service';
export const appConfigs: IApplicationConfigs = {
host: '0.0.0.0',
port: 3000,
path: { base: '/', isStrict: false },
transports: [ControllerTransports.REST, ControllerTransports.GRPC],
};
export class Application extends BaseApplication {
getAppInfo(): ValueOrPromise<IApplicationInfo> {
return { name: 'greeter-app', version: '1.0.0', description: 'gRPC greeter' };
}
staticConfigure() {}
preConfigure() {
this.service(GreeterService);
this.controller(GreeterController);
this.controller(EchoController);
}
postConfigure() {}
setupMiddlewares() {}
}7. Client (Testing)
// client.ts
import { create } from '@bufbuild/protobuf';
import { createClient } from '@connectrpc/connect';
import { createConnectTransport } from '@connectrpc/connect-web';
import { GreeterService, SayHelloRequestSchema } from './controllers/greeter/definition';
const transport = createConnectTransport({ baseUrl: 'http://localhost:3000/grpc' });
const client = createClient(GreeterService, transport);
const response = await client.sayHello(create(SayHelloRequestSchema, { name: 'Ignis' }));
console.log(response.message);Component-Based Registration
gRPC controllers can be registered through components, following the same pattern as REST controllers. This enables modular composition and late registration.
Basic Component
import {
BaseApplication,
BaseComponent,
CoreBindings,
inject,
} from '@venizia/ignis';
import { ValueOrPromise } from '@venizia/ignis-helpers';
import { EchoController } from '../controllers/echo';
export class EchoComponent extends BaseComponent {
constructor(
@inject({ key: CoreBindings.APPLICATION_INSTANCE })
private application: BaseApplication,
) {
super({ scope: 'EchoComponent' });
}
override binding(): ValueOrPromise<void> {
this.application.controller(EchoController);
}
}Component Composition
Components can compose other components, building a dependency graph of controllers:
import {
BaseApplication,
BaseComponent,
CoreBindings,
inject,
} from '@venizia/ignis';
import { TimeController } from '../controllers/time';
import { EchoComponent } from './echo.component';
export class TimeComponent extends BaseComponent {
constructor(
@inject({ key: CoreBindings.APPLICATION_INSTANCE })
private application: BaseApplication,
) {
super({ scope: 'TimeComponent' });
}
override async binding(): Promise<void> {
// Compose EchoComponent -- registers EchoController
this.application.component(EchoComponent);
// Register this component's own controller
this.application.controller(TimeController);
}
}The GrpcComponent handles these dynamically-registered controllers through its re-fetch loop -- after configuring each controller, it re-queries the container for newly added bindings.
Registration in Application
preConfigure() {
// TimeComponent composes EchoComponent internally
this.component(TimeComponent);
}Authentication and Authorization
Per-RPC authentication and authorization are configured via the authenticate and authorize fields in IRpcMetadata. Auth middlewares are pre-built during route registration by AbstractGrpcController.buildRpcMiddlewares() and executed before the handler inside the AsyncLocalStorage context.
Per-RPC Authentication
@unary({
configs: {
name: 'sayHello',
authenticate: {
strategies: ['jwt'],
mode: 'required',
},
},
})
async sayHello(opts: { request: SayHelloRequest }): Promise<SayHelloResponse> {
// Only accessible with a valid JWT token
return create(SayHelloResponseSchema, { message: 'Hello!' });
}| Field | Type | Default | Description |
|---|---|---|---|
strategies | TAuthStrategy[] | [] | Authentication strategies to apply (e.g., ['jwt'], ['basic']) |
mode | TAuthMode | 'any' | 'required' | 'optional' | 'any' | 'all' (defaults to AuthenticationModes.ANY) |
Per-RPC Authorization
@unary({
configs: {
name: 'deleteUser',
authenticate: { strategies: ['jwt'], mode: 'required' },
authorize: { action: 'delete', resource: 'user' },
},
})
async deleteUser(opts: { request: DeleteUserRequest }): Promise<DeleteUserResponse> {
// Requires JWT + delete permission on user resource
// ...
}Multiple authorization specs can be provided as an array:
authorize: [
{ action: 'read', resource: 'user' },
{ action: 'read', resource: 'profile' },
]Middleware Execution Order
Auth middlewares run in the following order inside the ConnectRPC handler wrapper:
- Authenticate middlewares (if
configs.authenticate.strategieshas entries) - Authorize middlewares (if
configs.authorizeis present), one per spec - Handler execution
See Also
- Controllers Reference -- REST controller classes and API endpoint patterns
- Components Reference -- Component system and built-in components
- Dependency Injection -- IoC container,
@inject, binding keys - Services -- Business logic layer