# connect-rust First 30 Minutes Wiki

> connect-rust is a tower-based ConnectRPC runtime for Rust that lets you build and consume gRPC, gRPC-Web, and Connect-protocol services using generated Rust code from .proto files. It ships three crates — a runtime library, a code-generation library, and a build.rs integration — wired together through a Tower service model.

## Context Links

- [Agent index](https://grok-wiki.com/public/wiki/anthropics-connect-rust-abe117693c52/llms.txt)
- [Human interactive wiki](https://grok-wiki.com/public/wiki/anthropics-connect-rust-abe117693c52)
- [GitHub repository](https://github.com/anthropics/connect-rust)

## Repository Metadata

- Repository: anthropics/connect-rust

- Generated: 2026-05-21T02:35:35.889Z
- Updated: 2026-05-21T21:34:43.297Z
- Runtime: Claude Code
- Format: First 30 Minutes
- Pages: 6

## Page Index

- 01. [Start Here: What This Repo Is & Where to Begin](https://grok-wiki.com/public/wiki/anthropics-connect-rust-abe117693c52/pages/01-start-here-what-this-repo-is-where-to-begin.md) - What connect-rust is, the three-crate layout (connectrpc / connectrpc-codegen / connectrpc-build), the fastest read order for a new contributor, key vocabulary (ConnectRPC, buffa, Tower service, Spec, StreamType), and which files to open first.
- 02. [Build Setup & Code Generation Pipeline](https://grok-wiki.com/public/wiki/anthropics-connect-rust-abe117693c52/pages/02-build-setup-code-generation-pipeline.md) - How .proto files become Rust service traits: connectrpc-build in build.rs, the protoc-gen-connect-rust buf plugin, the two generation modes (unified vs. service-stubs-only), checked-in generated directories, and the task generate:all workflow.
- 03. [Handlers, Router & ConnectRpcService](https://grok-wiki.com/public/wiki/anthropics-connect-rust-abe117693c52/pages/03-handlers-router-connectrpcservice.md) - The server-side dispatch path: implementing the generated FooService handler trait, registering methods on Router, wrapping the router in ConnectRpcService (the Tower service), and mounting it with Axum or raw Hyper. Covers unary and streaming handler shapes.
- 04. [Interceptors & Spec Metadata](https://grok-wiki.com/public/wiki/anthropics-connect-rust-abe117693c52/pages/04-interceptors-spec-metadata.md) - RPC-level interceptors as a typed alternative to Tower middleware: Interceptor trait, Next/NextStream continuations, unary vs. streaming intercept surfaces, registration order, and zero-cost opt-out. Also covers Spec (per-method static metadata) and StreamType, which interceptors use to label spans and gate behaviour without re-parsing URLs.
- 05. [Protocol, Codec & Client Transport](https://grok-wiki.com/public/wiki/anthropics-connect-rust-abe117693c52/pages/05-protocol-codec-client-transport.md) - How the three wire protocols (Connect, gRPC, gRPC-Web) are detected and demultiplexed; envelope framing (5-byte header); codec format selection (proto vs. JSON); compression (gzip, zstd); and the client-side Tower HTTP transport in connectrpc/src/client/. Covers where to look when adding a new codec or compression algorithm.
- 06. [After 30 Minutes: What to Try Next](https://grok-wiki.com/public/wiki/anthropics-connect-rust-abe117693c52/pages/06-after-30-minutes-what-to-try-next.md) - Synthesis of what you now understand — the codegen pipeline, Tower dispatch, interceptor chain, and protocol demux — plus concrete next actions: run the conformance suite, walk the examples (eliza, multiservice, streaming-tour, wasm-client), read docs/guide.md sections on TLS and streaming, and understand how task ci gates every PR.

## Source File Index

- `Cargo.toml`
- `CHANGELOG.md`
- `conformance/buf.gen.yaml`
- `conformance/Cargo.toml`
- `connectrpc-build/src/lib.rs`
- `connectrpc-codegen/src/codegen.rs`
- `connectrpc-codegen/src/lib.rs`
- `connectrpc-codegen/src/plugin.rs`
- `connectrpc/src/axum.rs`
- `connectrpc/src/client/http2.rs`
- `connectrpc/src/client/mod.rs`
- `connectrpc/src/codec.rs`
- `connectrpc/src/compression.rs`
- `connectrpc/src/dispatcher.rs`
- `connectrpc/src/envelope.rs`
- `connectrpc/src/error.rs`
- `connectrpc/src/grpc_status.rs`
- `connectrpc/src/handler.rs`
- `connectrpc/src/interceptor.rs`
- `connectrpc/src/lib.rs`
- `connectrpc/src/payload.rs`
- `connectrpc/src/protocol.rs`
- `connectrpc/src/response.rs`
- `connectrpc/src/router.rs`
- `connectrpc/src/service.rs`
- `connectrpc/src/spec.rs`
- `CONTRIBUTING.md`
- `docs/guide.md`
- `examples/eliza/src`
- `examples/eliza/src/generated`
- `examples/multiservice/src`
- `examples/streaming-tour/src`
- `examples/wasm-client/src`
- `README.md`
- `Taskfile.yaml`

---

## 01. Start Here: What This Repo Is & Where to Begin

> What connect-rust is, the three-crate layout (connectrpc / connectrpc-codegen / connectrpc-build), the fastest read order for a new contributor, key vocabulary (ConnectRPC, buffa, Tower service, Spec, StreamType), and which files to open first.

- Page Markdown: https://grok-wiki.com/public/wiki/anthropics-connect-rust-abe117693c52/pages/01-start-here-what-this-repo-is-where-to-begin.md
- Generated: 2026-05-21T02:33:11.650Z

### Source Files

- `README.md`
- `Cargo.toml`
- `connectrpc/src/lib.rs`
- `docs/guide.md`
- `CONTRIBUTING.md`

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [README.md](README.md)
- [Cargo.toml](Cargo.toml)
- [connectrpc/src/lib.rs](connectrpc/src/lib.rs)
- [connectrpc/src/spec.rs](connectrpc/src/spec.rs)
- [connectrpc/src/interceptor.rs](connectrpc/src/interceptor.rs)
- [connectrpc/src/payload.rs](connectrpc/src/payload.rs)
- [connectrpc-codegen/src/lib.rs](connectrpc-codegen/src/lib.rs)
- [connectrpc-build/src/lib.rs](connectrpc-build/src/lib.rs)
- [docs/guide.md](docs/guide.md)
- [CONTRIBUTING.md](CONTRIBUTING.md)
</details>

# Start Here: What This Repo Is & Where to Begin

`connect-rust` is a [Tower](https://docs.rs/tower/latest/tower/)-based Rust implementation of the [ConnectRPC](https://connectrpc.com/) protocol. It lets you build servers and clients that speak Connect, gRPC, and gRPC-Web over HTTP — using the same transport-agnostic `tower::Service` abstraction the broader Rust web ecosystem relies on. The library passes 3,600 server and 6,872 client conformance tests across all three protocols and is production-quality even at its pre-1.0 version.

This page orients you to the repository's structure, key terms, and the fastest reading path before you touch code. It answers the question every new contributor has: *where do I start?*

---

## The Three-Crate Layout

The workspace (`Cargo.toml`) publishes three crates that work together:

```text
connect-rust/
├── connectrpc/         ← runtime library (Tower service, codecs, compression, interceptors)
├── connectrpc-codegen/ ← code generation library + protoc-gen-connect-rust binary
└── connectrpc-build/   ← build.rs integration (wraps codegen for use at build time)
```

Sources: [Cargo.toml:1-11](), [docs/guide.md:27-33]()

### `connectrpc` — The Runtime

This is the crate your application code depends on at runtime. It provides:

- **`ConnectRpcService`** — the core `tower::Service` that receives HTTP requests and dispatches them to registered handlers.
- **`Router`** — collects RPC handler registrations; call `.register()` on your service impl to populate it.
- **`Handler` / `ViewHandler` / streaming variants** — async handler traits your service structs implement. The `View`-prefixed variants return zero-copy `buffa` view types directly from the request buffer.
- **`Interceptor` / `Next` / `NextStream`** — typed RPC middleware: runs after envelope decoding but before handler invocation; has access to `Spec`, headers, deadline, and a lazily decoded `Payload`.
- **`Spec` / `StreamType`** — static per-method metadata (see [Key Vocabulary](#key-vocabulary) below).
- **`Payload` / `AnyMessage`** — type-erased, lazily-decoded message bodies used by interceptors.
- **`client` module** — `HttpClient` and `Http2Connection` transports for generated clients (enabled by the `client` feature flag).
- **`Server`** — a built-in standalone hyper server (enabled by the `server` feature).

Sources: [connectrpc/src/lib.rs:1-200](), [connectrpc/src/lib.rs:220-315]()

### `connectrpc-codegen` — Code Generation Library and Plugin

This crate lives in two roles simultaneously:

1. **Library** (`codegen::generate_files` / `codegen::generate_services`): drives code generation from compiled proto descriptors. Used internally by `connectrpc-build` and the plugin binary.
2. **Binary** (`protoc-gen-connect-rust`): a `protoc` / `buf` plugin that generates service traits, client structs, and `Spec` constants from `.proto` files. Install it with `cargo install --locked connectrpc-codegen` or download a release binary.

Two generation modes exist:
- **Unified** (`generate_files`): message types + service stubs in one file per proto, with `super::`-relative paths. Used by `connectrpc-build`.
- **Service-stubs only** (`generate_services`): references message types via configurable absolute paths (e.g. `crate::proto::greet::v1::GreetRequest`). Used by the `protoc-gen-connect-rust` plugin when paired with `buf generate`.

Sources: [connectrpc-codegen/src/lib.rs:1-43]()

### `connectrpc-build` — Build-time Integration

A `build.rs` helper that shells out to `protoc` (or `buf`, or reads a pre-compiled `FileDescriptorSet`) to obtain descriptors, then calls `connectrpc-codegen` to write Rust code into `$OUT_DIR`. After adding this as a build-dependency, you reference the output with `connectrpc::include_generated!()`.

```rust
// build.rs
fn main() {
    connectrpc_build::Config::new()
        .files(&["proto/greet.proto"])
        .includes(&["proto/"])
        .include_file("_connectrpc.rs")
        .compile()
        .unwrap();
}
```

Sources: [connectrpc-build/src/lib.rs:1-54]()

---

## Key Vocabulary

New contributors encounter a handful of terms that don't resolve obviously from the crate names alone.

| Term | What it means |
|---|---|
| **ConnectRPC** | The [protocol specification](https://connectrpc.com/docs/protocol/) that defines how unary and streaming RPC calls are framed over HTTP/1.1 and HTTP/2. Connect is one of three protocols this library speaks; the other two are gRPC and gRPC-Web. |
| **buffa** | The companion protobuf runtime from `github.com/anthropics/buffa`. Replaces prost. Its key feature is *view types*: zero-copy structs that borrow string and bytes fields directly from the incoming request buffer, avoiding per-field allocations. `buffa = { version = "0.6" }` is a workspace dependency. |
| **Tower service** | `tower::Service<Request, Response = Response, Error = Infallible>`. `ConnectRpcService` implements this trait, making it composable with any Tower-compatible HTTP framework (Axum, Hyper) or middleware stack. |
| **`Spec`** | A `Copy`, `'static` struct emitted as a `const` by codegen for each RPC method. Carries the fully-qualified procedure path (`"/pkg.Service/Method"`), its `StreamType`, its idempotency level, and whether it lives on the client or server side (`SpecOrigin`). Interceptors read `Spec` to label tracing spans or route behavior without re-parsing the URL. |
| **`StreamType`** | An enum with four variants: `Unary`, `ClientStream`, `ServerStream`, `BidiStream`. Records how many messages flow in each direction on a given RPC. The connect-go naming is used so cross-runtime interceptor logic ports cleanly. |
| **`Payload`** | A type-erased, lazily-decoded request/response body. Holds wire bytes as a reference-counted `Bytes` and decodes to the typed message on first access. Interceptors that never inspect message content pay zero decode cost. |
| **`Interceptor`** | A typed RPC middleware trait. Unlike a raw Tower layer, an interceptor runs *after* envelope decoding and header parsing — it sees the already-understood `Spec` and `Payload`, not raw bytes. Registered via `ConnectRpcService::with_interceptor`. |
| **`MethodKind` / `MethodDescriptor`** | Internal routing-table equivalents of `StreamType`; used by `Router` when registering handlers. `StreamType` is the interceptor-facing version; prefer it in interceptor code. |

Sources: [connectrpc/src/spec.rs:1-60](), [connectrpc/src/interceptor.rs:1-42](), [connectrpc/src/payload.rs:1-15]()

---

## How the Three Pieces Fit Together

```text
  .proto files
       │
       ▼
┌─────────────────────────────────────┐
│  Code Generation (pick one path)    │
│                                     │
│  Option A: buf generate             │
│    protoc-gen-buffa   → message types (src/generated/buffa/)
│    protoc-gen-connect-rust → service stubs (src/generated/connect/)
│                                     │
│  Option B: build.rs                 │
│    connectrpc-build::Config::compile()
│    → unified .rs into $OUT_DIR/     │
└─────────────────────────────────────┘
       │
       ▼ (generated output)
 FooServiceServer<T>   FooServiceClient<T>   const SPEC_FOO: Spec
       │                       │
       ▼                       ▼
┌──────────────┐    ┌──────────────────────┐
│  connectrpc  │    │  connectrpc client   │
│  Router      │    │  HttpClient /        │
│  Dispatcher  │    │  Http2Connection     │
│  ConnectRpc  │    └──────────────────────┘
│  Service     │
│  (tower::    │
│   Service)   │
└──────────────┘
       │
       ▼
  Axum / Hyper / standalone Server
```

Sources: [README.md:23-30](), [connectrpc/src/lib.rs:62-73]()

---

## The Two Codegen Workflows

### Option A — `buf generate` (checked-in code)

Run `buf generate` with two plugins: `protoc-gen-buffa` (message types) and `protoc-gen-connect-rust` (service stubs). Each produces its own output directory. The `buffa_module=crate::proto` option tells the service-stub generator where the buffa output is mounted in your crate, so it emits absolute paths like `crate::proto::greet::v1::GreetRequest`.

Use this when you want generated code checked into source control and reviewed in PRs.

### Option B — `connectrpc-build` (build-time generation)

Add `connectrpc-build` as a build dependency and call it from `build.rs`. Message types and service stubs appear in one file per proto under `$OUT_DIR`, referenced via `connectrpc::include_generated!()`. No plugin binaries on `PATH` at build time (only `protoc` or `buf`).

Use this for simpler projects or when you don't want generated code in git.

Sources: [README.md:57-179](), [connectrpc-build/src/lib.rs:1-33]()

---

## Feature Flags

The runtime is gated so you only pay for what you use. Defaults include compression; networking is opt-in.

| Feature | Default | What it adds |
|---|---|---|
| `gzip` | yes | Gzip compression (flate2) |
| `zstd` | yes | Zstandard compression |
| `streaming` | yes | Streaming compression (async-compression) |
| `client` | **no** | HTTP client transports (plaintext) |
| `client-tls` | **no** | TLS for client transports |
| `server` | **no** | Built-in hyper `Server` |
| `server-tls` | **no** | TLS for the built-in server |
| `tls` | **no** | Convenience alias: both server-tls + client-tls |
| `axum` | **no** | Axum integration (`into_axum_service`) |

The core crate also compiles for `wasm32-unknown-unknown`. Client transports, server, TLS, and `zstd` require native targets and are excluded on wasm.

Sources: [connectrpc/src/lib.rs:139-152](), [README.md:307-340]()

---

## Fastest Read Order for a New Contributor

Work through these in order. Each step builds on the previous and should take 5–15 minutes.

### Step 1 — Orient yourself (15 min)

| File | What to notice |
|---|---|
| [`README.md`](README.md) | The quick-start shows the full unary server + Axum loop. Read the feature-flag table and the protocol support matrix. |
| [`CONTRIBUTING.md`](CONTRIBUTING.md) | Prerequisites (Rust 1.88, protoc v27+, buf, task), change-size limit (≤250 net lines), test-coverage expectations, and the `task` command inventory. |
| [`Cargo.toml`](Cargo.toml) | The workspace member list, the `buffa` dependency version floor, and the compression / TLS / framework dependency choices. |

### Step 2 — The runtime entry point (20 min)

| File | What to notice |
|---|---|
| [`connectrpc/src/lib.rs`](connectrpc/src/lib.rs) | Module inventory and the full re-export surface. Every public symbol the library exposes is listed here. The `__codegen` hidden module is what generated code calls. |
| [`connectrpc/src/spec.rs`](connectrpc/src/spec.rs) | `Spec`, `StreamType`, `IdempotencyLevel`, `SpecOrigin`. These four types are the lingua franca of interceptors and generated code; understanding them unlocks the rest. |

### Step 3 — Interceptors and Payload (15 min)

| File | What to notice |
|---|---|
| [`connectrpc/src/interceptor.rs`](connectrpc/src/interceptor.rs) | The `Interceptor` trait and how unary vs streaming interceptors differ. Notice the ASCII diagram of interception order. |
| [`connectrpc/src/payload.rs`](connectrpc/src/payload.rs) | Why `Payload` is lazily decoded: most interceptors never look inside the message. |

### Step 4 — Code generation (15 min)

| File | What to notice |
|---|---|
| [`connectrpc-codegen/src/lib.rs`](connectrpc-codegen/src/lib.rs) | The two generation modes (`generate_files` vs `generate_services`) and when each is used. |
| [`connectrpc-build/src/lib.rs`](connectrpc-build/src/lib.rs) | The `Config` builder: how `DescriptorSource` (protoc / buf / precompiled) is selected at build time. |

### Step 5 — The user guide and examples (30 min)

| File | What to notice |
|---|---|
| [`docs/guide.md`](docs/guide.md) | Long-form coverage of every topic: installation, codegen, streaming, interceptors, Tower middleware, TLS, errors, compression. Read the sections that match your task. |
| [`examples/`](examples/) | Runnable examples: `eliza` (TLS), `middleware` (Tower auth layer), `streaming-tour` (all four RPC types side by side), `wasm-client` (Fetch transport), `multiservice` (multi-service router). |

---

## Files to Open First

If you are fixing a bug or adding a feature, these files are most likely to be involved:

```text
connectrpc/src/
├── lib.rs          ← re-export surface; start here to find any symbol
├── spec.rs         ← Spec, StreamType — shared by codegen and runtime
├── interceptor.rs  ← Interceptor trait; the RPC middleware API
├── handler.rs      ← Handler traits your service structs implement
├── router.rs       ← MethodKind, Router — the routing table
├── service.rs      ← ConnectRpcService — the tower::Service impl
├── payload.rs      ← Payload, AnyMessage — lazy decode
├── codec.rs        ← ProtoCodec, JsonCodec — encode/decode
├── compression.rs  ← CompressionProvider, CompressionRegistry
└── client/         ← HttpClient, Http2Connection, CallOptions

connectrpc-codegen/src/
├── lib.rs          ← two generation entry points
└── codegen/        ← quote!-based code emitters

connectrpc-build/src/
└── lib.rs          ← Config builder; DescriptorSource variants
```

---

## What the Workspace Also Contains

Beyond the three published crates, the workspace includes:

| Path | Purpose |
|---|---|
| `conformance/` | Conformance test runner binaries (server and client) that talk to the upstream ConnectRPC conformance suite |
| `examples/eliza/`, `examples/streaming-tour/`, etc. | Runnable end-to-end examples; see `docs/guide.md` for a tour |
| `tests/streaming` | In-workspace integration consumer of `connectrpc-build` — the canonical test of the build-time codegen path |
| `benches/rpc/` | Criterion benchmarks; `task bench:cross:quick` runs them |
| `docs/specs/` | Local copies of the Connect, gRPC, and gRPC-Web protocol specs; fetch with `task specs:fetch` |

Sources: [Cargo.toml:2](), [CONTRIBUTING.md:1-120]()

---

## Summary

`connect-rust` is a three-crate workspace: `connectrpc` (the Tower-based runtime), `connectrpc-codegen` (the `protoc-gen-connect-rust` plugin library and binary), and `connectrpc-build` (the `build.rs` wrapper). The vocabulary that glues them together is small: `Spec` describes a method statically, `StreamType` describes its message shape, `Payload` defers decode cost to interceptors that actually need it, and `buffa` provides the zero-copy protobuf message types. Start with `connectrpc/src/lib.rs` to see every public symbol in one place, then read `connectrpc/src/spec.rs` and `docs/guide.md` before touching anything else.

Sources: [connectrpc/src/lib.rs:1-75](), [connectrpc/src/spec.rs:1-40]()

---

## 02. Build Setup & Code Generation Pipeline

> How .proto files become Rust service traits: connectrpc-build in build.rs, the protoc-gen-connect-rust buf plugin, the two generation modes (unified vs. service-stubs-only), checked-in generated directories, and the task generate:all workflow.

- Page Markdown: https://grok-wiki.com/public/wiki/anthropics-connect-rust-abe117693c52/pages/02-build-setup-code-generation-pipeline.md
- Generated: 2026-05-21T02:33:34.472Z

### Source Files

- `connectrpc-build/src/lib.rs`
- `connectrpc-codegen/src/lib.rs`
- `connectrpc-codegen/src/codegen.rs`
- `connectrpc-codegen/src/plugin.rs`
- `Taskfile.yaml`
- `conformance/buf.gen.yaml`
- `examples/eliza/src/generated`

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [connectrpc-build/src/lib.rs](connectrpc-build/src/lib.rs)
- [connectrpc-codegen/src/lib.rs](connectrpc-codegen/src/lib.rs)
- [connectrpc-codegen/src/codegen.rs](connectrpc-codegen/src/codegen.rs)
- [connectrpc-codegen/src/plugin.rs](connectrpc-codegen/src/plugin.rs)
- [Taskfile.yaml](Taskfile.yaml)
- [conformance/buf.gen.yaml](conformance/buf.gen.yaml)
- [examples/eliza/buf.gen.yaml](examples/eliza/buf.gen.yaml)
- [examples/eliza/src/generated/connect/connectrpc.eliza.v1.eliza.__connect.rs](examples/eliza/src/generated/connect/connectrpc.eliza.v1.eliza.__connect.rs)
</details>

# Build Setup & Code Generation Pipeline

This page explains how `.proto` service definitions become Rust service traits and client structs in connect-rust. The pipeline has two distinct entry points — a `build.rs` integration crate (`connectrpc-build`) and a `protoc`/`buf` plugin binary (`protoc-gen-connect-rust`) — each suited to a different workflow. Understanding the difference between unified and split generation, and knowing which checked-in directories exist, will save you from mysterious compile errors and regeneration confusion.

The core library (`connectrpc-codegen`) sits underneath both entry points. It owns all `quote!`-based Rust emission logic and exposes two public functions: `generate_files` (unified, used by `connectrpc-build`) and `generate_services` (service-stubs-only, used by the plugin). Both are built on `buffa-codegen` for protobuf message types; connect-rust adds only the service traits, clients, and `Encodable` impls.

---

## Prerequisites

| Tool | Minimum | Purpose |
|---|---|---|
| Rust | 1.88 (MSRV) | Compiles the workspace; requires let-chains |
| `protoc` | v27+ | Default descriptor source (editions syntax) |
| `buf` | any current | BSR module resolution, checked-in regeneration |
| `task` (go-task) | any | Command runner — `task --list` shows all targets |

`protoc` can be overridden with the `PROTOC` environment variable. When using `buf` mode, `protoc` is not needed at all. Sources: [connectrpc-build/src/lib.rs:377](), [conformance/buf.gen.yaml:1-33]()

---

## Two Entry Points, Two Generation Modes

```text
.proto files
     │
     ├─── connectrpc-build (build.rs) ──► generate_files()  ─► $OUT_DIR/
     │    Unified: messages + stubs      (buffa types + service traits
     │    in one module tree)             in one $OUT_DIR tree)
     │
     └─── protoc-gen-connect-rust ────► generate_services() ─► out/connect/
          (buf plugin, checked-in code)   Service stubs only; message types
                                          referenced via extern_paths
```

### Unified mode (`connectrpc-build` / `generate_files`)

Used in `build.rs`. Emits buffa message types **and** ConnectRPC service stubs together into `$OUT_DIR`. Service stubs reference message types with `super::`-relative paths, so both must live in the same module tree. This is the simplest workflow for projects that don't check in generated code.

```rust
// build.rs
fn main() {
    connectrpc_build::Config::new()
        .files(&["proto/my_service.proto"])
        .includes(&["proto/"])
        .include_file("_connectrpc.rs")
        .compile()
        .unwrap();
}

// lib.rs
connectrpc::include_generated!();
```

Sources: [connectrpc-build/src/lib.rs:1-27](), [connectrpc-codegen/src/lib.rs:16-20]()

### Split mode (`protoc-gen-connect-rust` / `generate_services`)

Used as a `buf` plugin to regenerate checked-in code. Emits **service stubs only** (`<stem>.__connect.rs` files) into a separate output directory. Message types are referenced via absolute Rust paths configured with `buffa_module=<rust_path>`. This is the workflow for checked-in generated code and BSR cargo SDKs.

Sources: [connectrpc-codegen/src/lib.rs:22-27](), [connectrpc-codegen/src/codegen.rs:274-285]()

---

## `connectrpc-build`: The `build.rs` Integration

`connectrpc-build` is a builder-pattern crate you add to `build-dependencies`. Its `Config` struct collects options and then drives the full pipeline on `compile()`.

### Descriptor acquisition

`Config` supports three ways to get a `FileDescriptorSet`:

| Mode | Trigger | Tool required |
|---|---|---|
| `Protoc` (default) | — | `protoc` on `PATH` or `$PROTOC` |
| `Buf` | `.use_buf()` | `buf` on `PATH` |
| `Precompiled` | `.descriptor_set("path.bin")` | none at build time |

Under `Protoc` mode, includes are sorted longest-first before stripping so that nested prefix directories (`proto/vendor/`) beat shallower ones (`proto/`). Under `Buf` and `Precompiled` modes, `.files()` takes proto-relative names (e.g. `"my/service.proto"`) not filesystem paths — the include stripping does not apply. Sources: [connectrpc-build/src/lib.rs:44-54](), [connectrpc-build/src/lib.rs:270-294]()

### The `compile()` pipeline

```
1. Acquire FileDescriptorSet bytes
   (run_protoc / run_buf / read precompiled file)
2. Decode FileDescriptorSet via buffa
3. codegen::generate_files(fds, files_to_generate, options)
4. Write per-file .rs outputs (write_if_changed)
5. Optionally emit include file (_connectrpc.rs)
6. Emit cargo:rerun-if-changed directives
```

`write_if_changed` skips writing identical content to avoid bumping mtimes and forcing downstream recompilation on every proto touch. Sources: [connectrpc-build/src/lib.rs:249-353](), [connectrpc-build/src/lib.rs:366-373]()

### Key builder methods

| Method | Default | Effect |
|---|---|---|
| `.files(&[...])` | required | Proto files to compile |
| `.includes(&[...])` | `[]` | Import search dirs (protoc only) |
| `.use_buf()` | — | Use `buf build` instead of `protoc` |
| `.descriptor_set("x.bin")` | — | Read precompiled FDS |
| `.include_file("_inc.rs")` | none | Emit module-tree include file |
| `.file_per_package(true)` | `false` | Emit one `.rs` per package (unified layout) |
| `.generate_json(false)` | `true` | Disable serde derives |
| `.emit_rerun_directives(false)` | `true` | For non-Cargo build systems |
| `.buffa_config(cfg)` | — | Replace buffa CodeGenConfig wholesale |

Sources: [connectrpc-build/src/lib.rs:83-190]()

---

## `protoc-gen-connect-rust`: The buf Plugin

The plugin binary lives in `connectrpc-codegen/src/main.rs` (installed via `cargo install --path connectrpc-codegen`). It implements the protoc plugin protocol: reads a `CodeGeneratorRequest` from stdin, calls `codegen::generate()`, and writes a `CodeGeneratorResponse` to stdout.

`generate()` parses comma-separated plugin `opt` parameters then delegates to `generate_services`. Sources: [connectrpc-codegen/src/codegen.rs:426-483]()

### Plugin options

| Option | Effect |
|---|---|
| `buffa_module=crate::proto` | Set the catch-all extern path (most common) |
| `extern_path=.pkg=::rust::path` | Map a specific proto package prefix (repeatable) |
| `file_per_package` | Emit one `<dotted.pkg>.rs` per package (no stitchers) |
| `strict_utf8_mapping` | Emit `Vec<u8>` for non-utf8 string fields |
| `no_json` | Accepted for compatibility; ignored (no messages emitted) |
| `no_register_fn` | Accepted for compatibility; ignored |

At least one catch-all extern path (`buffa_module` or `extern_path=.=...`) is required so every message type resolves. Sources: [connectrpc-codegen/src/codegen.rs:398-476]()

---

## Output File Layout

### Per-proto split (default)

For each `.proto` with at least one `service`, both modes emit:

```text
out/
├── <stem>.rs                  # buffa message types (unified only)
├── <stem>.__view.rs           # buffa view types (unified only)
├── <stem>.__connect.rs        # ConnectRPC service traits + clients
└── <pkg>.mod.rs               # Package stitcher (include!s siblings)
```

The `.__connect.rs` suffix is intentional — it avoids colliding with buffa's own per-proto filenames (`<stem>.rs`, `<stem>.__view.rs`, etc.). The package stitcher (`<pkg>.mod.rs`) is wired by `apply_companions` in the unified path, or emitted directly by `generate_services` in the split path. Sources: [connectrpc-codegen/src/codegen.rs:109-125](), [connectrpc-codegen/src/codegen.rs:182-184]()

### `file_per_package` layout

When `file_per_package` is enabled, everything collapses to one file per proto package:

```text
out/
└── <dotted.pkg>.rs   # messages + service stubs inlined (no companion files, no stitcher)
```

This mirrors the BSR/tonic filename convention. No `__connect.rs` siblings exist; no `protoc-gen-buffa-packaging` invocations are needed. Sources: [connectrpc-codegen/src/codegen.rs:168-178](), [connectrpc-build/src/lib.rs:151-174]()

### The include file

When `.include_file("_inc.rs")` is set, `connectrpc-build` emits a module-tree include file with nested `pub mod` blocks matching the proto package hierarchy. Include paths use either `env!("OUT_DIR")` (default `$OUT_DIR` workflow) or sibling-relative strings (when `out_dir()` is set explicitly).

```rust
// Generated _inc.rs (env!("OUT_DIR") form)
#[allow(impl_trait_redundant_captures, ...)]
pub mod connectrpc {
    use super::*;
    pub mod eliza {
        use super::*;
        pub mod v1 {
            use super::*;
            include!(concat!(env!("OUT_DIR"), "/connectrpc.eliza.v1.mod.rs"));
        }
    }
}
```

Sources: [connectrpc-build/src/lib.rs:463-545]()

---

## Checked-In Generated Directories

Four directories contain `buf generate` output that must be regenerated whenever `connectrpc-codegen` changes or the `buffa` dependency is bumped:

| Directory | Proto source | Notes |
|---|---|---|
| `conformance/src/generated/` | `buf.build/connectrpc/conformance` (BSR) | Separate `buffa/` and `connect/` subdirs |
| `examples/eliza/src/generated/` | local `proto/` | Same split layout |
| `examples/multiservice/src/generated/` | local `proto/` | Same split layout |
| `benches/rpc/src/generated/` | local `proto/` | Same split layout |

All four use the split layout: `protoc-gen-buffa` into `src/generated/buffa/`, `protoc-gen-connect-rust` into `src/generated/connect/`. Each directory has its own `buf.gen.yaml`.

### Example: eliza `buf.gen.yaml`

```yaml
# examples/eliza/buf.gen.yaml
version: v2
plugins:
  - local: ../../../buffa/target/release/protoc-gen-buffa
    out: src/generated/buffa
    opt: [views=true, json=true]
  - local: ../../../buffa/target/release/protoc-gen-buffa-packaging
    out: src/generated/buffa
    strategy: all
  - local: ../../target/release/protoc-gen-connect-rust
    out: src/generated/connect
    opt: [buffa_module=crate::proto]
  - local: ../../../buffa/target/release/protoc-gen-buffa-packaging
    out: src/generated/connect
    strategy: all
    opt: [filter=services]
```

The second `protoc-gen-buffa-packaging` invocation with `filter=services` emits the `<pkg>.mod.rs` stitcher for the connect output tree, covering only packages with service-declaring protos. Sources: [examples/eliza/buf.gen.yaml:1-16](), [conformance/buf.gen.yaml:1-33]()

### What the generated connect file looks like

The `.__connect.rs` files use fully-qualified paths (`::connectrpc::`, `::buffa::`) throughout — no top-level `use` statements — so multiple generated files can be `include!`d into the same module without E0252 collisions. Message types are referenced through the `buffa_module` path (e.g. `crate::proto::connectrpc::eliza::v1::SayResponse`). Sources: [examples/eliza/src/generated/connect/connectrpc.eliza.v1.eliza.__connect.rs:1-60]()

---

## The `task generate:all` Workflow

`task generate:all` is the single command to regenerate all four checked-in directories:

```bash
task generate:all
```

It runs the following steps in order:

```
1. cargo build --release -p connectrpc-codegen --bin protoc-gen-connect-rust
   (build the plugin from the current source)
2. cargo build --release --manifest-path ../buffa/Cargo.toml \
       -p protoc-gen-buffa -p protoc-gen-buffa-packaging
   (build sibling buffa plugins)
3. task example:eliza:generate    → cd examples/eliza && buf generate
4. task example:multiservice:generate → cd examples/multiservice && buf generate
5. task conformance:generate      → cd conformance && BUF_TOKEN="" buf generate
6. task bench:generate            → cd benches/rpc && buf generate
```

The build steps use release mode because `buf.gen.yaml` references the plugin binaries at `../../target/release/protoc-gen-connect-rust`. If you're iterating on codegen, run `task generate:all` after every change. CI detects stale generated code as compile errors in the `Check`/`Test` jobs, not as a dedicated stale-check. Sources: [Taskfile.yaml:368-376](), [Taskfile.yaml:119-123]()

### Local buffa override

When testing combined speculative changes across both repos:

```bash
task buffa:link    # writes .cargo/config.toml with path overrides to ../buffa
task buffa:unlink  # removes the override
```

The `.cargo/config.toml` override is gitignored. Sources: [Taskfile.yaml:98-109]()

---

## Sequence: How a `.proto` Becomes a Rust Trait

```mermaid
sequenceDiagram
    participant BR as build.rs / buf generate
    participant CB as connectrpc-build Config
    participant PC as protoc / buf
    participant CG as codegen::generate_files
    participant BC as buffa-codegen
    participant FS as $OUT_DIR / src/generated/

    BR->>CB: Config::new().files(...).compile()
    CB->>PC: run_protoc() / run_buf()
    PC-->>CB: FileDescriptorSet bytes
    CB->>CG: generate_files(fds, files, options)
    CG->>BC: buffa_codegen::generate(...)
    BC-->>CG: per-proto GeneratedFiles (Owned, View, Oneof, PackageMod)
    CG->>CG: emit_service_files() → __connect.rs Companion files
    CG->>CG: apply_companions() wires companions into PackageMod stitchers
    CG-->>CB: Vec<GeneratedFile>
    CB->>FS: write_if_changed() for each file
    CB->>FS: generate_include_file() → _inc.rs (if configured)
    CB->>BR: cargo:rerun-if-changed directives
```

The key invariant enforced in debug builds: every `Companion` (`.__connect.rs`) file must be referenced by an `include!` inside a matching `PackageMod` stitcher. If `apply_companions` orphans a companion, the service trait silently vanishes at use-site. Sources: [connectrpc-codegen/src/codegen.rs:155-200]()

---

## Summary

The connect-rust build pipeline revolves around two modes sharing one codegen core. The `connectrpc-build` crate handles the `build.rs` path end-to-end: it shells out to `protoc` or `buf`, decodes the descriptor set, calls `generate_files` for unified output, and writes everything to `$OUT_DIR`. The `protoc-gen-connect-rust` plugin handles checked-in regeneration via `buf generate`: it emits service stubs only (`__connect.rs` files) into a separate directory alongside buffa's message output, using `buffa_module=crate::proto` to resolve all type paths. The single `task generate:all` command rebuilds all plugin binaries from source and reruns `buf generate` in all four checked-in directories, ensuring the generated code stays synchronized with the codegen logic. Sources: [connectrpc-codegen/src/lib.rs:1-43]()

---

## 03. Handlers, Router & ConnectRpcService

> The server-side dispatch path: implementing the generated FooService handler trait, registering methods on Router, wrapping the router in ConnectRpcService (the Tower service), and mounting it with Axum or raw Hyper. Covers unary and streaming handler shapes.

- Page Markdown: https://grok-wiki.com/public/wiki/anthropics-connect-rust-abe117693c52/pages/03-handlers-router-connectrpcservice.md
- Generated: 2026-05-21T02:35:35.867Z

### Source Files

- `connectrpc/src/handler.rs`
- `connectrpc/src/router.rs`
- `connectrpc/src/service.rs`
- `connectrpc/src/dispatcher.rs`
- `connectrpc/src/axum.rs`
- `connectrpc/src/response.rs`
- `examples/eliza/src`

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [connectrpc/src/handler.rs](connectrpc/src/handler.rs)
- [connectrpc/src/router.rs](connectrpc/src/router.rs)
- [connectrpc/src/service.rs](connectrpc/src/service.rs)
- [connectrpc/src/dispatcher.rs](connectrpc/src/dispatcher.rs)
- [connectrpc/src/axum.rs](connectrpc/src/axum.rs)
- [connectrpc/src/response.rs](connectrpc/src/response.rs)
- [examples/eliza/src/main.rs](examples/eliza/src/main.rs)
- [examples/eliza/src/generated/connect/connectrpc.eliza.v1.eliza.__connect.rs](examples/eliza/src/generated/connect/connectrpc.eliza.v1.eliza.__connect.rs)
</details>

# Handlers, Router & ConnectRpcService

This page covers the server-side dispatch path: from implementing a generated `FooService` trait to mounting the router as a Tower service inside an Axum or Hyper application. It walks through the three conceptual layers — **handler traits**, **Router registration**, and **ConnectRpcService** (the Tower service) — and shows where the generated codegen glue and the hand-written path differ.

Understanding this path matters because the architecture makes a deliberate split: the *type-aware* layer (handler traits, request decode, response encode) is handled at compile time, while the *dispatch* layer (method-path lookup, protocol negotiation, envelope framing, interceptors, limits) happens uniformly inside `ConnectRpcService`. The two paths interact through the `Dispatcher` trait.

---

## Architecture Overview

```text
HTTP request
    │
    ▼
┌─────────────────────────────┐
│    ConnectRpcService<D>     │  Tower Service — protocol decode, limits,
│   (service.rs:1098)         │  compression, interceptors, deadline, tracing
└──────────┬──────────────────┘
           │ Dispatcher::lookup(path)  →  MethodDescriptor (kind, idempotent, Spec)
           │ Dispatcher::call_*(...)
           ▼
┌──────────────────────────────────────┐
│          D: Dispatcher               │
│                                      │
│  ┌──────────────┐  ┌──────────────┐  │
│  │    Router    │  │ FooServiceServer│ │
│  │  (HashMap)   │  │  (monomorphic)│  │
│  └──────┬───────┘  └──────┬───────┘  │
└─────────┼─────────────────┼──────────┘
          │                 │
          ▼ ErasedHandler   ▼ direct match
     Handler<Req, Res>   FooService trait impl
     (handler.rs)        (user code / Arc<T>)
```

Sources: [connectrpc/src/dispatcher.rs:1-19](), [connectrpc/src/service.rs:1098-1109]()

---

## Handler Traits

### The public surface

The library defines four handler *kinds*, mirroring the four RPC streaming shapes:

| Kind | Trait | Signature |
|---|---|---|
| Unary | `Handler<Req, Res>` | `fn call(&self, ctx, Req) -> BoxFuture<ServiceResult<Body>>` |
| Server streaming | `StreamingHandler<Req, Res>` | `fn call(&self, ctx, Req) -> BoxFuture<ServiceResult<ServiceStream<Item>>>` |
| Client streaming | `ClientStreamingHandler<Req, Res>` | `fn call(&self, ctx, ServiceStream<Req>) -> BoxFuture<ServiceResult<Body>>` |
| Bidi streaming | `BidiStreamingHandler<Req, Res>` | `fn call(&self, ctx, ServiceStream<Req>) -> BoxFuture<ServiceResult<ServiceStream<Item>>>` |

Each trait has a parallel *view* variant (`ViewHandler`, `ViewStreamingHandler`, `ViewClientStreamingHandler`, `ViewBidiStreamingHandler`) that receives an `OwnedView<ReqView>` instead of an owned `Req`. Views give zero-copy `&str` / `&[u8]` access into the decoded buffer without an extra allocation.

Sources: [connectrpc/src/handler.rs:154-170](), [connectrpc/src/handler.rs:276-297](), [connectrpc/src/handler.rs:415-429](), [connectrpc/src/handler.rs:537-554]()

### Function-wrapper helpers

You rarely implement these traits by hand. Four `*_handler_fn` helpers wrap closures:

```rust
// Unary: closure takes (ctx, owned Req)
let h = handler_fn(|ctx: RequestContext, req: SayRequest| async move {
    Response::ok(SayResponse { sentence: "hello".into(), ..Default::default() })
});

// Server-streaming: closure returns a stream
let h = streaming_handler_fn(|ctx, req: IntroduceRequest| async move {
    Response::stream_ok(futures::stream::iter([
        Ok(IntroduceResponse { sentence: "Hi!".into(), ..Default::default() })
    ]))
});

// View (zero-copy): closure takes OwnedView<ReqView>
let h = view_handler_fn(|ctx, req: OwnedView<SayRequestView<'static>>, format| async move {
    let reply = lookup_reply(req.sentence); // req.sentence is &str, no allocation
    Response::ok(SayResponse { sentence: reply, ..Default::default() })?.encode::<SayResponse>(format)
});
```

Sources: [connectrpc/src/handler.rs:172-210](), [connectrpc/src/handler.rs:331-361](), [connectrpc/src/handler.rs:748-755]()

### Response metadata

Handlers return `ServiceResult<Body>` where `Body: Encodable<Res>`. The `Response<B>` wrapper carries both the body and optional response-side metadata via a fluent builder:

```rust
Response::ok(body)                         // body only (most handlers)
Response::ok(body)
    .with_header("x-trace-id", id)         // add a response header
    .with_trailer("grpc-status-bin", bin)  // add a trailer
```

This separates read-only `RequestContext` (passed *in*) from `Response<B>` (returned *out*), avoiding the earlier design where a mutable `Context` was threaded through both directions.

Sources: [connectrpc/src/response.rs:282-310]()

---

## Router

`Router` is the dynamic dispatch path. It maps `"service_name/method_name"` string keys to type-erased handlers stored in a `HashMap`.

### Registration methods

```rust
let router = Router::new()
    // Unary
    .route("pkg.v1.MyService", "DoThing", handler)
    // Idempotent unary — enables Connect GET
    .route_idempotent("pkg.v1.MyService", "GetThing", handler)
    // Server streaming
    .route_server_stream("pkg.v1.MyService", "Watch", streaming_handler)
    // Client streaming
    .route_client_stream("pkg.v1.MyService", "Upload", client_stream_handler)
    // Bidi streaming
    .route_bidi_stream("pkg.v1.MyService", "Chat", bidi_handler)
    // Zero-copy view variants exist for all four shapes
    .route_view("pkg.v1.MyService", "FastRead", view_handler);
```

Each `route_*` call wraps the handler in a type-erasing `*HandlerWrapper` (e.g. `UnaryHandlerWrapper`, `ServerStreamingHandlerWrapper`) and stores it behind `Arc<dyn ErasedHandler>`. This is the *dynamic path*: method lookup at call time goes through a `HashMap` and a trait-object vtable.

Sources: [connectrpc/src/router.rs:114-119](), [connectrpc/src/router.rs:142-209](), [connectrpc/src/router.rs:227-301]()

### Attaching a Spec

`Router::with_spec(spec)` attaches static method metadata to the last-registered route. The generated `FooServiceExt::register` always chains this call so that `RequestContext::spec()` is populated exactly as it would be in a monomorphic `FooServiceServer<T>`:

```rust
const SAY_SPEC: Spec = Spec::server("/eliza.v1.Eliza/Say", StreamType::Unary);
let router = Router::new()
    .route_view_idempotent("eliza.v1.Eliza", "Say", handler)
    .with_spec(SAY_SPEC);   // attaches the Spec so handlers can read ctx.spec()
```

In debug builds, `with_spec` panics if the route is not registered or the idempotency flag disagrees with the `route` vs `route_idempotent` choice; in release builds it silently drops a mismatched spec.

Sources: [connectrpc/src/router.rs:433-483]()

### Merging routers

Multiple `Router` instances can be combined:

```rust
let all = merge_routers([service_a_router, service_b_router]);
```

`merge_routers` extends one `HashMap` with another. Per-route `Spec`s are preserved across the merge.

Sources: [connectrpc/src/router.rs:569-576]()

---

## Generated Service Traits and `FooServiceExt::register`

`connectrpc-codegen` generates two items per service:

1. **`FooService` trait** — the async trait you implement. Methods take `OwnedView<FooRequestView<'static>>` by default (zero-copy) and return `ServiceResult<impl Encodable<Out> + Send + use<Self>>`.
2. **`FooServiceExt` blanket impl** — its single method `register(self: Arc<Self>, router: Router) -> Router` chains all the `route_view*` + `with_spec` calls for you.

```rust
// Generated trait (simplified from the Eliza example)
pub trait ElizaService: Send + Sync + 'static {
    fn say<'a>(&'a self, ctx: RequestContext, request: OwnedSayRequestView)
        -> impl Future<Output = ServiceResult<impl Encodable<SayResponse> + Send + use<'a, Self>>> + Send;

    fn converse(&self, ctx: RequestContext, requests: ServiceStream<OwnedConverseRequestView>)
        -> impl Future<Output = ServiceResult<ServiceStream<impl Encodable<ConverseResponse> + Send + use<Self>>>> + Send;

    fn introduce(&self, ctx: RequestContext, request: OwnedIntroduceRequestView)
        -> impl Future<Output = ServiceResult<ServiceStream<impl Encodable<IntroduceResponse> + Send + use<Self>>>> + Send;
}

// Generated blanket impl on Arc<T: ElizaService>
impl<S: ElizaService> ElizaServiceExt for S {
    fn register(self: Arc<Self>, router: Router) -> Router {
        router
            .route_view_idempotent(ELIZA_SERVICE_SERVICE_NAME, "Say", { /* closure captures Arc<S> */ })
            .with_spec(ELIZA_SERVICE_SAY_SPEC)
            .route_view_bidi_stream(...)
            .with_spec(...)
            // ... one pair per method
    }
}
```

The `use<Self>` precise-capturing clause is significant for streaming methods: it tells the borrow checker the opaque stream does *not* capture `&'a self`, so items must be `'static`. To stream view-encoded data, yield [`PreEncoded`](connectrpc/src/handler.rs:0) (pre-encoded bytes) instead.

Sources: [examples/eliza/src/generated/connect/connectrpc.eliza.v1.eliza.__connect.rs:155-260]()

### Implementing the trait

```rust
struct ElizaServer { stream_delay: Duration }

impl ElizaService for ElizaServer {
    async fn say(&self, _ctx: RequestContext, request: OwnedSayRequestView)
        -> ServiceResult<SayResponse>
    {
        let (reply, _) = eliza::reply(request.sentence); // request.sentence is &str
        Response::ok(SayResponse { sentence: reply, ..Default::default() })
    }

    async fn introduce(&self, _ctx: RequestContext, request: OwnedIntroduceRequestView)
        -> ServiceResult<ServiceStream<IntroduceResponse>>
    {
        let intros = eliza::get_intro_responses(request.name);
        Response::stream_ok(futures::stream::iter(intros.into_iter().map(|s| {
            Ok(IntroduceResponse { sentence: s, ..Default::default() })
        })))
    }

    async fn converse(&self, _ctx: RequestContext, requests: ServiceStream<OwnedConverseRequestView>)
        -> ServiceResult<ServiceStream<ConverseResponse>>
    {
        let stream = futures::stream::unfold(requests, |mut reqs| async move {
            let req = reqs.next().await??; // Option<Result<..>>
            let (reply, end) = eliza::reply(req.sentence);
            Some((Ok(ConverseResponse { sentence: reply, ..Default::default() }), reqs))
        });
        Response::stream_ok(stream)
    }
}
```

Sources: [examples/eliza/src/main.rs:66-159]()

### Wiring it up

```rust
let service = Arc::new(ElizaServer::new(args.stream_delay));
let router = service.register(ConnectRouter::new());  // chains all route_view* calls
```

Sources: [examples/eliza/src/main.rs:207-209]()

---

## Dispatcher Trait

`ConnectRpcService` is generic over `D: Dispatcher`, not `Router` directly. This allows both the dynamic `Router` and codegen-emitted monomorphic dispatchers to plug in.

The trait has a two-step call contract:

1. **`lookup(path)`** — returns a `MethodDescriptor` (kind, idempotent flag, `Option<Spec>`) or `None` for a 404. The service uses the kind to choose the correct body-processing path (buffer unary body vs. stream bidi body).
2. **`call_unary` / `call_server_streaming` / `call_client_streaming` / `call_bidi_streaming`** — invoked after body setup. Returns a typed future.

The `Dispatcher` contract says that if the path is not found or the kind does not match, the `call_*` methods return an `Unimplemented` error future — they never panic.

```rust
pub trait Dispatcher: Send + Sync + 'static {
    fn lookup(&self, path: &str) -> Option<MethodDescriptor>;
    fn call_unary(&self, path, ctx, request: Payload, format) -> UnaryResult;
    fn call_server_streaming(&self, path, ctx, request: Bytes, format) -> StreamingResult;
    fn call_client_streaming(&self, path, ctx, requests: RequestStream, format) -> UnaryResult;
    fn call_bidi_streaming(&self, path, ctx, requests: RequestStream, format) -> StreamingResult;
}
```

The `Chain` type (from `dispatcher.rs`) lets you compose multiple `Dispatcher` impls so a single `ConnectRpcService` can serve several unrelated services.

Sources: [connectrpc/src/dispatcher.rs:130-209](), [connectrpc/src/dispatcher.rs:238-245]()

---

## ConnectRpcService

`ConnectRpcService<D>` is the Tower service that ties everything together. It is generic over `D: Dispatcher` (defaulting to `Router`).

### Fields and construction

```rust
pub struct ConnectRpcService<D = Router> {
    dispatcher: Arc<D>,
    limits: Limits,                        // body / message size caps
    compression: Arc<CompressionRegistry>, // available codecs
    compression_policy: CompressionPolicy,
    deadline_policy: DeadlinePolicy,
    interceptors: Arc<[Arc<dyn Interceptor>]>,
}
```

Construct it directly or through the `Router` convenience method:

```rust
// Directly from a dispatcher
let svc = ConnectRpcService::new(router);

// Through Router (axum feature)
let svc = router.into_axum_service();  // ConnectRpcService::new(self)
```

Sources: [connectrpc/src/service.rs:1098-1148](), [connectrpc/src/service.rs:2950-2951]()

### Configuration builder methods

| Method | Purpose |
|---|---|
| `.with_limits(Limits { .. })` | Override 4 MB body / message defaults |
| `.with_compression(registry)` | Register additional codecs (gzip, zstd, etc.) |
| `.with_compression_policy(policy)` | Control threshold for auto-compression |
| `.with_deadline_policy(policy)` | Clamp/default client-asserted timeouts |
| `.with_interceptor(impl Interceptor)` | Append to the unary + streaming interceptor chain |
| `.with_interceptor_arc(Arc<dyn Interceptor>)` | Same, but for shared interceptors |

Default limits are 4 MB on-wire and 4 MB post-decompression (matching tonic and grpc-go defaults). Interceptors are outermost-first: the first registered runs first on the way in and last on the way out, matching connect-go's `WithInterceptors`.

Sources: [connectrpc/src/service.rs:1150-1240]()

### The Tower::Service impl

`ConnectRpcService<D>` implements `tower::Service<Request<B>>` with `Response = Response<ConnectRpcBody>` and `Error = Infallible`. The `call()` method:

1. Extracts per-request context (limits, compression, deadline policy, interceptors) from `self` via cheap `Arc` clones.
2. Conditionally creates a `tracing::debug_span!("connectrpc_request")` only when the subscriber is actually watching that level (avoids `~0.8% CPU` from dead span wrappers in production).
3. Calls `handle_request(...)` which performs: protocol detection → header parse → `dispatcher.lookup(path)` → body collection/streaming → interceptor chain → `dispatcher.call_*` → response encoding → envelope framing.

Sources: [connectrpc/src/service.rs:1321-1385]()

---

## Mounting with Axum

### Plaintext (no TLS)

The `axum` feature adds two convenience methods on `Router`:

```rust
// Option A: use the ConnectRpcService directly as axum's fallback
let app = axum::Router::new()
    .route("/health", axum::routing::get(|| async { "OK" }))
    .fallback_service(connect_router.into_axum_service());

axum::serve(listener, app).await?;

// Option B: get an axum::Router with a catch-all route pre-installed
let axum_router = connect_router.into_axum_router();
// axum_router.into_make_service()...
```

Sources: [connectrpc/src/service.rs:2950-2976](), [examples/eliza/src/main.rs:238-251]()

### TLS / mTLS (`connectrpc::axum::serve_tls`)

`axum::serve` owns only a plain `TcpListener` — it cannot inject peer TLS identity into requests. `connectrpc::axum::serve_tls` fills this gap: it runs the `tokio-rustls` accept loop, captures `PeerAddr` and `PeerCerts` once per connection, stamps them into request extensions, and then forwards to the axum service. Handler code reads the same `ctx.peer_addr()` / `ctx.peer_certs()` accessors regardless of whether the server is the standalone `Server` or an axum app.

```rust
let app = axum::Router::new()
    .route("/health", axum::routing::get(|| async { "OK" }))
    .fallback_service(connect_router.into_axum_service());

connectrpc::axum::serve_tls(listener, app, tls_config)
    .with_graceful_shutdown(async { shutdown_rx.await.ok(); })
    .await?;
```

Key differences from `axum::serve`:
- Accepts a concrete `axum::Router`, not the generic make-service forms.
- `PeerAddr` replaces `ConnectInfo<SocketAddr>`.
- `PeerCerts` is only present when the `ServerConfig` requests client auth and the peer provides a verified chain.
- Bounded TLS handshake timeout (default `DEFAULT_TLS_HANDSHAKE_TIMEOUT`) to prevent slowloris exhaustion.
- Does not install `CatchPanicLayer` — add it yourself if you want panicking handlers to surface as Connect errors.

Sources: [connectrpc/src/axum.rs:1-56](), [connectrpc/src/axum.rs:120-132]()

---

## Mounting with the Standalone Server

The `Server` type owns the accept loop (plaintext or TLS) and delegates to a `ConnectRpcService` internally. For plaintext:

```rust
Server::bind(addr).await?.serve(router).await?;
```

For TLS, pass a `rustls::ServerConfig`:

```rust
Server::bind(addr).await?
    .with_tls(tls_config)
    .serve(router)
    .await?;
```

`Server` automatically wraps the service in `tower_http::catch_panic::CatchPanicLayer` (unlike `axum::serve` and `connectrpc::axum::serve_tls`), so panicking handlers become Connect `Internal` errors rather than dropped connections.

Sources: [examples/eliza/src/main.rs:325-333]()

---

## Dispatch Path Summary

```
HTTP POST /connectrpc.eliza.v1.ElizaService/Say
    │
    ├── ConnectRpcService::call()
    │       extract ctx, limits, interceptors
    │       detect protocol (Connect / gRPC / gRPC-Web)
    │       parse headers → RequestMetadata
    │       dispatcher.lookup("connectrpc.eliza.v1.ElizaService/Say")
    │           → MethodDescriptor { kind: Unary, idempotent: true, spec: Some(SAY_SPEC) }
    │       collect body (bounded by limits.max_request_body_size)
    │       decompress if Content-Encoding set
    │       wrap bytes in Payload (lazy decode cache)
    │
    ├── interceptor chain (outermost first)
    │
    ├── dispatcher.call_unary(path, ctx, payload, format)
    │       Router: HashMap lookup → UnaryViewHandlerWrapper::call_erased()
    │           decode_request_view::<SayRequestView>(bytes, format)
    │           svc.say(ctx, OwnedView<SayRequestView>).await
    │           Response<SayResponse>.encode::<SayResponse>(format) → EncodedResponse
    │
    └── encode HTTP response (Connect / gRPC / gRPC-Web framing)
            return Response<ConnectRpcBody>
```

The two-step `lookup` + `call_*` split means protocol negotiation and body setup finish before any handler-specific code runs, and the `Dispatcher` contract (`None` or `Unimplemented` on miss) keeps the service error-free regardless of routing outcome.

Sources: [connectrpc/src/dispatcher.rs:1-19](), [connectrpc/src/handler.rs:86-143](), [connectrpc/src/router.rs:500-566]()

---

## 04. Interceptors & Spec Metadata

> RPC-level interceptors as a typed alternative to Tower middleware: Interceptor trait, Next/NextStream continuations, unary vs. streaming intercept surfaces, registration order, and zero-cost opt-out. Also covers Spec (per-method static metadata) and StreamType, which interceptors use to label spans and gate behaviour without re-parsing URLs.

- Page Markdown: https://grok-wiki.com/public/wiki/anthropics-connect-rust-abe117693c52/pages/04-interceptors-spec-metadata.md
- Generated: 2026-05-21T02:33:52.967Z

### Source Files

- `connectrpc/src/interceptor.rs`
- `connectrpc/src/spec.rs`
- `connectrpc/src/payload.rs`
- `connectrpc/src/service.rs`
- `connectrpc/src/error.rs`

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [connectrpc/src/interceptor.rs](connectrpc/src/interceptor.rs)
- [connectrpc/src/spec.rs](connectrpc/src/spec.rs)
- [connectrpc/src/payload.rs](connectrpc/src/payload.rs)
- [connectrpc/src/service.rs](connectrpc/src/service.rs)
- [connectrpc/src/response.rs](connectrpc/src/response.rs)
- [connectrpc/src/dispatcher.rs](connectrpc/src/dispatcher.rs)
</details>

# Interceptors & Spec Metadata

Interceptors are the RPC-level cross-cutting concern layer in `connect-rust`. They wrap a single RPC *after* envelope decoding, decompression, and header parsing, and *before* the handler runs — giving you typed access to request and response bodies, headers, extensions, and the per-method `Spec`, without dropping to raw Tower middleware. `Spec` is the complementary type: a `Copy`, `'static` struct emitted by code generation that names the method, its stream shape, and its idempotency contract. Interceptors read it to label spans, gate behaviour, or decide retry eligibility without re-parsing the request URL.

This page covers the `Interceptor` trait, its two intercept surfaces (unary and streaming), the `Next`/`NextStream` continuations, chain ordering and short-circuiting, the `Payload` lazy-decode contract that keeps interceptors cheap, registration via `ConnectRpcService::with_interceptor`, and the `Spec`/`StreamType`/`IdempotencyLevel` static-metadata types.

---

## The `Interceptor` Trait

```rust
// connectrpc/src/interceptor.rs:116-189
#[async_trait::async_trait]
pub trait Interceptor: Send + Sync + 'static {
    async fn intercept_unary(
        &self,
        req: UnaryRequest,
        next: Next<'_>,
    ) -> Result<UnaryResponse, ConnectError> {
        next.run(req).await  // default: passthrough
    }

    async fn intercept_streaming(
        &self,
        req: StreamRequest,
        inbound: PayloadStream,
        next: NextStream<'_>,
    ) -> Result<StreamResponse, ConnectError> {
        next.run(req, inbound).await  // default: passthrough
    }
}
```

Both methods have a **passthrough default**. An interceptor that only cares about streaming RPCs (or only about unary) can implement just the relevant method and remain forwards-compatible as the framework grows.

The trait is annotated with `async_trait`. To avoid a direct `async-trait` dependency, the crate re-exports the macro as `connectrpc::async_trait`.

Sources: [connectrpc/src/interceptor.rs:56-66, 115-189]()

---

## Two Intercept Surfaces

### Unary: `intercept_unary`

Receives a `UnaryRequest` (which bundles `ctx: RequestContext` and `payload: Payload`) and a `Next<'_>` continuation. Returns `Result<UnaryResponse, ConnectError>`.

- Mutate `req.ctx.headers` or `req.ctx.extensions` before calling `next.run(req)` to propagate changes to the handler.
- Call `req.payload.set_message(m)` to replace the request body.
- Read the response on the way out and call `resp.with_header(...)` or inspect `resp.body`.

```rust
// connectrpc/src/interceptor.rs:84-113 (doc example)
#[connectrpc::async_trait]
impl Interceptor for LoggingInterceptor {
    async fn intercept_unary(
        &self,
        req: UnaryRequest,
        next: Next<'_>,
    ) -> Result<UnaryResponse, ConnectError> {
        let path = req.ctx.path()
            .expect("dispatch sets path before interceptors run")
            .to_owned();
        tracing::info!(%path, "rpc start");
        let resp = next.run(req).await;
        tracing::info!(%path, ok = resp.is_ok(), "rpc end");
        resp
    }
}
```

`UnaryResponse` is a type alias for `Response<Payload>`, so the same lazy-decode contract applies on the way out.

Sources: [connectrpc/src/interceptor.rs:295-341, 340]()

### Streaming: `intercept_streaming`

Called **once at stream establishment**, before any messages flow. It receives a `StreamRequest` (ctx only, no payload), a `PayloadStream` for the inbound direction, and a `NextStream<'_>`. Returns `Result<StreamResponse, ConnectError>`.

All three streaming shapes — server-streaming, client-streaming, and bidi — route through this single method. The difference is cardinality:

| Shape | Inbound items | Outbound items |
|---|---|---|
| Server-streaming | 1 | N |
| Client-streaming | N | 1 |
| Bidi-streaming | N | N |

To branch on cardinality, read `req.ctx.spec().map(|s| s.stream_type)`.

To observe or mutate the inbound stream, wrap `inbound` with a `Stream` adapter before passing it to `next.run`. To observe or mutate the outbound stream, wrap `resp.body` after calling `next.run`:

```rust
// connectrpc/src/interceptor.rs:1609-1626 (from test, adapted)
async fn intercept_streaming(
    &self,
    req: StreamRequest,
    inbound: PayloadStream,
    next: NextStream<'_>,
) -> Result<StreamResponse, ConnectError> {
    let wrapped = Box::pin(inbound.map(|item| {
        item.map(|mut payload| { payload.set_message(/* ... */); payload })
    }));
    next.run(req, wrapped).await
}
```

Cross-stream coordination (outbound depends on inbound state) requires shared state between the two adapters, typically an `Arc<Mutex<..>>` captured by both closures.

Sources: [connectrpc/src/interceptor.rs:135-188, 449-499]()

---

## `Next` and `NextStream` Continuations

`Next<'a>` and `NextStream<'a>` are the handles an interceptor calls to advance the chain. Both are **consume-once**: calling `run` on them is the only legal move, and not calling it short-circuits the rest of the chain (neither inner interceptors nor the handler run).

```rust
// connectrpc/src/interceptor.rs:240-275
pub struct Next<'a> {
    rest: &'a [Arc<dyn Interceptor>],
    terminal: &'a (dyn UnaryTerminal + 'a),
}

impl<'a> Next<'a> {
    pub async fn run(self, req: UnaryRequest) -> Result<UnaryResponse, ConnectError> {
        match self.rest.split_first() {
            Some((head, tail)) => head.intercept_unary(req, Next { rest: tail, .. }).await,
            None => self.terminal.call(req).await,
        }
    }
}
```

The terminal is the dispatch path itself (`DispatchTerminal`), which hands the `Payload` — not raw bytes — to the dispatcher so a handler can call `take_message()` and reuse an interceptor's cached decode.

Sources: [connectrpc/src/interceptor.rs:235-283, 501-555, 926-944]()

---

## Chain Ordering

```text
request ──▶ interceptor[0] ──▶ interceptor[1] ──▶ handler
                 │                  │                 │
response ◀───────┴──────────◀───────┴────────◀───────┘
```

The **first interceptor registered is the outermost**: first to run on the way in, last on the way out. This matches `connect-go`'s `WithInterceptors` ordering. The test suite verifies it explicitly:

```rust
// connectrpc/src/interceptor.rs:1014-1039
// chain: [Tagger("a"), Tagger("b"), Tagger("c")]
// way in:  trace == ["a", "b", "c"]
// way out: x-trace headers == ["c-out", "b-out", "a-out"]
```

Sources: [connectrpc/src/interceptor.rs:29-35, 1013-1040]()

---

## Short-Circuiting

An interceptor that returns `Err` — or returns `Ok` without calling `next.run` — terminates the chain immediately. Inner interceptors and the handler do not run.

Errors carry **response headers** via `ConnectError::with_headers`. These are preserved through the chain and reach the dispatch path's protocol-aware error renderers (`error_response`, `grpc_error_response`), which put them on the wire. This is the mechanism for auth interceptors to attach diagnostic headers (e.g. `x-deny-policy`) to a denial response.

```rust
// connectrpc/src/interceptor.rs:1054-1058
Err(ConnectError::permission_denied("nope")
    .with_headers(headers))
```

Sources: [connectrpc/src/interceptor.rs:1042-1078, 1083-1153]()

---

## Zero-Cost Opt-Out

When no interceptors are registered, the dispatch path makes a single `is_empty` check and delegates directly to the dispatcher — no `UnaryRequest` is constructed, no `Next` is built, no chain machinery runs:

```rust
// connectrpc/src/interceptor.rs:903-915
pub(crate) async fn call_unary_intercepted<D: crate::Dispatcher>(
    dispatcher: &D,
    interceptors: &[Arc<dyn Interceptor>],
    ...,
) -> Result<EncodedResponse, ConnectError> {
    if interceptors.is_empty() {
        return dispatcher.call_unary(path, ctx, Payload::new(body, format), format).await;
    }
    // ... chain machinery only when interceptors are present
}
```

The same pattern applies to all three streaming variants. A test pins the zero-allocation property by pointer-equality on the echoed body bytes.

Sources: [connectrpc/src/interceptor.rs:895-924, 648-673]()

---

## `Payload`: Lazy Decode

`Payload` is the body type seen by interceptors on both the request and response side. It holds the wire bytes (`Bytes`, reference-counted) and decodes to a typed message on first access. An interceptor that never inspects the message body pays only a struct construction — no decode, no allocation beyond the `Bytes` refcount.

| Method | Cost | Notes |
|---|---|---|
| `payload.bytes()` | Free | Raw wire bytes, always available |
| `payload.message::<M>()` | Decode once, then cache | Works for Proto and JSON wires |
| `payload.view::<V>()` | Zero-copy Proto view | Proto wires only; re-encodes if replaced |
| `payload.set_message(m)` | Box allocation | Replacement takes priority for all reads |
| `payload.take_message::<M>()` | Move from cache or fresh decode | Consuming; handler uses this to avoid re-decode |
| `payload.encoded()` | `Bytes` refcount clone or re-encode | What the dispatch path sends on the wire |

`Payload` is intentionally not `Clone`: a clone would either drop the decode cache (surprising) or duplicate it (defeating laziness). Pass by reference or move through the call chain.

The decode cache is a `OnceLock<Box<dyn AnyMessage>>` and is thread-safe; two threads racing on `message::<M>()` both decode, but only one `set` wins and the loser discards.

Sources: [connectrpc/src/payload.rs:1-20, 99-363]()

---

## Registering Interceptors

Use `ConnectRpcService::with_interceptor`:

```rust
// connectrpc/src/service.rs:1219-1220
pub fn with_interceptor(self, interceptor: impl Interceptor) -> Self {
    self.with_interceptor_arc(Arc::new(interceptor))
}
```

Internally the chain is stored as `Arc<[Arc<dyn Interceptor>]>`. `with_interceptor_arc` is provided for sharing a single interceptor instance (with its state — a connection pool, a token cache) across multiple `ConnectRpcService`s without duplicating it.

Calls compose: each successive `with_interceptor` pushes to the end of the slice, making the last registered the innermost.

Sources: [connectrpc/src/service.rs:1200-1240]()

---

## Closure Shortcuts

For lightweight interceptors that don't need a named type, two factory functions produce `impl Interceptor` from closures:

```rust
// connectrpc/src/interceptor.rs:206-233
let timing = unary_interceptor(|req, next| Box::pin(async move {
    let started = std::time::Instant::now();
    let resp = next.run(req).await;
    tracing::debug!(elapsed = ?started.elapsed(), "rpc");
    resp
}));

// connectrpc/src/interceptor.rs:463-499
let logging = streaming_interceptor(|req, inbound, next| Box::pin(async move {
    tracing::info!(path = ?req.ctx.path(), "stream open");
    next.run(req, inbound).await
}));
```

The `Box::pin` boilerplate is unavoidable (the trait method returns a boxed future through `async_trait`), but the closure body is identical to what an `impl Interceptor` block would contain.

Sources: [connectrpc/src/interceptor.rs:191-233, 449-499]()

---

## Unit-Testing Interceptors

Two public helpers let you drive a chain in unit tests without a TCP listener or Tower service:

```rust
// Unary
let resp = connectrpc::interceptor::run_chain(
    &chain,
    my_req,
    |req| async move { Ok(/* handler stub */) },
).await?;

// Streaming
let resp = connectrpc::interceptor::run_chain_streaming(
    &chain,
    my_stream_req,
    my_inbound_stream,
    |req, inbound| async move { Ok(/* handler stub */) },
).await?;
```

Both helpers accept a closure as the terminal, so you can assert what the handler would see (headers mutated, body replaced) without a real dispatcher.

Sources: [connectrpc/src/interceptor.rs:849-893, 571-624]()

---

## `Spec`: Per-Method Static Metadata

`Spec` is a `Copy`, `'static` struct emitted by code generation as a `pub const` per method. It is threaded through to handlers and interceptors via `RequestContext::spec()`.

```rust
// connectrpc/src/spec.rs:125-153
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct Spec {
    pub procedure: &'static str,   // "/package.Service/Method"
    pub stream_type: StreamType,
    pub origin: SpecOrigin,        // Server or Client
    pub idempotency_level: IdempotencyLevel,
}
```

`Spec` is `#[non_exhaustive]` so future fields can be added without a breaking change. Construct via `Spec::server` or `Spec::client` (both `const fn`); chain `with_idempotency_level` in `const` position so the constant lives in `.rodata`.

```rust
// connectrpc/src/spec.rs:284-292 (test, shows const construction)
const SPEC: Spec = Spec::server("/pkg.Greet/Say", StreamType::Unary)
    .with_idempotency_level(IdempotencyLevel::NoSideEffects);
```

### Accessors

| Method | Returns | Example |
|---|---|---|
| `spec.procedure` | `&'static str` | `"/pkg.Greet/Say"` |
| `spec.service()` | `&'static str` | `"pkg.Greet"` |
| `spec.method()` | `&'static str` | `"Say"` |
| `spec.stream_type` | `StreamType` | `StreamType::Unary` |
| `spec.origin` | `SpecOrigin` | `SpecOrigin::Server` |
| `spec.idempotency_level` | `IdempotencyLevel` | `IdempotencyLevel::NoSideEffects` |

In debug builds, `Spec::server`/`Spec::client` assert that `procedure` starts with `/` and contains a service/method separator, turning malformed paths into compile-time panics for `const` specs.

Sources: [connectrpc/src/spec.rs:1-265]()

---

## `StreamType`

`StreamType` is the interceptor-facing equivalent of `MethodKind` (the routing-table type). It uses `connect-go` naming so cross-runtime interceptor logic ports cleanly.

```rust
// connectrpc/src/spec.rs:30-39
pub enum StreamType {
    Unary,        // 1 request, 1 response
    ClientStream, // N requests, 1 response
    ServerStream, // 1 request, N responses
    BidiStream,   // N requests, N responses
}
```

`From<MethodKind>` and `From<StreamType>` conversions exist in both directions. Prefer `StreamType` in code that consumes a `Spec`; use `MethodKind` when registering routes.

Sources: [connectrpc/src/spec.rs:19-61]()

---

## `IdempotencyLevel`

Derived from the proto `option idempotency_level` field. Interceptors use it to make retry or GET-eligibility decisions without re-examining the proto schema:

```rust
// connectrpc/src/spec.rs:71-81
pub enum IdempotencyLevel {
    Unknown,        // proto default — no guarantee
    NoSideEffects,  // read-only, safe to retry or GET
    Idempotent,     // may have side effects, but repeatable
}
```

`NoSideEffects` is the only value that makes a call GET-eligible under the Connect protocol. `Idempotent` is safe to retry but not GET-eligible — this distinction is captured by `MethodDescriptor::idempotent` (a derived boolean) versus `Spec::idempotency_level` (the full three-valued enum).

Sources: [connectrpc/src/spec.rs:63-81, 148-152]()

---

## `SpecOrigin`

Records whether a `Spec` constant was emitted by a generated server dispatcher (`FooServiceServer<T>`) or a generated client (`FooServiceClient<T>`). An interceptor registered on both sides can read this to open the right span kind or inject trace-context headers only on the client side:

```rust
// connectrpc/src/spec.rs:101-107
pub enum SpecOrigin { Server, Client }
```

Sources: [connectrpc/src/spec.rs:83-107]()

---

## Where `Spec` Appears at Runtime

`RequestContext::spec()` returns `Option<Spec>`:

- `Some(..)` — for generated `FooServiceServer<T>` dispatchers and for `Router` routes registered through the generated `register()` (which chains `Router::with_spec` per route).
- `None` — for manual `route_*` registrations without a `Router::with_spec` call.

`ctx.path()` is always present when constructed by the dispatch path (including dynamic `Router` routes that have no `Spec`). Code that must label every request — auth interceptors, span builders, rate limiters — should read `path()` first and fall back to `spec()` for richer metadata when available.

Sources: [connectrpc/src/response.rs:190-224](), [connectrpc/src/dispatcher.rs:55]()

---

## Design Note: Stream-Shaped, Not Connection-Shaped

The streaming interceptor receives and returns Rust `Stream`s rather than a stateful `conn.Send()`/`conn.Receive()` pair (as in `connect-go`). This matches the Rust ecosystem model (`tower`, `tonic`, `axum` all work with `Stream`-shaped bodies) and avoids the channel-intermediary and pump-task overhead that a connection-wrapper would require. The expressive power is equivalent: wrapping the inbound or outbound stream with an adapter gives full per-message observe/mutate capability.

Sources: [connectrpc/src/interceptor.rs:17-26]()

---

## Summary

Interceptors in `connect-rust` provide typed, post-decode hooks for every RPC without paying any cost when none are registered. The `Interceptor` trait's two surfaces — `intercept_unary` and `intercept_streaming` — cover all four message-flow shapes through a uniform `Stream`-based API. `Spec` supplies the per-method static facts (`procedure`, `stream_type`, `idempotency_level`, `origin`) that interceptors need to label spans or gate behavior, without re-parsing the request URL. Together they form the recommended cross-cutting concern layer, sitting between the protocol decoder and the handler, with `Payload`'s lazy-decode design ensuring that interceptors that never touch message bodies are effectively free.

Sources: [connectrpc/src/interceptor.rs:1-41]()

---

## 05. Protocol, Codec & Client Transport

> How the three wire protocols (Connect, gRPC, gRPC-Web) are detected and demultiplexed; envelope framing (5-byte header); codec format selection (proto vs. JSON); compression (gzip, zstd); and the client-side Tower HTTP transport in connectrpc/src/client/. Covers where to look when adding a new codec or compression algorithm.

- Page Markdown: https://grok-wiki.com/public/wiki/anthropics-connect-rust-abe117693c52/pages/05-protocol-codec-client-transport.md
- Generated: 2026-05-21T02:34:10.443Z

### Source Files

- `connectrpc/src/protocol.rs`
- `connectrpc/src/envelope.rs`
- `connectrpc/src/codec.rs`
- `connectrpc/src/compression.rs`
- `connectrpc/src/client/mod.rs`
- `connectrpc/src/client/http2.rs`
- `connectrpc/src/grpc_status.rs`

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [connectrpc/src/protocol.rs](connectrpc/src/protocol.rs)
- [connectrpc/src/envelope.rs](connectrpc/src/envelope.rs)
- [connectrpc/src/codec.rs](connectrpc/src/codec.rs)
- [connectrpc/src/compression.rs](connectrpc/src/compression.rs)
- [connectrpc/src/client/mod.rs](connectrpc/src/client/mod.rs)
- [connectrpc/src/client/http2.rs](connectrpc/src/client/http2.rs)
- [connectrpc/src/grpc_status.rs](connectrpc/src/grpc_status.rs)
</details>

# Protocol, Codec & Client Transport

This page explains the three wire layers that every ConnectRPC message passes through: protocol detection (which of the three wire protocols is in use), envelope framing (how streaming messages are delimited), and codec/compression (how a protobuf message becomes bytes on the wire). It then covers the two client-side Tower HTTP transports and explains where to extend the system with a new codec or compression algorithm.

Understanding these layers is essential before adding a new protocol feature, debugging a conformance failure, or wiring up a custom compressor. The key types live in five source files: `protocol.rs`, `envelope.rs`, `codec.rs`, `compression.rs`, and `client/mod.rs`.

---

## Wire Protocols

The crate supports three protocols that share RPC semantics (services, methods, error codes) but differ on the wire in framing, header conventions, trailer delivery, and error representation.

| Protocol | Content-Type prefix | HTTP version | Trailers | Error delivery |
|---|---|---|---|---|
| Connect unary | `application/proto`, `application/json` | HTTP/1.1+ | None (HTTP status) | HTTP status + JSON body |
| Connect streaming | `application/connect+{proto,json}` | HTTP/1.1+ | Encoded in final body frame | Envelope end-stream |
| gRPC | `application/grpc{+proto,+json}` | HTTP/2 only | HTTP/2 HEADERS frame | `grpc-status` trailer |
| gRPC-Web | `application/grpc-web{+proto,+json}` | HTTP/1.1+ | 0x80-flagged body frame | Inline trailer frame |
| gRPC-Web text | `application/grpc-web-text{+proto}` | HTTP/1.1+ | Same as gRPC-Web, base64 | Inline trailer frame |

Sources: [connectrpc/src/protocol.rs:44-61]()

### Protocol Detection

Incoming requests are identified solely by the `Content-Type` header. `Protocol::detect` reads the header and delegates to `detect_from_content_type`:

```rust
// connectrpc/src/protocol.rs:84-87
pub fn detect(headers: &HeaderMap) -> Option<RequestProtocol> {
    let content_type = headers.get(CONTENT_TYPE)?.to_str().ok()?;
    Self::detect_from_content_type(content_type)
}
```

The detection order matters because `"application/grpc"` is a prefix of `"application/grpc-web"`. The implementation checks the most specific prefix first:

1. `application/grpc-web-text` → gRPC-Web text mode (proto only; `+json` is rejected)
2. `application/grpc-web` → gRPC-Web
3. `application/grpc` → gRPC
4. `application/connect+` → Connect streaming
5. `application/proto` / `application/json` → Connect unary

Sources: [connectrpc/src/protocol.rs:92-168]()

The result is a `RequestProtocol` struct carrying `protocol`, `codec_format`, `is_streaming`, and `is_text_mode`. All four fields are set in one pass, so callers never re-parse the content type.

Sources: [connectrpc/src/protocol.rs:64-78]()

### Protocol-Specific Header Names

Each protocol uses different header names for the same concept. The `Protocol` enum exposes accessor methods so upper layers never hardcode strings:

```rust
// connectrpc/src/protocol.rs:203-210
pub fn timeout_header(&self) -> &'static str {
    match self {
        Protocol::Connect => "connect-timeout-ms",
        Protocol::Grpc | Protocol::GrpcWeb => "grpc-timeout",
    }
}
```

Pre-parsed `HeaderName` statics in the `hdr` module avoid re-parsing per-request; profiling showed the naive `header(&str, _)` approach consumed ~0.7% CPU on the echo hot path.

Sources: [connectrpc/src/protocol.rs:26-39]()

### Key Protocol Properties

| Property | Connect | gRPC | gRPC-Web |
|---|---|---|---|
| Requires HTTP/2 | No | Yes | No |
| Real HTTP status codes | Yes (unary) | No (always 200) | No |
| HTTP/2 HEADERS trailers | No | Yes | No |

Sources: [connectrpc/src/protocol.rs:234-256]()

---

## Envelope Framing

All streaming RPCs (Connect streaming, gRPC, gRPC-Web) use the same 5-byte envelope header. Connect unary RPCs do **not** use envelope framing — the body is the raw message.

```text
Byte 0:    flags  (0x00 = data, 0x01 = compressed, 0x02 = end-stream)
Bytes 1-4: payload length (big-endian uint32)
Bytes 5…:  payload
```

Sources: [connectrpc/src/envelope.rs:1-29]()

### Encoding and Decoding

`Envelope::encode` writes `[flag, len_u32_be, payload]` into a `BytesMut`. `Envelope::decode_with_limit` reads a header, checks the declared length against `max_size` before waiting for data (defense against malicious headers claiming huge lengths), then splits the payload.

```rust
// connectrpc/src/envelope.rs:109-135
pub fn decode_with_limit(buf: &mut BytesMut, max_size: usize) -> Result<Option<Self>, ConnectError> {
    if buf.len() < HEADER_SIZE { return Ok(None); }
    let flags = buf[0];
    let length = u32::from_be_bytes([buf[1], buf[2], buf[3], buf[4]]) as usize;
    if length > max_size {
        return Err(ConnectError::resource_exhausted(...));
    }
    if buf.len() < HEADER_SIZE + length { return Ok(None); } // need more data
    buf.advance(HEADER_SIZE);
    let data = buf.split_to(length).freeze();
    Ok(Some(Self { flags, data }))
}
```

### Streaming Codec Integration

`EnvelopeDecoder` implements `tokio_util::codec::Decoder` and plugs into `FramedRead`. It tracks a `done: bool` flag; once it sees a frame with `END_STREAM` (0x02), all subsequent calls return `Ok(None)`. Compressed frames are decompressed inline via the `CompressionRegistry`.

`EnvelopeEncoder` implements `tokio_util::codec::Encoder<Bytes>` and applies compression when the `CompressionPolicy` allows it for the given payload size. End-stream frames are never compressed.

Sources: [connectrpc/src/envelope.rs:139-298]()

---

## Codec Format Selection

`CodecFormat` is a two-variant enum:

```rust
// connectrpc/src/codec.rs:104-111
pub enum CodecFormat {
    Proto,  // binary protobuf via buffa::Message
    Json,   // JSON via serde_json
}
```

### Encoding Functions

Two stateless codec types (`ProtoCodec`, `JsonCodec`) provide `encode`/`decode` methods. The proto path delegates to `buffa::Message::encode_to_bytes` and `decode_from_slice`. The JSON path uses `serde_json`.

```rust
// connectrpc/src/codec.rs:38-58
pub fn encode_proto<M: Message>(message: &M) -> Result<Bytes, ConnectError> {
    Ok(message.encode_to_bytes())
}
pub fn encode_json<M: Serialize>(message: &M) -> Result<Bytes, ConnectError> {
    serde_json::to_vec(message).map(Bytes::from)
        .map_err(|e| ConnectError::internal(...))
}
```

Sources: [connectrpc/src/codec.rs:38-101]()

### Format-to-Content-Type Mapping

`CodecFormat` also owns the content-type string constants and the reverse parse:

| Situation | Proto | JSON |
|---|---|---|
| Unary | `application/proto` | `application/json` |
| Connect streaming | `application/connect+proto` | `application/connect+json` |
| From query param `encoding=` | `"proto"` | `"json"` |

Sources: [connectrpc/src/codec.rs:14-173]()

---

## Compression

### Architecture

Compression is pluggable. `CompressionProvider` is a trait that every algorithm implements:

```rust
// connectrpc/src/compression.rs:94-116
pub trait CompressionProvider: Send + Sync + 'static {
    fn name(&self) -> &'static str;
    fn compress(&self, data: &[u8]) -> Result<Bytes, ConnectError>;
    fn decompressor<'a>(&self, data: &'a [u8])
        -> Result<Box<dyn std::io::Read + 'a>, ConnectError>;
    // decompress_with_limit has a default impl using Read::take
}
```

`CompressionRegistry` maps encoding names to `Arc<dyn CompressionProvider>` and caches the sorted `Accept-Encoding` header string at registration time (not per-request).

Sources: [connectrpc/src/compression.rs:169-260]()

### Built-in Providers

| Provider | Feature flag | Crate | Default level | Notes |
|---|---|---|---|---|
| `GzipProvider` | `gzip` (default) | `flate2` with `zlib-rs` backend | 1 (fastest) | Pools `Compress`/`Decompress` objects (up to 64 per provider) to avoid ~200 KB allocation per request |
| `ZstdProvider` | `zstd` (default) | `zstd` | 3 | Pools `Compressor`; decompressor uses streaming `zstd::Decoder` to handle any compression ratio |

Sources: [connectrpc/src/compression.rs:581-1062]()

The `CompressionRegistry::default()` automatically registers all feature-enabled providers. When the `streaming` feature is enabled, providers additionally implement `StreamingCompressionProvider` with `compress_stream`/`decompress_stream` returning `BoxedAsyncRead` (via `async-compression`).

### Compression Policy

`CompressionPolicy` controls when compression fires:

```rust
// connectrpc/src/compression.rs:475-527
pub struct CompressionPolicy { enabled: bool, min_size: usize }
// Default: enabled=true, min_size=1024 bytes
pub fn should_compress(&self, message_size: usize) -> bool {
    self.enabled && message_size >= self.min_size
}
```

The 1 KiB default matches gRPC-Java. Per-call overrides (`Some(true)` / `Some(false)`) are applied via `with_override`.

Sources: [connectrpc/src/compression.rs:474-543]()

### Encoding Negotiation

`CompressionRegistry::negotiate_encoding` implements the Connect spec rule: use the client's `Accept-Encoding` preference list (most preferred first, no quality values); if the client omits it, the server may assume the client accepts the same encoding used in the request.

Sources: [connectrpc/src/compression.rs:263-301]()

### Security

All decompression goes through `decompress_with_limit`, never a free `decompress`. The default trait implementation uses `Read::take(max_size + 1)` structurally — custom providers get compression-bomb protection for free. Zero-length bodies short-circuit before calling the decoder (zstd rejects empty input as an incomplete frame).

Sources: [connectrpc/src/compression.rs:127-146, 330-344]()

---

## Client-Side Transport

Generated service clients are generic over `T: ClientTransport`, defined in `client/mod.rs`. The trait requires only one method:

```rust
// connectrpc/src/client/mod.rs:160-171
pub trait ClientTransport: Clone + Send + Sync + 'static {
    type ResponseBody: Body<Data = Bytes> + Send + 'static;
    type Error: std::error::Error + Send + Sync + 'static;
    fn send(&self, request: Request<ClientBody>) -> BoxFuture<'static, Result<...>>;
}
```

`ServiceTransport<S>` adapts any `tower::Service` to `ClientTransport` using `ServiceExt::oneshot`, which correctly calls `poll_ready` before `call`.

Sources: [connectrpc/src/client/mod.rs:156-228]()

### Two Concrete Transports

```text
┌──────────────────────────────────────────────────────┐
│  Generated FooServiceClient<T: ClientTransport>      │
└──────────┬──────────────────────────────┬────────────┘
           │                              │
    ┌──────▼──────┐               ┌───────▼────────────┐
    │  HttpClient  │               │ SharedHttp2Connection│
    │ (hyper_util) │               │ (raw h2, honest     │
    │ HTTP/1.1+h2  │               │  poll_ready)        │
    │ ALPN / pool  │               │ Reconnect wrapper   │
    └─────────────┘               └────────────────────┘
```

#### `HttpClient` (HTTP/1.1 + HTTP/2, ALPN)

Wraps `hyper_util::client::legacy::Client`. Supports `http://` and `https://` URIs. ALPN negotiates h2 or h/1.1 on TLS connections. The `poll_ready` implementation is always `Ready(Ok(()))` — the client manages pooling and queuing internally.

- **Limitation:** For HTTP/2, the pool holds exactly one shared connection. All concurrent requests contend on h2's `Mutex<Inner>`, creating a throughput ceiling around 30–40k req/s.
- **Use when:** Connect over HTTP/1.1; unknown or mixed protocol; ALPN-based TLS.

Sources: [connectrpc/src/client/mod.rs:240-557]()

Constructors:

| Method | URI scheme | HTTP version |
|---|---|---|
| `HttpClient::plaintext()` | `http://` | H/1.1 + H/2 |
| `HttpClient::plaintext_http2_only()` | `http://` | H/2 only |
| `HttpClient::with_tls(rustls_config)` | `https://` | H/2 + H/1.1 (ALPN) |

#### `Http2Connection` / `SharedHttp2Connection` (HTTP/2 only)

A single raw h2 connection with a `Reconnect` state machine. `poll_ready` reflects actual connection state (`Idle` / `Connecting` / `Connected`). This makes it composable with `tower::balance::p2c::Balance` and `tower::load::PendingRequests`.

```rust
// connectrpc/src/client/http2.rs:204-208
pub struct Http2Connection {
    inner: Reconnect<MakeSendRequest>,
}
```

`SharedHttp2Connection` wraps it in `Arc` so multiple callers can clone a handle cheaply. For high-throughput gRPC, create N connections and wrap them in a p2c balancer to reduce per-connection h2 mutex contention by ~1/N.

Sources: [connectrpc/src/client/http2.rs:167-226]()

Constructors: `connect_plaintext`, `lazy_plaintext`, `connect_tls`, `lazy_tls`, `lazy_with_connector`, `lazy_unix` (Unix domain sockets, non-Windows).

### `ClientConfig` — Per-Client Settings

`ClientConfig` bundles the protocol, codec format, compression registry, and policy into one struct passed alongside the transport:

```rust
// connectrpc/src/client/mod.rs:577-605
pub struct ClientConfig {
    base_uri: Uri,
    protocol: Protocol,         // Connect / Grpc / GrpcWeb
    codec_format: CodecFormat,  // Proto / Json
    compression: CompressionRegistry,
    request_compression: Option<String>,  // e.g. "gzip"
    compression_policy: CompressionPolicy,
    default_timeout: Option<Duration>,
    default_max_message_size: Option<usize>,
    default_headers: http::HeaderMap,
}
```

Sources: [connectrpc/src/client/mod.rs:559-750]()

### Tower Middleware Composition

Both transports are `tower::Service`s. Wrap them with `ServiceBuilder` before passing to `ServiceTransport`:

```rust
let conn = Http2Connection::connect_plaintext(uri).await?.shared(1024);
let stacked = ServiceBuilder::new()
    .layer(TimeoutLayer::new(Duration::from_secs(30)))
    .service(conn);
let client = GreetServiceClient::new(
    connectrpc::client::ServiceTransport::new(stacked),
    config,
);
```

Sources: [connectrpc/src/client/mod.rs:82-100]()

---

## gRPC Status Encoding

For gRPC and gRPC-Web, errors are carried in the `grpc-status-details-bin` trailer as a hand-encoded `google.rpc.Status` protobuf message. The crate encodes this directly using `buffa`'s low-level `Tag`/`WireType`/`encode_varint` primitives rather than generating a `.proto` type, avoiding a dependency on the generated type.

Sources: [connectrpc/src/grpc_status.rs:1-43]()

---

## Adding a New Codec or Compression Algorithm

### New codec format

1. Add a variant to `CodecFormat` in `connectrpc/src/codec.rs:104-111`.
2. Extend `CodecFormat::from_content_type`, `content_type()`, and `streaming_content_type()` with the new MIME type.
3. Extend `Protocol::detect_from_content_type` and `Protocol::response_content_type` in `connectrpc/src/protocol.rs` for each applicable protocol.
4. Implement the encode/decode functions and wire them into the server handler and generated client call sites.

### New compression algorithm

1. Implement `CompressionProvider` (three required methods: `name`, `compress`, `decompressor`). The default `decompress_with_limit` via `Read::take` is safe — override only for performance.
2. Optionally implement `StreamingCompressionProvider` (two methods: `compress_stream`, `decompress_stream`) when the `streaming` feature is relevant.
3. Add a Cargo feature flag gating the provider on the appropriate optional dependency.
4. Register in `CompressionRegistry::default()` behind the feature flag, following the existing `GzipProvider` / `ZstdProvider` pattern in `connectrpc/src/compression.rs:546-575`.

No changes to the protocol or envelope layers are needed — `CompressionRegistry` is injected into `EnvelopeDecoder` and `EnvelopeEncoder` at construction time, and negotiation is fully data-driven by the registered provider names.

Sources: [connectrpc/src/compression.rs:94-167, 193-240, 546-575]()

---

## Summary

The request path through the wire stack is: `Content-Type` → `Protocol::detect` (identifies protocol + codec + streaming flag) → `EnvelopeDecoder` (for streaming: strips the 5-byte header, decompresses if needed) → codec decode (proto or JSON) → handler. The response path is the mirror: codec encode → `EnvelopeEncoder` (for streaming: compresses if policy allows, writes 5-byte header) → protocol-specific trailer delivery. The client stack is a `ClientConfig` (protocol + codec + compression settings) paired with a `ClientTransport` (either `HttpClient` for HTTP/1.1+h2 with ALPN, or `SharedHttp2Connection` for gRPC with honest Tower readiness semantics). All compression is enforced through size limits, making the codebase structurally resistant to compression-bomb attacks.

Sources: [connectrpc/src/compression.rs:127-146]()

---

## 06. After 30 Minutes: What to Try Next

> Synthesis of what you now understand — the codegen pipeline, Tower dispatch, interceptor chain, and protocol demux — plus concrete next actions: run the conformance suite, walk the examples (eliza, multiservice, streaming-tour, wasm-client), read docs/guide.md sections on TLS and streaming, and understand how task ci gates every PR.

- Page Markdown: https://grok-wiki.com/public/wiki/anthropics-connect-rust-abe117693c52/pages/06-after-30-minutes-what-to-try-next.md
- Generated: 2026-05-21T02:33:28.997Z

### Source Files

- `docs/guide.md`
- `Taskfile.yaml`
- `examples/streaming-tour/src`
- `examples/multiservice/src`
- `examples/wasm-client/src`
- `conformance/Cargo.toml`
- `CHANGELOG.md`

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [docs/guide.md](docs/guide.md)
- [Taskfile.yaml](Taskfile.yaml)
- [examples/streaming-tour/src/server.rs](examples/streaming-tour/src/server.rs)
- [examples/streaming-tour/src/client.rs](examples/streaming-tour/src/client.rs)
- [examples/multiservice/src/bin/server.rs](examples/multiservice/src/bin/server.rs)
- [examples/wasm-client/src/transport.rs](examples/wasm-client/src/transport.rs)
- [conformance/Cargo.toml](conformance/Cargo.toml)
- [CHANGELOG.md](CHANGELOG.md)
- [.github/workflows/ci.yml](.github/workflows/ci.yml)
</details>

# After 30 Minutes: What to Try Next

By this point you have a mental map of the three-crate layout (`connectrpc`, `connectrpc-codegen`, `connectrpc-build`), you have seen a unary handler compile and serve, and you have a sense of where the major moving parts live. This page consolidates what those parts actually do — the codegen pipeline, the Tower dispatch stack, the interceptor chain, and the protocol demultiplexer — then gives you a concrete sequence of next actions: running the conformance suite, walking each example, and understanding how `task ci` gates every PR.

---

## What You Now Understand

### The Codegen Pipeline

Two workflows produce identical runtime APIs. Both ultimately invoke `protoc-gen-connect-rust` (the binary crate inside `connectrpc-codegen`) with a `buf`-based pipeline:

```
.proto files
    │
    ├─ protoc-gen-buffa           → <stem>.rs, <stem>.__view.rs   (message types + zero-copy views)
    ├─ protoc-gen-connect-rust    → <stem>.__connect.rs            (service trait + client struct)
    └─ protoc-gen-buffa-packaging → <pkg>.mod.rs                   (include! stitcher)
```

`connectrpc-build` wraps this in a `build.rs` call that writes into `OUT_DIR` and re-triggers on change. The `buf generate` path writes checked-in files that you import with an explicit `#[path = "generated/..."]` declaration. The output for both is a service trait (e.g. `trait GreetService`) and a generated client (`GreetServiceClient`), with the same call-site shape either way.

The `multiservice` example is the best live view of the `buf generate` output layout — two separate `generated/buffa/` and `generated/connect/` trees, one for message types and one for service stubs, stitched together by `mod.rs` files.

Sources: [docs/guide.md:172-249](), [examples/multiservice/src](examples/multiservice/src/bin/server.rs)

---

### Tower Dispatch

Every `connectrpc::Router` is a `tower::Service`. The request path, from wire to handler, is:

```text
TCP / TLS
  └── hyper (HTTP/1.1 or HTTP/2)
        └── Tower layer stack (TraceLayer, auth middleware, TimeoutLayer, …)
              └── ConnectRpcService    ← demux: detects Connect / gRPC / gRPC-Web
                    └── Interceptor chain  ← typed, after envelope decode
                          └── Router::dispatch  ← matches /package.Service/Method
                                └── your handler async fn
```

`ServiceBuilder` applies layers top-to-bottom so the first `.layer()` call is outermost (sees requests first, responses last). A typical axum composition looks like:

```rust
// docs/guide.md:599-609
let app = axum::Router::new()
    .fallback_service(connect_router.into_axum_service())
    .layer(
        ServiceBuilder::new()
            .layer(TraceLayer::new_for_http())          // outermost
            .layer(axum::middleware::from_fn_with_state(tokens, auth_middleware))
            .layer(TimeoutLayer::with_status_code(
                http::StatusCode::REQUEST_TIMEOUT,
                Duration::from_secs(5),
            )),                                         // innermost
    );
```

Tower middleware operates on raw `http::Request` / `http::Response` bytes — it runs before the envelope is decoded. Middleware inserts values via `req.extensions_mut().insert(value)` and handlers read them back via `ctx.extensions().get::<T>()`.

Sources: [docs/guide.md:577-639]()

---

### The Interceptor Chain

Interceptors (added in 0.6.0) run after the dispatcher decodes the envelope, decompresses the body, and parses protocol headers — so they already know the RPC method, the deadline, and the negotiated protocol (Connect / gRPC / gRPC-Web):

```text
ConnectRpcService
  ├── protocol demux + envelope decode
  ├── Interceptor A  ← sees Spec, headers, deadline, Payload (lazily decoded)
  │     └── Interceptor B
  │           └── handler
  └── (response flows back through A ← B ← handler)
```

Registration order is outermost-first, matching `connect-go`'s `WithInterceptors`:

```rust
// docs/guide.md:760-770
.with_interceptor(A).with_interceptor(B)
//  request:  A → B → handler
//  response: A ← B ← handler
```

Key design details:
- **Zero-cost fast path.** A service with no interceptors pays one `is_empty()` branch and allocates nothing per request.
- **Lazy decode.** `Payload::message::<M>()` decodes once and caches — if an interceptor decodes, the handler's decode is free.
- **Shared instances.** `with_interceptor_arc(Arc<dyn Interceptor>)` shares a process-wide interceptor (token cache, rate limiter) across multiple services.
- **Streaming hook.** `intercept_streaming` covers all three non-unary shapes (server, client, bidi) with a single `Stream`-shaped hook at stream establishment.

Sources: [docs/guide.md:718-888](), [CHANGELOG.md:11-75]()

---

### Protocol Demultiplexer

`ConnectRpcService` inspects incoming requests and routes them to the appropriate codec path. Three wire protocols are supported on the same port:

| Protocol | Content-Type header | `ctx.protocol()` value |
|---|---|---|
| Connect (unary) | `application/proto` or `application/json` | `Connect` |
| gRPC | `application/grpc` | `Grpc` |
| gRPC-Web | `application/grpc-web` | `GrpcWeb` |

Handlers and interceptors never branch on protocol — the dispatcher handles all encoding, framing, error serialization (Connect JSON vs gRPC trailers vs gRPC-Web trailers), and compression negotiation. `ctx.protocol()` is available for observability spans that need to label `rpc.system` correctly.

Sources: [docs/guide.md:289-309](), [CHANGELOG.md:100-118]()

---

## Concrete Next Actions

### 1. Run the Conformance Suite

The conformance suite is the mechanized proof that the implementation speaks the Connect, gRPC, and gRPC-Web protocols correctly. A healthy run ends with `N passed, 0 failed`.

```bash
# One-time setup
task conformance:download
task conformance:build

# Full server suite (3 600 tests, all protocols)
task conformance:test

# Focused suites
task conformance:test-connect-only          # 1 192 tests
task conformance:test-client-connect-only   # 2 580 tests
task conformance:test-client-grpc-only      # 1 454 tests
task conformance:test-client-grpc-web-only  # 2 838 tests
```

CI runs the `connect-only` server suite and the `client-connect-only` client suite (`.github/workflows/ci.yml:120-146`). Running the full `task conformance:test` locally is the broadest protocol-correctness check you have.

The conformance binary itself (`conformance-server` and `conformance-client`) lives in `conformance/src/bin/` and links against `connectrpc` with the `server`, `axum`, and `tls` features — it is the most complete exerciser of the runtime short of your own production service.

Sources: [Taskfile.yaml:164-264](), [conformance/Cargo.toml:30](), [.github/workflows/ci.yml:120-146]()

---

### 2. Walk the Examples

Run them in this order — each one adds one concept:

#### `streaming-tour` — all four RPC types in ~130 lines

```bash
cargo run -p streaming-tour-example --bin streaming-tour-server &
cargo run -p streaming-tour-example --bin streaming-tour-client
```

The server (`examples/streaming-tour/src/server.rs`) implements `NumberService` with four methods: `square` (unary), `range` (server stream via `futures::stream::iter`), `sum` (client stream draining with `requests.next().await`), and `running_sum` (bidi via `futures::stream::unfold`). The client (`examples/streaming-tour/src/client.rs`) demonstrates all four call patterns: `.await?` for unary, `.message().await?` loop for server-stream, a `Vec` of messages for client-stream, and `.send()` / `.message()` / `.close_send()` for bidi.

Sources: [examples/streaming-tour/src/server.rs:40-129](), [examples/streaming-tour/src/client.rs:22-90]()

#### `multiservice` — multiple proto packages, multiple services, well-known types

```bash
task example:multiservice:server &   # in one terminal
task example:multiservice:client     # in another
# or end-to-end:
task example:multiservice:test
```

This is the example CI runs (`ci.yml:114`). Three services (`GreetService`, `MathService`, `WellKnownTypesService`) from separate proto packages register onto a single `ConnectRouter`, then mount on axum alongside a `/health` HTTP route. It shows the full `buf generate` output layout and `buffa_types` well-known-type usage (`Timestamp`, `Duration`, `Struct`).

Sources: [examples/multiservice/src/bin/server.rs:225-246](), [Taskfile.yaml:136-143]()

#### `eliza` — production-shaped app with TLS, mTLS, CORS, and cross-language interop

```bash
task example:eliza
```

This is the most complete example. It ports the `connectrpc/examples-go` ELIZA chatbot: server-streaming `Introduce` plus bidi-streaming `Converse`, both with TLS. The README walks through generating self-signed certs with `openssl`, enabling mTLS with `--client-ca`, and the rustls constraint that the CA cert must be distinct from the leaf cert. It interoperates with the live Go reference at `demo.connectrpc.com`.

Sources: [docs/guide.md:1269]()

#### `wasm-client` — browser fetch transport

The wasm example replaces `HttpClient` with a hand-rolled `ClientTransport` that bridges the browser `fetch` API (`web-sys`) into the `tower::Service` shape the generated client expects. The key file is `examples/wasm-client/src/transport.rs`: `FetchTransport` implements `ClientTransport` and wraps the fetch future in `SendWrapper` to satisfy the `Send` bound in a single-threaded wasm environment. The same generated `NumberServiceClient` compiles to `wasm32-unknown-unknown` without changes.

```rust
// examples/wasm-client/src/transport.rs:83-95
impl ClientTransport for FetchTransport {
    type ResponseBody = http_body_util::Full<Bytes>;
    type Error = FetchError;

    fn send(&self, request: Request<ClientBody>) -> BoxFuture<'static, ...> {
        Box::pin(SendWrapper::new(fetch(request)))
    }
}
```

Sources: [examples/wasm-client/src/transport.rs:83-95]()

---

### 3. Read the TLS and Deadline Sections of `docs/guide.md`

Two sections repay close reading because the options are non-obvious and the defaults are permissive:

**TLS** (`docs/guide.md:935-978`): The standalone `Server` and the axum path use different entry points (`Server::with_tls` vs `connectrpc::axum::serve_tls`), but both stamp `PeerAddr` and `PeerCerts` into request extensions so `ctx.peer_certs()` is portable across hosting paths. The `mtls-identity` example shows identity extraction from the cert's DNS SAN.

**Deadline policy** (`docs/guide.md:982-1040`): By default the server trusts whatever timeout the client sends — including no timeout at all. `DeadlinePolicy::with_max` is the most critical knob for any service that accepts untrusted callers. `with_default_timeout` covers requests with no timeout header. `with_enforce_on_streams(true)` closes the gap for streaming responses.

```rust
// docs/guide.md:995-1002
let policy = DeadlinePolicy::new()
    .with_min(Duration::from_millis(5))
    .with_max(Duration::from_secs(30))
    .with_default_timeout(Duration::from_secs(10))
    .with_enforce_on_streams(true);
```

Sources: [docs/guide.md:935-978](), [docs/guide.md:982-1040]()

---

### 4. Understand `task ci` — the PR Gate

`task ci` runs the exact checks CI enforces, in order:

```bash
task ci
# Equivalent to:
# cargo check --workspace --all-features --all-targets
# cargo clippy --workspace --all-features --all-targets -- -D warnings
# cargo fmt --all -- --check  (nightly rustfmt)
# cargo test --workspace --all-features
# cargo doc --workspace --all-features --no-deps
# cargo check -p connectrpc --no-default-features   (minimal build check)
# cargo test -p connectrpc --no-default-features
# ./examples/multiservice/test.sh
```

CI adds three jobs that `task ci` omits: the MSRV check (Rust 1.88), the wasm build (`wasm32-unknown-unknown`), and the conformance suite. Run `task conformance:test` separately for full protocol validation.

All CI jobs pin `protoc` at version 33.5 (required for `edition = "2023"` proto files) via `arduino/setup-protoc`. The `RUSTFLAGS=-Dwarnings` env var turns every compiler warning into a build error in both Check and Clippy.

Sources: [Taskfile.yaml:69-83](), [.github/workflows/ci.yml:1-178]()

---

## Summary Table: Examples at a Glance

| Example | What it adds | Run command |
|---|---|---|
| `streaming-tour` | All four RPC types | `task example:eliza` (see server/client bins) |
| `multiservice` | Multi-service, multi-package, WKTs | `task example:multiservice:test` |
| `eliza` | TLS, mTLS, CORS, bidi streaming | `task example:eliza` |
| `wasm-client` | Browser fetch transport | `./examples/wasm-client/test.sh` |
| `middleware` | Tower layer composition, auth | run manually (no task shortcut) |
| `mtls-identity` | mTLS + cert-SAN ACL | run manually |
| `bazel` | Bazel build integration | run manually |

Sources: [docs/guide.md:1264-1274](), [Taskfile.yaml:114-143]()

---

The most productive 30 minutes from here is: `task conformance:test` to see the protocol coverage, then `streaming-tour` to cement the handler shape for all four RPC types, then the TLS and deadline sections of `docs/guide.md` to understand what the defaults leave open. The CHANGELOG for 0.6.0 is the authoritative record of the interceptor design decisions and is worth reading as a companion to the guide.

Sources: [CHANGELOG.md:11-159]()

---
