Skip to content

feat(ts): protobuf-es runtime mode for TS client/server (native messages + sebuf transport)#207

Draft
hishamank wants to merge 13 commits into
feat/ts-modules-oneof-integrationfrom
feat/ts-es-transport
Draft

feat(ts): protobuf-es runtime mode for TS client/server (native messages + sebuf transport)#207
hishamank wants to merge 13 commits into
feat/ts-modules-oneof-integrationfrom
feat/ts-es-transport

Conversation

@hishamank

@hishamank hishamank commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

What

Adds an opt-in ts_runtime=protobuf-es mode to protoc-gen-ts-client and protoc-gen-ts-server. In this mode, message types + JSON codec come from Buf's native protoc-gen-es (*_pb.ts), and sebuf generates only the HTTP transport on top — routing every request/response through create/toJson/fromJson(..., { ignoreUnknownFields: true }). This mirrors how Go uses protoc-gen-go + protoc-gen-go-http.

The existing hand-rolled mode stays the default — no behavior change unless ts_runtime=protobuf-es is passed.

Why

sebuf's Go output uses the language's native protobuf runtime, so it can't drift from protojson. The TS output hand-rolls message types with no codec, so it can silently disagree with the wire — e.g. a server omits zero-values per protojson, but the hand-rolled TS types declare them present, so resp.list.map(...) throws on an empty list. This brings TS into the native-runtime tier. It is not adopting Connect — sebuf keeps its own HTTP transport (paths, headers, ValidationError); only the message layer goes native.

How it works

  • New ts_runtime=protobuf-es plugin option (invalid values fail loudly).
  • es-mode skips emitting the hand-rolled type modules (protoc-gen-es owns them) but still emits the shared errors.ts.
  • Messages import <Msg>Schema (value) + <Msg> (type) from ./<proto>_pb.js.
  • The @bufbuild/protobuf import lists only the symbols actually used: each es-mode emission site records the helpers it prints (MessageInitShape/fromJson per method, create/toJson only when a body or response is encoded), so the import mirrors real usage and compiles under noUnusedLocals.
  • Client: MessageInitShape<typeof ReqSchema> params; body: JSON.stringify(toJson(ReqSchema, create(ReqSchema, req))); fromJson(ResSchema, await resp.json(), { ignoreUnknownFields: true }).
  • Server: fromJson request decode; toJson(create(ResSchema, result)) response encode; handler takes branded <Req>, returns MessageInitShape<...>.
  • Unary and server-streaming (SSE) supported.

Review fixes (second round of commits)

