Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,9 @@ jobs:
typescript-repo-path: ${{github.event.pull_request.head.repo.full_name}}
version: ${{github.event.pull_request.head.ref}}
version-is-repo-ref: true
features-repo-ref: main
# FIXME: Remove this once 1) our protobufjs v8 bump has been merged and included in a
# release, and 2) https://github.com/temporalio/features/pull/863 has been merged to main.
features-repo-ref: bump-protobufjs
Comment thread
mjameswh marked this conversation as resolved.

stress-tests-no-reuse-context:
name: Stress Tests (No Reuse V8 Context)
Expand Down
2 changes: 1 addition & 1 deletion .npmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
node-options=--max-old-space-size=8192
node-options=--max-old-space-size=12288
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,42 @@ to docs, or any other relevant information.

## [Unreleased]

### Breaking Changes

- Major upgrade of `protobufjs` to v8 and `protobufjs-cli` to v2.

Temporal's protobuf messages types generated by `protobufjs` are exposed as part of our public
API surface (mostly through `@temporalio/proto` and `Client`'s raw gRPC interfaces). This
upgrade introduces some minor changes in the `protobuf` types that are generated for them. The
large majority of applications either don't reference those types directly or do not use those
types in way that would be affected by these changes.

You may however want to consider the following user-facing and potentially breaking changes:

- Applications that use the Protobuf Payload Converters with their own `.proto` files will need
to upgrade their own `protobufjs` dependency to v8. A root built with v7 still works for binary
payloads, but JSON-encoded payloads will fail at runtime with `TypeError: type must be a Type`.

- The constructor on generated message types is no longer publicly accessible, which means that
`new SomeMessage()` no longer compiles. Use `SomeMessage.create({ ... })` instead. Use of the
constructor was never officially supported, and would have previously resulted in a runtime error.

- The `getTypeUrl` method on generated message types is no longer declared.

- A field holding its default value is no longer materialized as an own property on decoded
messages. This applies both to JSON-encoded payloads, which are now spec-compliant and _omit_
such fields rather than writing them out, and to binary ones, since `protobufjs` v8 no longer
sets them when decoding. Reading the field still yields its default (`msg.someEnum` is still
`0`), but it no longer shows up in `Object.keys`, object spreads, or a deep equality comparison
against a message built with `create({ someEnum: 0 })`. Unknown fields are still ignored when
parsing JSON.

- `protobufjs` renamed the generated "properties" interfaces from `IFoo` to `Foo.$Properties`,
and added a companion `Foo.$Shape` that narrows `oneof` fields. We still expose `IFoo` as an
alias of `Foo.$Properties` preserving backward compatibility with type definitions that rely
on the legacy spelling. Note that we may choose to deprecate the legacy spelling at some point
in the future to encourage adoption of protobufjs' roadmap.

### Added

- **Experimental**: Workflow Clients can now use `TypeInfo` to encode Workflow inputs and decode Workflow results.
Expand Down
33 changes: 24 additions & 9 deletions docs/protobuf-libraries.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ A and B, but not C.
- [Non-standard](https://github.com/protobufjs/protobuf.js/issues/1304) JSON serialization
- Message classes with generated types and runtime-checkable instances

**Update as of Aug '26:**

- `protobufjs` now covers C as well. Since 8.6.0 it bundles
`protobufjs/ext/protojson`, a spec-compliant ProtoJSON converter derived from `proto3-json-serializer`.
- Its own `toJSON`/`fromJSON` remain non-standard — `protojson` is a separate API alongside them, not a
fix to them.
- Project activity has also picked up considerably since the original survey.

### proto3-json-serializer

C
Expand All @@ -23,6 +31,11 @@ C
- Maintained by responsive Googlers, 900k downloads/wk
- Requires runtime-loaded messages (not compatible with generated classes)

**Update as of Aug '26:**

- `protobufjs` v8.6.0 introduced its own API (`protobufjs/ext/protojson`) which is derived from `proto3-json-serializer` library, so it cannot fall behind the protobufjs version we depend on.
- `proto3-json-serializer` still declares `protobufjs: ^7.5.4` and does not support v8.

### google-protobuf

B
Expand Down Expand Up @@ -53,6 +66,13 @@ A and B
- Have users use runtime-loaded messages (not generated classes) and `Class.create` (not `new Class()`, which doesn't work with runtime-loaded messages)
- Patch `json-module` output (which adds `nested` attributes to lowercase namespaces [which causes a TS error](https://github.com/protobufjs/protobuf.js/issues/1014))

**Update as of Aug '26:**

- The approach remains mostly unchanged, save for a few implementation details:
- Spec-compliant JSON comes from `protobufjs/ext/protojson` instead of `proto3-json-serializer`.
- `Class.create` is now enforced rather than merely recommended: we generate the declarations with
`pbts --no-constructor`, so `new Class()` is a compile error instead of a runtime surprise.

```ts
// json-module.js generated with:
// pbjs -t json-module -w commonjs -o json-module.js *.proto
Expand Down Expand Up @@ -111,13 +131,8 @@ function myWorkflowError(input: root.foo.bar.ProtoActivityInput) {

On root in `root.foo.bar.ProtoActivityInput`, TS errors: `Cannot find namespace 'root'.`

## Future work

If we can get changes merged into `protobufjs` (or want to fork), we can do one or both of the below:

1. Change the `json-module` output to not have `nested` attributes so we don't have to patch
2. Add to the generated classes:
## Future work (as of Aug '26 - protobufjs v8.7.1)

- spec-compliant `to/fromJSON` methods
- `typename` field that includes the namespace (eg `"foo.bar.MyMessage"`)
- "this is a generated file" comment @ top
- Considerably reduce the generated source file sizes (root.d.ts is currently 8.8MB; json-module.js is 1.3MB)
- Make it easier for user to package their own protobuf files (maybe provide a CLI for that?)
- Consider support of different protobuf libraries for user payloads
5 changes: 0 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,5 @@
"version": "10.27.0",
"onFail": "warn"
}
},
"pnpm": {
"patchedDependencies": {
"protobufjs@^7.6.4": "patches/protobufjs@7.6.2.patch"
}
}
}
4 changes: 2 additions & 2 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@
"@temporalio/common": "workspace:*",
"@temporalio/proto": "workspace:*",
"abort-controller": "^3.0.0",
"long": "^5.2.3",
"long": "^5.3.2",
"nexus-rpc": "^0.0.2",
"uuid": "^11.1.0"
},
"devDependencies": {
"protobufjs": "^7.6.4"
"protobufjs": "^8.7.1"
},
"bugs": {
"url": "https://github.com/temporalio/sdk-typescript/issues"
Expand Down
2 changes: 1 addition & 1 deletion packages/cloud/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"@temporalio/proto": "workspace:*"
},
"devDependencies": {
"protobufjs": "^7.6.4"
"protobufjs": "^8.7.1"
},
"bugs": {
"url": "https://github.com/temporalio/sdk-typescript/issues"
Expand Down
7 changes: 3 additions & 4 deletions packages/common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
"license": "MIT",
"dependencies": {
"@temporalio/proto": "workspace:*",
"long": "^5.2.3",
"long": "^5.3.2",
"ms": "3.0.0-canary.1",
"nexus-rpc": "^0.0.2",
"proto3-json-serializer": "^2.0.0"
"protobufjs": "^8.7.1"
},
"scripts": {
"build": "tsc --build",
Expand All @@ -28,8 +28,7 @@
"workerThreads": false
},
"devDependencies": {
"ava": "^5.3.1",
"protobufjs": "^7.6.4"
"ava": "^5.3.1"
},
"bugs": {
"url": "https://github.com/temporalio/sdk-typescript/issues"
Expand Down
7 changes: 4 additions & 3 deletions packages/common/src/converter/payload-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,9 +392,10 @@ export class JsonPayloadConverter implements PayloadConverterWithEncoding {
}

export class DefaultPayloadConverter extends CompositePayloadConverter {
// Match the order used in other SDKs, but exclude Protobuf converters so that the code, including
// `proto3-json-serializer`, doesn't take space in Workflow bundles that don't use Protobufs. To use Protobufs, use
// {@link DefaultPayloadConverterWithProtobufs}.
// Match the order used in other SDKs, with the exception of the Protobuf converter, which is
// excluded by default to avoid the significant bundle size increase it introduces.
//
// To use `protobufjs` for Payload conversion, use {@link DefaultPayloadConverterWithProtobufs}.
//
// Go SDK:
// https://github.com/temporalio/sdk-go/blob/5e5645f0c550dcf717c095ae32c76a7087d2e985/converter/default_data_converter.go#L28
Expand Down
137 changes: 77 additions & 60 deletions packages/common/src/converter/protobuf-payload-converters.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as protoJsonSerializer from 'proto3-json-serializer';
import type { Message, Namespace, Root, Type } from 'protobufjs';
import * as protojson from 'protobufjs/ext/protojson';
import * as protobufjslight from 'protobufjs/light';
import type { Message, Root, Type } from 'protobufjs';
import { decode, encode } from '../encoding';
import { PayloadConverterError, ValueError } from '../errors';
import type { Payload } from '../interfaces';
Expand All @@ -14,7 +15,23 @@ import {

import { encodingTypes, METADATA_ENCODING_KEY, METADATA_MESSAGE_TYPE_KEY } from './types';

const GLOBAL_BUFFER = globalThis.constructor.constructor('return globalThis.Buffer')();
/**
* `protobufjs` optimizes allocations of `bytes` fields using `Buffer.allocUnsafe()`
* instead of `Uint8Array` if the global `Buffer` class exists. `Buffer.allocUnsafe()`
* carves small allocations out of a shared pool slab (of 64KB as of Node 24); the
* slab is retained as long as any one payload is reachable, which may effectively leak
* memory if payloads are not garbage collected within a reasonable timeframe, and pose
* other subtle issues (i.e. `Buffer` and `Uint8array` differ under `JSON.stringify`
* and deep equality). For those reasons, we walk through objects decoded by `protobufjs`
* and replace `Buffer`s with `Uint8Array`s.
*
* That fix adds unnecessary overhead in the very common case of decoding payloads
* inside the Workflow sandbox, as Node's `Buffer` class is known to be not available
* (protobufjs explicitly rejects `Buffer` polyfills).
*
* @hidden
*/
const PROTOBUFJS_MAY_ALLOCATE_BUFFERS = protobufjslight.util.Buffer != null;

abstract class ProtobufPayloadConverter implements PayloadConverterWithEncoding {
protected readonly root: Root | undefined;
Expand Down Expand Up @@ -123,70 +140,71 @@ export class ProtobufJsonPayloadConverter extends ProtobufPayloadConverter {
return undefined;
}

const hasBufferChanged = setBufferInGlobal();
try {
const jsonValue = protoJsonSerializer.toProto3JSON(value);

return this.constructPayload({
messageTypeName: getNamespacedTypeName(value.$type),
message: encode(JSON.stringify(jsonValue)),
});
} finally {
resetBufferInGlobal(hasBufferChanged);
}
const jsonValue = protojson.toJson(value.$type, value);

return this.constructPayload({
messageTypeName: getNamespacedTypeName(value.$type),
message: encode(JSON.stringify(jsonValue)),
});
}

public fromPayload<T>(content: Payload): T {
const hasBufferChanged = setBufferInGlobal();
try {
const { messageType, data } = this.validatePayload(content);
const res = protoJsonSerializer.fromProto3JSON(messageType, JSON.parse(decode(data))) as unknown as T;
if (Buffer.isBuffer(res)) {
return new Uint8Array(res) as any;
}
replaceBuffers(res);
return res;
} finally {
resetBufferInGlobal(hasBufferChanged);
}
const { messageType, data } = this.validatePayload(content);
const res = protojson.fromJson(messageType, JSON.parse(decode(data)), {
ignoreUnknownFields: true,
}) as unknown as T;
return replaceBuffers(res);
}
}

function replaceBuffers<X>(obj: X) {
const replaceBuffersImpl = <Y>(value: any, key: string | number, target: Y) => {
if (Buffer.isBuffer(value)) {
// Need to copy. `Buffer` manages a pool slab, internally reused when Buffer objects are GC.
type T = keyof typeof target;
target[key as T] = new Uint8Array(value) as any;
} else {
replaceBuffers(value);
}
};

if (obj != null && typeof obj === 'object') {
// Performance optimization for large arrays
if (Array.isArray(obj)) {
obj.forEach(replaceBuffersImpl);
} else {
for (const [key, value] of Object.entries(obj)) {
replaceBuffersImpl(value, key, obj);
}
/**
* Recursively replace the `Buffer`s that `protobufjs` may have allocated for
* `bytes` fields with plain `Uint8Array`s; see {@link PROTOBUFJS_MAY_ALLOCATE_BUFFERS}.
*/
function replaceBuffers<X>(value: X): X {
if (PROTOBUFJS_MAY_ALLOCATE_BUFFERS) {
if (isBuffer(value)) {
return new Uint8Array(value) as unknown as X;
}
replaceBuffersInChildren(value);
}
return value;
}

function setBufferInGlobal(): boolean {
if (typeof globalThis.Buffer === 'undefined') {
globalThis.Buffer = GLOBAL_BUFFER;
return true;
function replaceBuffersInChildren(obj: unknown): void {
// Bail on binary leaves; descending into one would visit it a byte at a time
if (obj == null || typeof obj !== 'object' || ArrayBuffer.isView(obj)) return;

// Indexing rather than Object.entries() is a performance optimization for large arrays
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
const child = obj[i];
if (isBuffer(child)) obj[i] = new Uint8Array(child);
else replaceBuffersInChildren(child);
}
} else {
const record = obj as Record<string, unknown>;
for (const [key, child] of Object.entries(record)) {
if (isBuffer(child)) record[key] = new Uint8Array(child);
else replaceBuffersInChildren(child);
}
}
return false;
}

function resetBufferInGlobal(hasChanged: boolean): void {
if (hasChanged) {
delete (globalThis as any).Buffer;
}
function isBuffer(value: unknown): value is Buffer {
// Can't use `Buffer` as a function here (i.e. `instanceof Buffer`)
// because that would fail in the Workflow sandbox.
if (!isRecord(value)) return false;

// Resolving `isBuffer` through the value's own `constructor` keeps this correct
// for `Buffer`s created in another realm, and avoids referencing the `Buffer`
// global, which is undefined in the Workflow sandbox.
const maybeBufferConstructor = value.constructor as BufferConstructor | undefined;
return (
maybeBufferConstructor?.name === 'Buffer' &&
typeof maybeBufferConstructor?.isBuffer === 'function' &&
maybeBufferConstructor?.isBuffer?.(value) === true
);
}

function isProtobufType(type: unknown): type is Type {
Expand All @@ -206,12 +224,11 @@ function isProtobufMessage(value: unknown): value is Message {
return isRecord(value) && hasOwnProperty(value, '$type') && isProtobufType(value.$type);
}

function getNamespacedTypeName(node: Type | Namespace): string {
if (node.parent && !isRoot(node.parent)) {
return getNamespacedTypeName(node.parent) + '.' + node.name;
} else {
return node.name;
}
function getNamespacedTypeName(type: Type): string {
// protobufjs qualifies names from the root down, e.g. `.temporal.api.common.v1.Payload`.
// The leading dot is not part of the name that goes on the wire.
const { fullName } = type;
return fullName.charAt(0) === '.' ? fullName.slice(1) : fullName;
}

function isRoot(root: unknown): root is Root {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export class ExternalStorageRunner {
// ============================================================================

function payloadProtoSize(payload: Payload): number {
return PayloadProto.encode(payload).len;
return PayloadProto.encode(payload).pos;
}

/**
Expand Down
Loading
Loading