Skip to content

Support protobuf-es (@bufbuild/protobuf v2) payloads#2047

Open
wcygan wants to merge 7 commits into
temporalio:mainfrom
wcygan:feat/protobuf-es-payload-converters
Open

Support protobuf-es (@bufbuild/protobuf v2) payloads#2047
wcygan wants to merge 7 commits into
temporalio:mainfrom
wcygan:feat/protobuf-es-payload-converters

Conversation

@wcygan
Copy link
Copy Markdown

@wcygan wcygan commented May 12, 2026

Resolves #2045.

I would like to use this in my project protoc-gen-ts-temporal which is similar to protoc-gen-go-temporal from "Go Code Generation with Temporal & Protobufs"

This would be useful to me because I could run an HTTP server in TypeScript and use a typed client (this PR + protoc-gen-ts-temporal) to begin a workflow that executes on a Rust worker

Summary

Adds these in @temporalio/common/lib/protobufs-es:

  1. ProtobufEsBinaryPayloadConverter
  2. ProtobufEsJsonPayloadConverter
  3. DefaultPayloadConverterWithProtobufsEs

Wire-compatible with the protobufjs path — binary bytes are byte-identical; JSON is canonical proto3.

@wcygan wcygan requested a review from a team as a code owner May 12, 2026 20:53
@wcygan wcygan changed the title Feat/protobuf es payload converters Add protobuf-es payload converters May 12, 2026
@wcygan wcygan changed the title Add protobuf-es payload converters Support protobuf-es (@bufbuild/protobuf v2) payloads May 12, 2026
@mjameswh
Copy link
Copy Markdown
Contributor

Are there any reason why this would need to be part of the SDK itself?

@wcygan
Copy link
Copy Markdown
Author

wcygan commented May 12, 2026

Are there any reason why this would need to be part of the SDK itself?

Hey @mjameswh I started by mirroring the existing layout. It might not be strictly necessary, but it might help with discoverability since the SDK already ships the protobufjs converter. I also reused some internal APIs like PayloadConverterWithEncoding in the new code.

