Skip to content

Node Protobuf Messaging

Contents | Previous: Game Engine Integration | Next: Node Protobuf Codecs and Types

After reading this chapter

You can generate TypeScript message classes from .proto and receive Protobuf pushes and replies. Code comes from the runnable framework/languages/node/tutorial/StreamClient example.

When a server uses Protobuf payloads, the client needs the same .proto definitions to read the bytes. This example uses one codec to receive Ping and Pong pushes through on(Type, handler). Receiving 255 message kinds does not require 255 codecs. Pass each generated class to its handler registration. Protobuf Codecs and Types explains how types and names are selected.

1. Generating Message Code

A .proto file defines message field numbers and types. This example defines Ping, which carries a string, and Pong, which carries a number.

StreamClient/messages.proto
syntax = "proto3";
package tutorial;

// Both message types are received through one connector codec.
message Ping { string text = 1; }
message Pong { uint32 rank = 1; }

Install the dependencies and run the generation command from framework/languages/node/tutorial/StreamClient. The protobufjs-cli static-module target generates a constructor for each message that exists at runtime.

npm ci
npm run generate:protobuf

The command uses pbjs -t static-module -w commonjs to create generated/messages.cjs and pbts to create generated/messages.d.cts. The .cjs extension loads the generated module as CommonJS in this ESM project. These files are regenerated during the build.

2. Configuring the Codec

Import the generated messages and the browser entry point of @zlink-systems/framework-codec-protobuf. The server serializer entry point, ./framework, is not used in the client.

StreamClient/protobuf.ts
import { createZlinkStreamProtobufCodec } from '@zlink-systems/framework-codec-protobuf';
import {
  ZlinkStreamDispatchMode,
  zlinkStreamConnectorFactory,
  type ZlinkStreamMessage
} from '@zlink-systems/stream-connector';
import messages from './generated/messages.cjs';

const { Ping, Pong } = messages.tutorial;

Configure one codec when creating the connector. The configured Ping is the fallback when no receiving type is supplied. Encoding uses the generated instance type; typed receiving uses the handler constructor. The endpoint is the WebSocket address of a server using Protobuf.

StreamClient/protobuf.ts
// Ping is the fallback when no runtime decoding type is supplied.
const connector = zlinkStreamConnectorFactory.create({
  endpoint,
  codec: createZlinkStreamProtobufCodec(Ping),
  dispatchMode: ZlinkStreamDispatchMode.Immediate
});

3. Receiving — Push Handlers

on(Ping, handler) supplies Ping as the decoding type; on(Pong, handler) supplies Pong. The same codec invokes each constructor's decode, so handlers receive instances of different types. The example also shows an explicit packet name and a name-only registration using the fallback type.

↗ Open larger

StreamClient/protobuf.ts
// The constructor supplies the packet name and runtime decoding type.
const byType = connector.on<messages.tutorial.Ping>(Ping, handlePing);
// An explicit wire name can differ from the generated class name.
const byNameAndType = connector.on<messages.tutorial.Ping>('Ping', handlePing, Ping);
// Without a runtime type, the codec falls back to Ping.
const byName = connector.on<messages.tutorial.Ping>('Ping', handlePing);
// The same codec decodes Pong with the handler's generated constructor.
const byPong = connector.on<messages.tutorial.Pong>(Pong, (message) => {
  if (!(message.payload instanceof Pong)) {
    rejectPong(new Error('Pong handler received a payload with the wrong generated type.'));
    return;
  }
  resolvePong(message.payload.rank);
});

The server's packet name is Ping, so registrations taking the Ping constructor or an explicit name receive the same push. Use the named form when the generated class name differs from the wire name.

4. Sending — Send and Request

Use a generated class instance to send the message received by the handlers above. The connector derives the packet name from the constructor, and the codec calls that constructor's encode.

StreamClient/protobuf.ts
// Use a generated instance so its constructor supplies the packet name.
await connector.send(new Ping({ text: 'hello' })).submit();
// The peer sends both push types after receiving Ping.
const [, pushRank] = await Promise.all([pushed, pongPushed]);
await connector.send(new Pong({ rank: 3 })).submit();

Receive a Pong reply with submit(Pong). The request builder passes the generated class to the existing codec's decode(payload, Pong) and returns a Pong instance. Pass a cancellation signal with submit(Pong, signal).

↗ Open larger

StreamClient/protobuf.ts
// Pass the generated constructor to the existing codec's decode method.
const reply = await connector.request(new Ping({ text: 'rank' })).submit(Pong);

For callback delivery, use submitCallback(Pong, callback). In the default dispatch mode, the callback runs when dispatch() is called. This tutorial uses Immediate mode.

StreamClient/protobuf.ts
const callbackReply = await new Promise<messages.tutorial.Pong>((resolve, reject) => {
  connector.request(new Ping({ text: 'rank' })).submitCallback(Pong, (result) => {
    if (result.isSuccess) resolve(result.value!);
    else reject(new Error(result.error!.message));
  });
});

The type argument in submit<Pong>() is erased at runtime, so use submit(Pong) to pass the generated class. The low-level alternative is to read the bytes from submitEncoded() with fromProto(encodedReply, Pong).

5. Execution Result

This command starts the StreamClient verification WebSocket peer on an ephemeral local port and calls the same tutorial function. A separate Server process is not needed. The peer sends Ping and Pong { rank: 3 } pushes and replies to requests with Pong { rank: 7 }.

cd framework/languages/node/tutorial/StreamClient
npm ci
# Before release: build the local connector and codec sources.
npm run prepare:local
npm run protobuf:check
# protobuf: Ping=hello, Pong.rank=3, reply.rank=7

Verification checks real WebSocket traffic, handler payloads for both message types, encoded bytes, and the reply type. It also checks rejection of an incorrect codec number and malformed Protobuf bytes. The verification program connects through Node's WebSocket and runs the connector's browser entry point.