@setu-ts/grpc-plugin
gRPC plugin for Setu-TS — enables co-serving of gRPC, Connect, and gRPC-Web protocols on the same
port as ordinary Hono routes. The plugin registers an IGrpcService under CAPABILITIES.GRPC; the
kernel resolves that service from the registry and dispatches RPC traffic itself — after the
middleware pipeline, before route matching — so gRPC rides the same auth, metrics, and shutdown
drain as every other route. No adapter seam is involved: GrpcService.available is unconditionally
true, and Application.inject() reaches gRPC handlers exactly as it reaches ordinary routes.
Features
- Supports all four RPC kinds: unary, server-streaming, client-streaming, and bidi (HTTP/2 required)
- Connect protocol (
application/connect+json,application/connect+proto) - gRPC-Web protocol (
application/grpc-web+json,application/grpc-web+proto) - Native gRPC-binary requests (
application/grpc,application/grpc+proto,application/grpc+json) are refused with a Trailers-OnlyUNIMPLEMENTEDresponse — see Limitations - Server reflection (v1, default ON), reachable over Connect and gRPC-Web — not from stock
grpcurl/grpcui, which speak nativeapplication/grpc(see Limitations) - gRPC Health v1 service bridged to M20 health plugin (default ON)
- Zero runtime dependencies in the source — Connect-ES is loaded lazily behind a structural facade
- Module loading works on Node, Deno, Bun, and Cloudflare Workers without modification
Installation
The Connect and Protobuf-ES modules are lazy-loaded on first use. Install them with the package manager that matches your runtime:
# Deno
deno add npm:@connectrpc/connect@^2.1.2 npm:@bufbuild/protobuf@^2.7.0
# npm
npm i @connectrpc/connect @bufbuild/protobuf
# Bun
bun add @connectrpc/connect @bufbuild/protobuf
Usage
Pass your services through the plugin’s services option. Plugins register during
app.start(), so CAPABILITIES.GRPC does not exist until start() resolves — resolving it before
start() throws No service registered for capability 'grpc'. (createApplication() returns a
descriptor, not a running application; there is no new Application() / app.use() API.)
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { GrpcPlugin } from '@setu-ts/grpc-plugin';
import type { GrpcServiceDefinition } from '@setu-ts/grpc-plugin';
// Your generated descriptor + implementation (illustrative stand-ins).
declare const MyServiceDefinition: GrpcServiceDefinition;
declare const myServiceImpl: Record<string, unknown>;
const app = createApplication({
plugins: [
RuntimePlugin(),
GrpcPlugin({
services: [{ definition: MyServiceDefinition, implementation: myServiceImpl }],
}),
],
});
await app.start({ port: 3000 });
Options
The GrpcPlugin accepts optional configuration:
basePath: Base path under which gRPC services are served (defaults to/— the root, so a client pointed athttp://host:3000reaches its procedures without a path prefix). Detection is still segment-aware: with the default,/grpcfooand other prefix-adjacent routes stay yours.reflection: Whether to enable server reflection (defaults totrue)health: Whether to enable gRPC Health v1 service (defaults totrue)services: Initial services to register at startupconnectModule: Injected Connect runtime module (for testing)interceptors: Application Connect interceptors, forwarded to Connect router construction (createConnectRouter({ interceptors })). Composed after the built-in handler-error logging, so a handler throw is logged before an application interceptor observes it. Absent: none installed (defaults to[])
GrpcPlugin({
basePath: '/my-grpc',
reflection: true,
health: true,
services: [{ definition: MyServiceDescriptor, implementation: MyServiceImpl }],
interceptors: [myInterceptor],
});
Reflection and Health
Reflection (grpc.reflection.v1.ServerReflection, default ON) answers list_services,
file_by_filename, file_containing_symbol and all_extension_numbers_of_type. Symbols resolve
for services, their methods, messages, nested types, enums and extensions — of the plugin’s own two
protos AND of every registered application service’s DescFile plus its transitive dependencies.
Nothing else is exposed. file_containing_extension answers UNIMPLEMENTED (the framework
registers no extensions); an unknown filename, symbol or type answers NOT_FOUND. Set
reflection: false to register nothing.
Health (grpc.health.v1.Health, default ON) implements Check only, resolving
CAPABILITIES.HEALTH optionally — absent, it answers SERVING. An empty service field means “the
whole server” and returns the mapped readiness aggregate: up → SERVING, down → NOT_SERVING, and
degraded → NOT_SERVING. Since M119b the bridge reads the readiness report (checkReady()), so
it agrees with the health plugin’s /ready: an indicator that counts toward /health but not
/ready (the mail indicator, by default) does not turn the server NOT_SERVING. degraded still
maps to NOT_SERVING because the health plugin already withdraws a degraded replica from its
Service (503) — reporting SERVING here would leave the two health faces of one process
disagreeing, with gRPC clients load-balancing onto a replica HTTP has taken out of rotation. A
service naming something this server does not serve answers SERVICE_UNKNOWN. List and Watch
are left to Connect’s automatic unimplemented responder.
Errors
| Error | Thrown when |
|---|---|
GrpcRuntimeLoadError | Any of the four Connect/Protobuf-ES specifiers cannot be imported |
GrpcDescriptorError | An embedded descriptor set cannot be decoded or lacks its expected service |
GrpcUnavailableError | Deprecated — never thrown since M70a. Retained as published surface only; gRPC dispatch no longer depends on any adapter capability, so handleRequest is always serviceable and available is always true |
Limitations
-
Detection is prefix-only. A request is RPC when its path starts with
basePath. Content-type sniffing is deliberately NOT used: Connect’s real unary content types includeapplication/jsonandapplication/proto, so matching them would hijack ordinary application routes. Point your client’s base URL atbasePath. Paths outside it — including prefix-adjacent ones like/grpcfoo— fall through to Hono untouched. -
Bidi streaming needs a genuinely full-duplex transport — HTTP/2, or in-process
app.fetch. The plugin deliberately leaves Connect’shttpVersionoption unset, becauseIHttpAdaptersurfaces no negotiated version and guessing'1.1'would make Connect refuse bidi even on transports that support it. The consequence is that a bidi call over a real HTTP/1.1 socket fails at the transport rather than with a clean505. In practice this is benign — gRPC clients speak HTTP/2 — but note it also applies to this plugin’s OWNgrpc.reflection.v1.ServerReflection, whose sole method is bidi-streaming. Unary, server-streaming and client-streaming are unaffected on every runtime.Separately, the kernel BUFFERS the request body before dispatching a call it will actually serve, so a client that holds its request stream open past its last message stalls until it closes. A Connect or gRPC-Web client that sends its messages and finishes is unaffected, which is every in-repo client and every one the reference suite exercises. A native
application/grpccall is refused from its headers and never reaches the buffering — which is what a half-open stream used to hang on. -
Application injection:
Application.inject()reaches gRPC handlers — the kernel dispatches gRPC from the service registry, not through the HTTP adapter, so an injected request is routed exactly like an adapter-delivered one.app.fetch()with a webRequestworks too, for streaming procedures. -
No client SDK: This plugin only provides server-side gRPC serving. Client-side gRPC calls are handled by generated Connect/gRPC client code in the application.
- Native gRPC-binary is refused by design: requests with a native gRPC content type
(
application/grpc,application/grpc+proto,application/grpc+json) are answered with a Trailers-OnlyUNIMPLEMENTEDresponse — HTTP200,content-type: application/grpc, andgrpc-status: 12in the headers. The native wire protocol depends on HTTP/2 trailers for status signaling, and no fetch-based server runtime exposes them to aResponse(Deno’sDeno.servesurfaces no trailers; Node’s and Bun’s fetch handlers do not either). Module loading itself is unaffected — the plugin loads its Connect dependency on every runtime it runs on — but a fetchResponsecarries trailers nowhere, so the protocol cannot be served honestly. Serving half of the protocol — reflection and health resolve, every real call fails with “missing status” — is worse than refusing it cleanly: clients see an explicit, well-formedUNIMPLEMENTEDinstead of an opaque transport error after a successful handshake. Measured with realgrpcurlv1.9.3: a unary native call reportstarget server does not expose service …and exits 1. A bidi native call reports the same refusal, and since0.7.0it does so without hanging: the refusal is decided from the request HEADERS and answered before the body is read. Use Connect or gRPC-Web instead; both work completely for unary, server-streaming and client-streaming over both HTTP/1.1 and HTTP/2; bidi additionally requires HTTP/2, per the bidi bullet above. Every non-JS gRPC client can speak Connect or gRPC-Web, but not throughgrpcurl— its-formatflag selects the message encoding (json/text), not the wire protocol, and it implements native gRPC only. Usebuf curl --protocol connect(or--protocol grpcweb), a generated Connect client, or a web client; for an existing native-gRPC fleet, put Envoy’sgrpc_webfilter in front.
Health Indicator
The plugin registers a health indicator named 'grpc' whose data reports:
available: whether gRPC dispatch is available — unconditionallytruesince M70a, because the kernel resolvesIGrpcServicefrom the service registry and dispatches after the middleware pipeline; the previous adapter-based seam (and theGrpcUnavailableErrorthat guarded it) is retiredserviceCount: how many application services are registered
Development
To regenerate embedded descriptor constants (when upgrading protos):
# From grpc/grpc-proto at the desired commit
protoc -Iproto --include_imports \
--descriptor_set_out=health.binpb proto/grpc/health/v1/health.proto
protoc -Iproto --include_imports \
--descriptor_set_out=reflection.binpb proto/grpc/reflection/v1/reflection.proto
base64 -w0 < health.binpb # 1168 chars
base64 -w0 < reflection.binpb # 2332 chars
See Also
- PUBLIC_API.md
— the
grpc-pluginOptions / Exports / Notes section - ARCHITECTURE.md — §7 the
IHttpAdapterseam, §18 why the plugin does not hang off the kernel - ROADMAP.md — Milestone 49
Exports
| Export | Kind |
|---|---|
adaptConnectModule | function |
GrpcPlugin | function |
GrpcDescriptorError | class |
GrpcRuntimeLoadError | class |
GrpcService | class |
GrpcUnavailableError | class |
CAPABILITIES | const |
ConnectModuleLike | interface |
GrpcPluginOptions | interface |
GrpcServiceDefinition | interface |
IGrpcService | interface |
GrpcServingStatus | type |
RpcFetchHandler | type |
Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it
drifts.
API reference
API Reference: @setu-ts/grpc-plugin
gRPC/Connect co-serving on the same port as ordinary Hono routes. Registered under
CAPABILITIES.GRPC. Added in Milestone 49.
createApplication()returns an application descriptor; there is nonew Application()/app.use()API. Plugins register duringapp.start(), so do not resolveCAPABILITIES.GRPCbeforestart()resolves — the capability does not exist yet and the lookup throws. Pass services through theservicesoption (below) or calladdServiceafterstart().
Registration
import { GrpcPlugin } from '@setu-ts/grpc-plugin';
GrpcPlugin({
basePath: '/', // default — the root
reflection: true, // default — grpc.reflection.v1.ServerReflection
health: true, // default — grpc.health.v1.Health (bridged to M20)
services: [], // initial service definitions
connectModule: undefined, // inject for testing; otherwise lazy-loaded
interceptors: [], // default — application Connect interceptors
});
Usage
Pass services through the plugin’s services option at construction:
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { GrpcPlugin } from '@setu-ts/grpc-plugin';
import { MyServiceDefinition, myServiceImpl } from './my-service.ts';
const app = createApplication({
plugins: [
RuntimePlugin(),
GrpcPlugin({
services: [{ definition: MyServiceDefinition, implementation: myServiceImpl }],
}),
],
});
await app.start({ port: 3000 });
// AFTER start(): late registration through the resolved capability.
const grpc = app.services.get<IGrpcService>(CAPABILITIES.GRPC);
grpc.addService(AnotherDefinition, anotherImpl);
Options
| Option | Type | Default | Description |
|---|---|---|---|
basePath | string | / | URL prefix that marks a request as RPC. Defaults to the root so clients reach procedures without a path prefix; detection stays segment-aware, so prefix-adjacent routes (/grpcfoo) are untouched. Requests outside this prefix fall through to Hono. |
reflection | boolean | true | Register grpc.reflection.v1.ServerReflection. Bidi streaming — requires HTTP/2 or in-process fetch. |
health | boolean | true | Register grpc.health.v1.Health (Check only), bridged to the M20 health plugin. |
services | Array<{ definition, implementation? }> | [] | Initial services to register at startup. The recommended registration path: the capability does not exist before start(). |
connectModule | ConnectRuntime | omitted | Injected Connect runtime for tests; omitted triggers lazy import() of four npm specifiers. |
interceptors | readonly unknown[] | [] | Application Connect interceptors, forwarded to Connect router construction (createConnectRouter({ interceptors })). Composed after the built-in handler-error logging, so a handler throw is logged before an application interceptor observes it. Absent: none installed. |
Native gRPC-binary requests (
application/grpc,+proto,+json) are refused with a Trailers-OnlyUNIMPLEMENTED(grpc-status: 12). No runtime the plugin loads on exposes the HTTP/2 trailers the native protocol requires. Connect and gRPC-Web are fully supported.
Exports
| Export | Kind | Purpose |
|---|---|---|
GrpcPlugin | function | Plugin factory — registers IGrpcService under CAPABILITIES.GRPC |
GrpcService | class | The IGrpcService implementation; exported so tests can compose it without subclassing |
adaptConnectModule | function | Structural adaptation of raw Connect/Protobuf modules into the internal ConnectRuntime port |
GrpcUnavailableError | class | Deprecated — nothing throws it since M70a; handleRequest needs no adapter capability |
GrpcRuntimeLoadError | class | Thrown by loadConnectModule when any of the four npm specifiers cannot be imported |
GrpcDescriptorError | class | Thrown when an embedded descriptor set cannot be decoded or lacks its expected service |
GrpcPluginOptions | type | The factory parameter shape |
ConnectModuleLike | type | The four-module bundle adaptConnectModule accepts |
ConnectRuntimeand the structural Connect facades are not exported. They are an internal port; publishing them would commit the package to a shape that tracks Connect’s own API.
Notes
- Co-serves with Hono. gRPC requests are detected by path prefix only (
basePath, which defaults to/— the root, so clients reach procedures at the bare method path). Content-type sniffing is deliberately not used because Connect’s real unary content types includeapplication/jsonandapplication/proto. A path outsidebasePathreturnsnulland falls through to the Hono pipeline unchanged. - The middleware pipeline runs first. Since M70a the kernel dispatches gRPC from its terminal
handler, after the pipeline and before route matching — so auth, metrics, security headers and
the shutdown drain apply to RPC exactly as to ordinary routes, a draining application answers
503, and an application catch-all cannot shadow a claimed path.GrpcPluginno longer callsadapter.setRpcHandler, which is deprecated and consulted by nothing. IGrpcService.claims(request)is what keeps ordinary 404s intact.handleRequestreturnsPromise<Response>and nevernull, so it answers404both for a path outsidebasePathand for a claimed path with no such procedure — the two are indistinguishable once dispatched. The kernel therefore asksclaims()before dispatching. Without that guard, registering the plugin would change every unmatched route in the application from the kernel’s{"error":"Not Found"}(application/json) to gRPC’sNot Found(text/plain).claimsis optional on the contract for source compatibility, and the kernel treats an implementor that lacks it as claiming nothing. A rootbasePathnormalizes to'', which contains every path, so thereclaimsreports only registered procedures rather than the whole application — otherwise a root-mounted service consulted before route matching would 404 every ordinary route.IGrpcService.availableis now alwaystrue. It used to report whether the resolved HTTP adapter implementedsetRpcHandler; dispatch no longer depends on any adapter capability.- The request handed to
handleRequestis reconstructed from the mappedIRequest(method, URL, headers, buffered body), because the framework mapping has already consumed the original body. Cloning every request before mapping would tax the whole application to serve the gRPC minority. Trailers do not survive the round trip — M49 already records that native gRPC-binary trailers work on no runtime this plugin runs on, so no working path regresses. inject()CAN exercise RPC. Since M70a the kernel dispatches gRPC from its terminal handler, andinject()attaches the undisturbed webRequestasIRequest.rawbefore running the pipeline, so an injected request reaches RPC dispatch exactly as a socket request does — the integration suite drives gRPC throughinject(). The retired adapter interceptor is not involved: nosetRpcHandlerseam is consulted on any path.- Bidi streaming requires HTTP/2.
grpc.reflection.v1.ServerReflectionis bidi-only. Over a real HTTP/1.1 socket, bidi calls fail at the transport. Unary, server-streaming, and client-streaming work on every runtime. - Health bridge maps
degraded → NOT_SERVING.'up' → SERVING (1),'down' → NOT_SERVING (2),'degraded' → NOT_SERVING (2). The health plugin already withdraws a degraded replica from its Service via/ready(503), so reportingSERVINGhere would leave the two health faces of one process disagreeing — gRPC clients would keep load-balancing onto a replica HTTP has taken out of rotation. Since M70c the bridge agrees with/ready; a client relying onSERVINGwhile a process is degraded now seesNOT_SERVING(see the CHANGELOG). Checkhonors theservicefield. The empty string means “the whole server” and returns the mapped aggregate health. A name the server does not serve returnsSERVICE_UNKNOWN (3), regardless of overall health.ListandWatchare unimplemented. Connect auto-responsesunimplementedfor methods not provided by the bridge.- Reflection covers four request variants.
list_services,file_by_filename,file_containing_symbol(services, methods, messages, nested types, enums and extensions), andall_extension_numbers_of_type.file_containing_extensionanswersUNIMPLEMENTED— the framework registers no extensions. An unknown filename, symbol or type answersNOT_FOUND (5). - Reflection sees the app’s own protos. A registered service’s
DescFileand its transitivedependenciesare indexed, sofile_containing_symbolresolves types declared in imported protos too. Nothing beyond the registered services and the plugin’s own two files is exposed. - Descriptors are addressed by their suffixed proto path. Reflection clients ask for
example/echo.proto; Protobuf-ES’sDescFile.namedrops the.protosuffix whileproto.nameretains it, and the registry keys on the latter. - Lazy loading. The four npm specifiers (
@connectrpc/connect@^2.1.2,@bufbuild/protobuf@^2.7.0,@bufbuild/protobuf@^2.7.0/wkt) are loaded on firstregister(). Absence throwsGrpcRuntimeLoadErrorwith the install command. - No adapter seam. Since M70a the kernel dispatches gRPC from its terminal handler after the
middleware pipeline; dispatch depends on no adapter capability, so the plugin serves on every
runtime and
IGrpcService.availableis alwaystrue. The retiredsetRpcHandler?member is consulted by nothing, andGrpcUnavailableErrorremains exported only as published surface — nothing throws it.
- Native gRPC-binary is refused by design. Native gRPC (
application/grpc,+proto,+json) relies on HTTP/2 response trailers (specificallygrpc-status) for proper status signaling, and no fetch-based server runtime exposes them to aResponse— including Deno’sDeno.serve, Node.js, and Bun. Every native request is therefore answered with a Trailers-OnlyUNIMPLEMENTED(HTTP 200,content-type: application/grpc,grpc-status: 12) instead of half-serving the protocol. This is a deliberate design decision, not a platform bug. Since0.7.0the refusal is decided from the request HEADERS through the optionalIGrpcService.refuses?, which the kernel consults BEFORE it reads the body — without that, a client-streaming or bidirectional call held its request stream open, the read never resolved, and the caller received no frames at all rather than the refusal. Connect-JSON and gRPC-Web work completely on all runtimes; point native gRPC clients at a gRPC-Web-capable proxy or switch them to Connect (see the CHANGELOG migration notes).