I opened this PR because I see myself reusing this stack with Rust / TypeScript in a few different projects:

  1. Temporal TypeScript SDK (durable execution)
  2. ConnectRPC (transport)
  3. Buf CLI (codegen via https://github.com/bufbuild)

I feel like supporting https://github.com/bufbuild/protobuf-es natively in the SDK would be a really nice user experience so that clients don't need too many workarounds to enable it; I am aiming for something close to a "it just works" experience


Sidenote: I am also admittedly not the most familiar with the node ecosystem's packaging conventions (I primarily write Java and Rust, only using TypeScript where absolutely necessary), so I'm open to whatever direction you prefer, and I'm happy to support this work

@wcygan
Copy link
Copy Markdown
Author

wcygan commented May 12, 2026

I also have a prototype of this working locally with protoc-gen-ts-temporal

wcygan added 6 commits May 12, 2026 21:33
Adds `ProtobufEsBinaryPayloadConverter`, `ProtobufEsJsonPayloadConverter`, and
`DefaultPayloadConverterWithProtobufsEs` exported from
`@temporalio/common/lib/protobufs-es`, parallel to the existing protobufjs trio
at `@temporalio/common/lib/protobufs`.

Why
---
Downstream codegen tooling (e.g. protoc-gen-ts-temporal) emits
`@bufbuild/protobuf` v2 message types, but the SDK only shipped protobufjs-based
payload converters. Consumers had to write the bridge themselves to use generated
protobuf-es types with Temporal — mechanical work that's easy to get subtly
wrong, especially around the proto3 JSON mapping for `google.protobuf.Any`,
`oneof`, and `int64`, and that rarely benefits from cross-SDK wire-compatibility
review. Bringing a first-class converter into the SDK closes that gap and gives
the protobuf-es ecosystem the same out-of-the-box experience the protobufjs path
has had since v0.

Design
------
* Wire-compatible with the protobufjs converters. Same encoding markers
  (`binary/protobuf`, `json/protobuf`), same fully-qualified `messageType`
  metadata, byte-identical binary payloads, and conforming proto3 JSON. A worker
  on one runtime can decode a payload produced by a client on the other in
  either binary or JSON form. Composite ordering
  (Undefined → Binary → ProtobufJson → ProtobufBinary → Json) matches
  `DefaultPayloadConverterWithProtobufs` and the Go SDK default.
* `@bufbuild/protobuf` is declared as an optional peer dependency, so consumers
  on the protobufjs path pay nothing. The converters live behind a separate
  `protobufs-es` sub-path entry; workflow bundles that don't import it stay free
  of the dependency.
* Constructor accepts a pre-built `Registry` or a schema array (in which case
  the converters call `createRegistry` internally) — both forms are common in
  consumer code.
* JSON serialization forwards `this.registry` to `toJson`/`fromJson`, which is
  required to round-trip `google.protobuf.Any` and proto2 extension fields.
* JSON output is semantically equivalent to the protobufjs path but not
  byte-identical. protobuf-es follows `@bufbuild/protobuf`'s default — and the
  Go SDK's `protojson.Marshal` default — of omitting fields with implicit
  (zero) values. protobufjs + `proto3-json-serializer` emits them. Both forms
  parse correctly by either reader; the wire-bytes difference is documented in
  `docs/protobuf-libraries.md` and pinned by tests rather than papered over.

Tests (42 cases under AVA)
--------------------------
* Unit: `toPayload`/`fromPayload` shape, round-trip, error paths (missing or
  malformed metadata, missing schema, missing registry, bad registry argument),
  composite-ordering parity with the protobufjs default.
* Cross-runtime: binary byte-equality and JSON structural equality between the
  protobufjs and protobuf-es converters for the same logical message; each
  runtime decodes the other's output. Covers nested messages, `oneof`
  (text / number / nested-message branches), `map<string, T>`, and bytes fields.
* Edge cases: `int64` (protobuf-es emits the canonical quoted-string form;
  protobufjs emits a number — divergence asserted explicitly); plain JS objects
  that happen to share a `$typeName` field surface a clear
  `PayloadConverterError` rather than silently round-tripping as JSON.
* `google.protobuf.Any`: positive round-trip through JSON proves the registry
  forwarding works; a negative test with an Any-only registry confirms that
  `toJson` actually consults the registry rather than silently emitting an
  unrenderable Any.
* Sandbox integration (gated on `RUN_INTEGRATION_TESTS`): a workflow round-trips
  a `BinaryMessage` through the v8 isolate; a second workflow exercises
  signal / query / update with protobuf-es message types — the surface
  protoc-gen-ts-temporal generates against — and verifies wire round-trip
  through Core; a third confirms that a worker whose registry lacks an inbound
  message type surfaces a clean `PayloadConverterError` as a workflow task
  failure rather than crashing.

Fixtures share `.proto` sources with the protobufjs codegen pipeline under
`packages/test/protos/`; `buf` is configured to read the same directory, so the
two converters can't drift on what "the same message" means. A `regen:protos-es`
npm script and `@bufbuild/buf` as a devDependency mean regeneration works from a
clean checkout with no system buf install.

Docs
----
`docs/protobuf-libraries.md` gains an "Alternative: @bufbuild/protobuf v2"
section alongside the existing protobufjs decision log, with a minimal
end-to-end consumer example and the key API-shape differences (`createRegistry`
vs protobufjs `Root`, `create(Schema, init)` vs `Message.create`, no
`patchProtobufRoot` step, spec-compliant proto3 JSON built in). Includes a
direct note on the JSON divergence on implicit defaults with an actionable
workaround if byte parity is required.
Drop preferential language and reduce ceremony to a one-paragraph
overview plus a minimal payload-converter example.
Replaces the hand-rolled isProtobufEsMessage helper with
@bufbuild/protobuf's public isMessage. Tracks future tightening of
the brand upstream and removes the MessageShape<DescMessage> casts
that were only there because we'd erased the schema type.
ava doesn't guarantee declaration-order execution for non-serial
tests, so `Runtime.install` inside the first integration test could
race with a Worker constructed by another concurrent test, hitting
"runtime already instantiated". Switch the three integration tests
to `test.serial` to lock in the ordering the comment was already
claiming.
- Hoist the structural-typing ($typeName collision) caveat from a
  private helper to the public DefaultPayloadConverterWithProtobufsEs
  JSDoc, where users configuring the converter will actually see it.
- Explain why the binary fromPayload rewraps Uint8Array but the JSON
  path doesn't (TextDecoder accepts cross-realm BufferSource).
- Fix a test comment that wrongly claimed $typeName is non-enumerable;
  it's a regular enumerable assignment in @bufbuild/protobuf.
- Add unit test for `toJson` on google.protobuf.Any with no registry
  at all (the existing test only covered the "registry has AnySchema
  but not the inner type" case).
- Add cross-runtime test asserting pbjs rejects an es-produced binary
  payload with `metadata.messageType` stripped — symmetric to the
  existing es-side malformed-metadata test.
- Make the workflow's "finish before input" guard a nonRetryable
  `ApplicationFailure` instead of a plain `Error` (plain Errors fail
  the workflow task and retry forever, so the throw was unreachable
  in practice). Add an integration test that pins this contract.
@wcygan wcygan force-pushed the feat/protobuf-es-payload-converters branch from 48e2e6d to 347ef0d Compare May 13, 2026 02:59
@mjameswh
Copy link
Copy Markdown
Contributor

the SDK already ships the protobufjs converter

Yeah, to be honest, I’d say the existing ProtobufPayloadConverter lives in the SDK mostly for legacy reasons. Given the opportunity to make that decision again today, I don’t think we would include it directly in the core SDK package.

The main concern is not with protobuf-es specifically — the proposal itself makes sense, and I can see why it would be useful. The issue is more about where optional integrations like this should live.

Every optional feature we ship as part of the SDK carries long-term maintenance, compatibility, testing, release, and support expectations. Over time, that does not scale very well: it reduces maintainer bandwidth for core SDK work, makes the package surface area harder to reason about, and blurs the line between “core Temporal SDK behavior” and “integration code for a particular ecosystem/toolchain.” It also makes us more cautious about future changes, because users reasonably expect anything shipped in the SDK itself to follow the SDK’s reliability and compatibility standards.

We’re trying to move toward a repo/ecosystem model that is more scalable for us and more open to community contributions. In particular, we’ve been extracting optional features either into the contrib area or into external community-maintained repositories, depending on the case. We’re also encouraging integrations like this to use the newly introduced Plugin API, which should significantly improve the DX for users of optional features without requiring those features to live directly in the core SDK package.

For external community-maintained integrations, we’ve started the Temporal Code Exchange initiative to give those contributions better visibility, so users can still discover them without everything needing to live directly in the SDK repo.

So, what about this specific pull request? I think that, at this time, an external community-maintained package may be the better fit than adding this to our contrib directory.

The main reason is that this converter seems closely tied to the protoc codegen plugin you’re building. In practice, the interesting evolution is likely to happen at that integration layer: what the generated types look like, how schemas are registered, how cross-language payload compatibility is exposed, what conventions the generator establishes, etc.

By contrast, the Temporal payload converter API itself is relatively stable. If we put only the converter piece in the SDK repo, but the generator and surrounding workflow live elsewhere, ownership gets split in a way that would be inefficient for both teams. Changes to the end-to-end experience would need coordination across two repositories, and in some cases you’d need SDK maintainer involvement for changes that are really driven by the generator’s design.

So my intuition, at least for now, is that the best ownership model is for the protobuf-es Temporal integration to live near the codegen/tooling that drives it. That way, our team can evolve the whole user experience together, while still depending on the SDK’s stable payload-converter/plugin APIs. Once the integration has matured and the boundaries are clearer, we can always revisit whether some part of it belongs closer to the SDK itself.

If you’re open to that direction, I’d be happy to help figure out what the right contrib/plugin shape should look like.

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.

Add @bufbuild/protobuf (protobuf-es) payload converters

2 participants