A review pass found two real issues in the first iteration; both are fixed on this branch:

  1. Latent hand-rolled @bufbuild/protobuf import (fixed in 8c1e4c1). The runtime-symbol import list was originally derived by regex-scanning the generated body, and the scan ran in both modes. An RPC literally named Create renders async create(, which matched \bcreate\b and injected import { create } from "@bufbuild/protobuf" into hand-rolled output — a package hand-rolled consumers don't install. A field named create in an es-mode GET method had the mirror problem: an unused import that fails noUnusedLocals. The body scan is now deleted; each es-mode emission site records exactly the helpers it prints, and NeedProtobufES is a no-op outside es mode as a structural invariant. Regression tests cover both failure modes (runtime_symbols_test.go in both generators), and all pre-existing goldens are byte-identical under the new tracking.
  2. es output was never machine-typechecked (fixed in 96b33c9). The es goldens' typecheck was a manual verification, and it turned out the checked-in golden tree didn't actually compile: the fixtures' *_pb.ts import ./sebuf/http/annotations_pb.js, which was never checked in (TS2307). New TestTSClientGenESGoldenTypecheck / TestTSServerGenESGoldenTypecheck run tsc --noEmit (strict nodenext + noUnusedLocals, via the ported tscommon/typecheck harness) over testdata/golden/es against the real @bufbuild/protobuf package, skipping cleanly when the toolchain is absent. The missing annotations_pb.ts goldens are now checked in. This is also the machine guard for ESQualifiedName drift: a symbol-name divergence from protoc-gen-es now fails CI with a TS2724: no exported member error instead of surfacing in a consumer build (verified by fault injection).

Also documented (5854efd): URL path/query params do not route through the codec (only bodies and SSE events do — by design, URL params have no protojson form), and MessageInitShape makes all request fields optional, so a missing path param compiles and only surfaces as a runtime "undefined" URL segment.

Tests / verification

  • go test ./..., gofmt, vet, staticcheck all green.
  • Hand-rolled default mode is byte-identical (all pre-existing goldens unchanged; new golden coverage lives under testdata/golden/es/).
  • es goldens are typechecked in CI (tsc --noEmit, strict nodenext, noUnusedLocals) against the real @bufbuild/protobuf package — no longer a manual claim.
  • Regression tests assert hand-rolled output never imports @bufbuild/protobuf (RPC named Create) and that es-mode imports only the runtime symbols actually used (GET method with a field named create).
  • Wire-conformance test proves: omitted zero-values are materialized on decode (scalars → 0/""/false, lists → []), and unknown fields don't throw (ignoreUnknownFields) — but do throw without the flag.
  • Golden es tests cover unary POST, GET, and server-streaming (SSE); a non-enum GET fixture and enum-param rejection are asserted.

Known limitations (documented in docs/client-generation.md)

  • Enum path/query parameters are not yet supported in es-mode (protobuf-es enums are numeric; string↔enum conversion isn't implemented). The generator fails loudly at generation time rather than emitting uncompilable code.
  • RPC I/O should be top-level messages (nested names reconciled via ESQualifiedName, verified against @bufbuild/protoc-gen-es@2.12.1; the es golden typecheck catches drift on other versions).
  • URL parameters bypass the codec (string-coerced from the init shape) and path params are not compile-time required under MessageInitShape — see docs.

Base

Targets feat/ts-modules-oneof-integration (the merged #199 + #200 result) since the es transport reuses that modules-layout import machinery. Rebase onto main once those land.

🤖 Generated with Claude Code

hishamank and others added 13 commits July 13, 2026 17:38
…ess proof

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reject unknown parameter keys and invalid ts_runtime values in the
plugin ParamFunc so a typo fails loudly instead of silently falling
back to hand-rolled output. Collapse the ParseMessageRuntime pass-
through wrapper into a single exported function.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…oreUnknownFields)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend ts_runtime=protobuf-es to server-streaming (SSE) RPCs: the async
generator now takes a MessageInitShape request, encodes any request body via
create/toJson, and decodes each streamed event through
fromJson(<Res>Schema, JSON.parse(data), { ignoreUnknownFields: true }) instead
of a raw cast, importing schemas/types from protoc-gen-es <proto>_pb.js.

Derive protoc-gen-es local symbol names via ESQualifiedName (underscore-joined
nested names, e.g. Outer_Inner / Outer_InnerSchema) rather than QualifiedTSName,
matching @bufbuild/protoc-gen-es v2.12.1 output (verified empirically).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nknown fields ignored)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…onses)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…afe)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enum path/query parameters (including repeated enum query params) are not
representable in protobuf-es mode: enums are numeric there but URL params
arrive as strings, and RefEnum resolves to the hand-rolled ./<proto>.js type
module that es-mode deliberately does not emit -> downstream TS2307. Instead
of emitting uncompilable output, both generators now reject enum path/query
params at generation time via checkNoEnumParamsES with a clear error.

Adds a non-enum unary GET golden (string path param + scalar query params,
get_params.proto) wired into the es golden tests and typechecked under
--strict --noUnusedLocals, plus in-process tests asserting the fail-loud path.
Updates docs to cover enum path AND query params. Removes obsolete TODO(es).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The @bufbuild/protobuf import list was derived by regex word-boundary
scanning of the generated body, which had two failure modes:

- an RPC named Create renders `async create(`, injecting
  `import { create } from "@bufbuild/protobuf"` into HAND-ROLLED output,
  whose consumers do not install that package;
- a field named create in an es-mode GET method (`req.create`) imported
  the helper unused, failing consumers under noUnusedLocals.

Each es-mode emission site now records exactly the helpers it prints
(MessageInitShape/fromJson per method; create/toJson only when a body or
response is encoded; create for the server GET init wrap), the body scan
is deleted, and EmitContext.NeedProtobufES is a no-op outside protobuf-es
runtime mode as a structural invariant. Existing goldens are
byte-identical; regression tests cover both failure modes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Byte-comparing goldens locks down what sebuf emits but not that it
compiles against the real @bufbuild/protobuf types and the symbols
protoc-gen-es actually exported — the check that catches an
ESQualifiedName naming divergence in CI instead of a consumer build.

Ports the tscommon/typecheck harness (strict nodenext + noUnusedLocals,
tsc from PATH or pinned via npx, skips when unavailable) and runs it
over testdata/golden/es in both generators, resolving @bufbuild/protobuf
through the same git-ignored node_modules discovery the wire-conformance
test uses (SEBUF_ES_NODE_MODULES override supported).

Also checks in the transitive sebuf/http/annotations_pb.ts goldens the
fixtures' *_pb.ts import — without them the es golden tree never
typechecked (TS2307 on ./sebuf/http/annotations_pb.js).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
URL path/query parameters do not route through create/toJson/fromJson —
only request bodies, response bodies, and SSE events do — and
MessageInitShape makes every request field optional, so a missing path
parameter compiles and surfaces only as a runtime "undefined" URL
segment. Also notes the es golden typecheck tests as the guard for
protoc-gen-es naming drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant