@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-Only UNIMPLEMENTED response — see Limitations
  • Server reflection (v1, default ON), reachable over Connect and gRPC-Web — not from stock grpcurl/grpcui, which speak native application/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 at http://host:3000 reaches its procedures without a path prefix). Detection is still segment-aware: with the default, /grpcfoo and other prefix-adjacent routes stay yours.
  • reflection: Whether to enable server reflection (defaults to true)
  • health: Whether to enable gRPC Health v1 service (defaults to true)
  • services: Initial services to register at startup
  • connectModule: 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

ErrorThrown when
GrpcRuntimeLoadErrorAny of the four Connect/Protobuf-ES specifiers cannot be imported
GrpcDescriptorErrorAn embedded descriptor set cannot be decoded or lacks its expected service
GrpcUnavailableErrorDeprecated — 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 include application/json and application/proto, so matching them would hijack ordinary application routes. Point your client’s base URL at basePath. 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’s httpVersion option unset, because IHttpAdapter surfaces 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 clean 505. In practice this is benign — gRPC clients speak HTTP/2 — but note it also applies to this plugin’s OWN grpc.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/grpc call 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 web Request works 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-Only UNIMPLEMENTED response — HTTP 200, content-type: application/grpc, and grpc-status: 12 in the headers. The native wire protocol depends on HTTP/2 trailers for status signaling, and no fetch-based server runtime exposes them to a Response (Deno’s Deno.serve surfaces 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 fetch Response carries 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-formed UNIMPLEMENTED instead of an opaque transport error after a successful handshake. Measured with real grpcurl v1.9.3: a unary native call reports target server does not expose service … and exits 1. A bidi native call reports the same refusal, and since 0.7.0 it 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 through grpcurl — its -format flag selects the message encoding (json/text), not the wire protocol, and it implements native gRPC only. Use buf curl --protocol connect (or --protocol grpcweb), a generated Connect client, or a web client; for an existing native-gRPC fleet, put Envoy’s grpc_web filter in front.

Health Indicator

The plugin registers a health indicator named 'grpc' whose data reports:

  • available: whether gRPC dispatch is available — unconditionally true since M70a, because the kernel resolves IGrpcService from the service registry and dispatches after the middleware pipeline; the previous adapter-based seam (and the GrpcUnavailableError that guarded it) is retired
  • serviceCount: 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-plugin Options / Exports / Notes section
  • ARCHITECTURE.md — §7 the IHttpAdapter seam, §18 why the plugin does not hang off the kernel
  • ROADMAP.md — Milestone 49

Exports

ExportKind
adaptConnectModulefunction
GrpcPluginfunction
GrpcDescriptorErrorclass
GrpcRuntimeLoadErrorclass
GrpcServiceclass
GrpcUnavailableErrorclass
CAPABILITIESconst
ConnectModuleLikeinterface
GrpcPluginOptionsinterface
GrpcServiceDefinitioninterface
IGrpcServiceinterface
GrpcServingStatustype
RpcFetchHandlertype

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 no new Application() / app.use() API. Plugins register during app.start(), so do not resolve CAPABILITIES.GRPC before start() resolves — the capability does not exist yet and the lookup throws. Pass services through the services option (below) or call addService after start().

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

OptionTypeDefaultDescription
basePathstring/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.
reflectionbooleantrueRegister grpc.reflection.v1.ServerReflection. Bidi streaming — requires HTTP/2 or in-process fetch.
healthbooleantrueRegister grpc.health.v1.Health (Check only), bridged to the M20 health plugin.
servicesArray<{ definition, implementation? }>[]Initial services to register at startup. The recommended registration path: the capability does not exist before start().
connectModuleConnectRuntimeomittedInjected Connect runtime for tests; omitted triggers lazy import() of four npm specifiers.
interceptorsreadonly 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-Only UNIMPLEMENTED (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

ExportKindPurpose
GrpcPluginfunctionPlugin factory — registers IGrpcService under CAPABILITIES.GRPC
GrpcServiceclassThe IGrpcService implementation; exported so tests can compose it without subclassing
adaptConnectModulefunctionStructural adaptation of raw Connect/Protobuf modules into the internal ConnectRuntime port
GrpcUnavailableErrorclassDeprecated — nothing throws it since M70a; handleRequest needs no adapter capability
GrpcRuntimeLoadErrorclassThrown by loadConnectModule when any of the four npm specifiers cannot be imported
GrpcDescriptorErrorclassThrown when an embedded descriptor set cannot be decoded or lacks its expected service
GrpcPluginOptionstypeThe factory parameter shape
ConnectModuleLiketypeThe four-module bundle adaptConnectModule accepts

ConnectRuntime and 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 include application/json and application/proto. A path outside basePath returns null and 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. GrpcPlugin no longer calls adapter.setRpcHandler, which is deprecated and consulted by nothing.
  • IGrpcService.claims(request) is what keeps ordinary 404s intact. handleRequest returns Promise<Response> and never null, so it answers 404 both for a path outside basePath and for a claimed path with no such procedure — the two are indistinguishable once dispatched. The kernel therefore asks claims() 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’s Not Found (text/plain). claims is optional on the contract for source compatibility, and the kernel treats an implementor that lacks it as claiming nothing. A root basePath normalizes to '', which contains every path, so there claims reports only registered procedures rather than the whole application — otherwise a root-mounted service consulted before route matching would 404 every ordinary route.
  • IGrpcService.available is now always true. It used to report whether the resolved HTTP adapter implemented setRpcHandler; dispatch no longer depends on any adapter capability.
  • The request handed to handleRequest is reconstructed from the mapped IRequest (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, and inject() attaches the undisturbed web Request as IRequest.raw before running the pipeline, so an injected request reaches RPC dispatch exactly as a socket request does — the integration suite drives gRPC through inject(). The retired adapter interceptor is not involved: no setRpcHandler seam is consulted on any path.
  • Bidi streaming requires HTTP/2. grpc.reflection.v1.ServerReflection is 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 reporting SERVING here 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 on SERVING while a process is degraded now sees NOT_SERVING (see the CHANGELOG).
  • Check honors the service field. The empty string means “the whole server” and returns the mapped aggregate health. A name the server does not serve returns SERVICE_UNKNOWN (3), regardless of overall health.
  • List and Watch are unimplemented. Connect auto-responses unimplemented for 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), and all_extension_numbers_of_type. file_containing_extension answers UNIMPLEMENTED — the framework registers no extensions. An unknown filename, symbol or type answers NOT_FOUND (5).
  • Reflection sees the app’s own protos. A registered service’s DescFile and its transitive dependencies are indexed, so file_containing_symbol resolves 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’s DescFile.name drops the .proto suffix while proto.name retains 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 first register(). Absence throws GrpcRuntimeLoadError with 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.available is always true. The retired setRpcHandler? member is consulted by nothing, and GrpcUnavailableError remains 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 (specifically grpc-status) for proper status signaling, and no fetch-based server runtime exposes them to a Response — including Deno’s Deno.serve, Node.js, and Bun. Every native request is therefore answered with a Trailers-Only UNIMPLEMENTED (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. Since 0.7.0 the refusal is decided from the request HEADERS through the optional IGrpcService.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).

Agent card · TypeDoc API reference