Skip to content

buffa-build: expose the parsed FileDescriptorSet for runtime use #125

@christopherwxyz

Description

@christopherwxyz

Context

buffa-build already parses .proto files into a FileDescriptorSet internally — either via protoc (default), buf (Config::use_buf()), or a pre-built file (Config::descriptor_set(path)). The bytes then flow into buffa-codegen for Rust generation.

Those descriptor bytes aren't exposed to the build-time consumer. There's only an input descriptor_set(path) method.

Use case

Implementing gRPC Server Reflection v1 (grpc.reflection.v1.ServerReflection) requires the server to serve raw FileDescriptorProto bytes back to clients like grpcurl. The natural place to obtain those is from the same codegen pipeline that emits the message types — a single descriptor set covers the whole service surface.

This came up while writing a generic ServerReflection handler over connectrpc. The straightforward expectation was something like:

// build.rs
buffa_build::Config::new()
    .files(&["proto/myservice.proto"])
    .includes(&["proto/"])
    .emit_descriptor_set("myservice_descriptor.bin")  // <-- desired
    .compile()?;
// src/lib.rs
pub const FILE_DESCRIPTOR_SET: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/myservice_descriptor.bin"));

Workaround today

Invoke protoc a second time directly from build.rs, separate from buffa-build:

let _ = Command::new("protoc")
    .arg(format!("--descriptor_set_out={}", out.join("descriptor.bin").display()))
    .arg("--include_imports")
    .arg(format!("-I{}", proto_dir.display()))
    .arg(&proto_file)
    .status()?;

This works but:

  • Runs protoc twice over the same files
  • Diverges from Config::use_buf() (the workaround hard-codes protoc)
  • Requires every consumer to re-derive the include paths

Proposal

Add Config::emit_descriptor_set(name: impl Into<String>) -> Self that writes the internally-parsed FileDescriptorSet bytes to <out_dir>/<name>. Internally a one-liner since the parsed set is already on the stack — at minimum:

let bytes = parsed_descriptor_set.encode_to_vec();
std::fs::write(out_dir.join(&name), bytes)?;

Flag with --include_imports semantics (transitive closure) by default, matching what reflection clients need; an opt-out toggle could be added if needed.

The same opportunity applies symmetrically to connectrpc-build, which wraps buffa-build and would naturally surface the option as Config::emit_descriptor_set(...) too.

Why this matters

Without runtime FileDescriptorSet access, the grpcreflect story for the buffa + connectrpc stack requires a parallel protoc invocation in every consumer's build.rs. Closing the gap inside buffa-build makes server reflection a 1-line opt-in instead of a per-project recipe.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions