SRPC
SRPC is a typed bidirectional RPC layer over WebSocket. It uses generated protobuf message codecs, request/response calls, one-way notifications, optional metadata, HMAC client authentication, reconnect handling, and multiplexed byte streams.
Message Shape
SRPC message types must include shared envelope fields plus request/response or notification payload fields.
syntax = "proto3";
message ClientMessage {
string requestId = 1;
bool reply = 2;
optional string error = 3;
optional bool userError = 4;
optional TraceContext trace = 5;
oneof request {
PingPong pingPong = 50;
ByteStreamOperation byteStreamOperation = 51;
UEchoRequest uEchoRequest = 100;
UChangedNotification uChangedNotification = 101;
DNotifyResponse dNotifyResponse = 200;
}
}
message ServerMessage {
string requestId = 1;
bool reply = 2;
optional string error = 3;
optional bool userError = 4;
optional TraceContext trace = 5;
oneof response {
PingPong pingPong = 50;
ByteStreamOperation byteStreamOperation = 51;
UEchoResponse uEchoResponse = 100;
DNotifyRequest dNotifyRequest = 200;
DChangedNotification dChangedNotification = 201;
}
}
enum PingPongType {
PING_PONG_TYPE_UNSPECIFIED = 0;
PING_PONG_TYPE_HELLO = 1;
PING_PONG_TYPE_HELLO_ACK = 2;
PING_PONG_TYPE_ACTIVATED = 3;
PING_PONG_TYPE_PING = 4;
PING_PONG_TYPE_PONG = 5;
}
message PingPong { optional PingPongType type = 1; }
message TraceContext {
string traceId = 1;
string spanId = 2;
int32 traceFlags = 3;
}
message WriteByteStream { bytes chunk = 1; }
message FinishByteStream {}
message DestroyByteStream { optional string error = 1; }
message ByteStreamOperation {
int32 streamId = 1;
oneof operation {
WriteByteStream write = 2;
FinishByteStream finish = 3;
DestroyByteStream destroy = 4;
}
}
message UEchoRequest {
string message = 1;
}
message UEchoResponse {
string message = 1;
}
message DNotifyRequest {
string event = 1;
}
message DNotifyResponse {
bool acknowledged = 1;
}
message UChangedNotification {
string message = 1;
}
message DChangedNotification {
string message = 1;
}The prefix is the method name without Request or Response. A client can invoke prefixes where its message type has <prefix>Request and the server message type has <prefix>Response. The server can invoke prefixes in the opposite direction.
Generated codecs satisfy SrpcMessageFns<T>: an encode() function returning bytes (or an object with finish()) and a decode() function accepting bytes. ClientMessage and ServerMessage generated by ts-proto provide this shape directly.
Fire And Forget Notifications
Add a <prefix>Notification field to the sending envelope, then use notify() and registerNotificationHandler(). Notifications require no corresponding response field. Payload types and prefixes are checked against the generated envelope types.
server.registerNotificationHandler('uChanged', async (stream, data) => {
await recordChange(stream.clientId, data.message);
});
client.registerNotificationHandler('dChanged', data => {
logger.info('change received', { message: data.message });
});
await client.notify('uChanged', { message: 'client changed' });
await server.notify(stream, 'dChanged', { message: 'server changed' });notify() returns Promise<void> that resolves when the local WebSocket send completes. It does not wait for the remote handler, request an acknowledgement, allocate a pending RPC, or start a reply timeout. Handle local send failures with await or .catch(); successful sending does not confirm remote delivery or processing. Notification handler failures and unregistered notification types are logged by the receiver without sending a reply or disconnecting the peer.
Notification envelopes carry exactly one notification payload and an optional trace, with an absent or empty requestId and no reply/error fields. Both peers must support notifications and include the payload fields in their codecs. Existing request/response calls continue to use invoke() and registerMessageHandler().
Server notification handlers also accept zero-argument handler classes; each notification creates a new instance. server.notify() accepts a local SrpcStream, including local streams on a MeshSrpcServer. Notifications share the existing inbound handler count/byte limits, pre-activation queue limits, and outbound message/buffer limits with requests. A client can handle server notifications during activation; the server queues incoming client notifications until activation completes.
Proto Generation
corepack yarn tsf-gen-proto resources/proto/service.proto src/generated/protoOptions:
| Flag | Description |
|---|---|
--only-types | Generates TypeScript type declarations only. |
--use-date | Uses Date for google.protobuf.Timestamp. |
--use-map-type | Uses Map for proto maps. |
PROTOC can point to a custom protoc binary. ts-proto must be installed.
Server
import { SrpcServer } from '@zyno-io/ts-server-foundation';
import { ClientMessage, ServerMessage } from './generated/proto/service';
const server = new SrpcServer({
logger,
clientMessage: ClientMessage,
serverMessage: ServerMessage,
wsPath: '/srpc'
});
server.registerConnectionHandler(async stream => {
logger.info('client connected', { clientId: stream.clientId, streamId: stream.id });
});
server.registerMessageHandler('uEcho', async (_stream, data) => {
return { message: data.message };
});
server.registerDisconnectHandler((stream, cause) => {
logger.info('client disconnected', { clientId: stream.clientId, cause });
});SrpcServer registers its WebSocket upgrade handler through the current app's app.http runtime unless an explicit httpServer is provided.
Server options:
| Option | Description |
|---|---|
logger | Logger with info, warn, error, and debug methods. |
clientMessage | Generated codec for client-to-server envelope messages. |
serverMessage | Generated codec for server-to-client envelope messages. |
wsPath | WebSocket path. |
httpServer | Optional Node HTTP server for direct upgrade registration. |
logTraffic | Optional traffic logging; heartbeats are excluded unless explicitly enabled. |
Handlers may also be zero-argument classes with a handle(stream, data) method. A new class instance is created for each request; SRPC does not resolve handler classes through application DI.
Connection setup is deliberately ordered. After authentication, the server creates a pending stream and sends pingPong with type HELLO. The client answers with HELLO_ACK. Registered connection handlers run in registration order and are awaited. Only after they complete does the stream become active, the server send ACTIVATED, and queued client requests begin. A connection-handler failure disconnects the stream before activation. Disconnect handlers run in registration order but are synchronous callbacks; returning a promise does not delay cleanup.
Server-To-Client Calls
const stream = server.streamsByClientId.get('worker-1');
if (stream) {
const result = await server.invoke(stream, 'dNotify', { event: 'reload' }, 5000);
result.acknowledged;
}SrpcServer.createInvoke(() => server) creates a stable invoke function for DI or callbacks that need to resolve the current server lazily.
Client
import { SrpcClient, SrpcConflictError } from '@zyno-io/ts-server-foundation';
import { ClientMessage, ServerMessage } from './generated/proto/service';
const client = new SrpcClient(logger, 'wss://api.example.com/srpc', ClientMessage, ServerMessage, 'worker-1', { role: 'worker' }, 'shared-secret', {
enableReconnect: true
});
client.registerConnectionHandler(() => {
logger.info('connected');
});
client.registerMessageHandler('dNotify', async data => {
return { acknowledged: true };
});
client.registerDisconnectHandler(cause => {
logger.warn('disconnected', { cause });
});
try {
await client.connect();
} catch (error) {
if (error instanceof SrpcConflictError) {
await client.connect({ supersede: true });
}
}
const echo = await client.invoke('uEcho', { message: 'hello' });
client.disconnect();Client options:
| Option | Default | Description |
|---|---|---|
enableReconnect | true | Reconnects after unexpected disconnects. |
logTraffic | false | Traffic logging; heartbeats are excluded unless explicitly enabled. |
The exported SrpcClientOptions type describes this object. connect({ supersede?: boolean }) controls only that connection attempt and is separate from the constructor options.
Protocol-v2, protocol-v3, and protocol-v4 connections reject a duplicate clientId unless connect({ supersede: true }) is used. Legacy protocol-v1 connections retain replacement behavior: a later connection with the same clientId silently supersedes the existing stream.
The optional PingPong.type enum distinguishes handshake messages from client pings and server pongs. Generated TypeScript codecs represent it as a numeric PingPongType enum with the prefixed names shown above and an optional type property (undefined when absent). The library exports matching numeric values as SrpcPingPongType.
| Type | Value | SrpcClient behavior |
|---|---|---|
UNSPECIFIED or absent | 0 | Legacy sequence: answer the first ping, connect on the next, then only refresh liveness. |
HELLO | 1 | Answer with HELLO_ACK; wait for activation. |
HELLO_ACK | 2 | Sent by the client to acknowledge HELLO. |
ACTIVATED | 3 | Complete activation without answering. |
PING | 4 | Sent by the client to check liveness. |
PONG | 5 | Refresh liveness without answering or completing activation. |
An explicit ACTIVATED completes activation even without a prior HELLO. Duplicate HELLO messages receive HELLO_ACK responses and do not complete activation or repeat connection callbacks. All pingPongs refresh liveness; inbound HELLO_ACK, PING, and unknown future nonzero types do nothing else on the client. If liveness messages precede the handshake, the client still answers the first untyped ping and waits for the next untyped ping or ACTIVATED. The DevConsole browser client also waits for typed ACTIVATED, while preserving its existing first-ping connection behavior with untyped servers.
After activation, the client sends PING every 55 seconds. The server updates inbound liveness for every pingPong and answers typed PING and legacy untyped or UNSPECIFIED messages with PONG only after activation, regardless of the reply flag. It does not answer other nonzero types. Server pingPongs and client pings carry no requestId or reply: true flag. A connection with no pong for 75 seconds closes with the timeout cause. The server checks the same 75-second inactivity window every 15 seconds. Unexpected client disconnects reconnect after one second when enableReconnect is true; disconnect(), conflicts, and an explicit enableReconnect: false suppress reconnection. triggerConnectionCheck() forces an immediate PING-based liveness check.
This is wire-compatible with empty legacy PingPong messages. Old protobuf decoders ignore the new field, and new decoders accept empty messages. Applications own their envelope schemas and pass their generated codecs to sRPC: add the enum and optional field to each application's proto and regenerate with tsf-gen-proto to send or read the type. An old server-side codec ignores the in-memory type during encoding, so even an upgraded library sends empty pingPongs until that codec is regenerated. Existing peers can continue using the legacy handshake without regeneration. Custom codecs must likewise accept the optional field or ignore it.
Authentication
Clients sign connection metadata with HMAC-SHA256. New clients send pv=3, appv, ts, nonce, aud, id, cid, signature, optional cap capabilities and supersede, and custom metadata as ordinary WebSocket query parameters. Every query parameter other than those transport fields becomes stream.meta. The canonical signature covers the request path, audience, protocol version, capabilities, and normalized metadata.
Servers require an explicit pv by default, but accept _v when pv is absent for compatibility. The protocol version selects the authentication format: v1/v2 use the legacy HMAC format and v3/v4 use the canonical HMAC format. Legacy _supersede and m--<key> metadata are accepted; _supersede is used only when supersede is absent, and m--<key> is normalized to <key> in stream.meta with unprefixed metadata taking precedence. Set defaultUnspecifiedProtocolVersion to 1, 2, or 3 only while migrating an unmarked client. Protocol v2 continues to use its original signature (1, appv, ts, id, cid), whose stream ID is consumed once for replay protection and whose capability field is ignored. Clients use v3 by default. Set SrpcClientOptions.protocolVersion: 4 after both envelope codecs and the server support receipts. The canonical signature binds the advertised version. Deploy servers before clients: v4 requires a server that recognizes v4. An explicit pv=1 always selects v1 behavior; use it only while supporting a legacy client that intentionally relies on same-clientId replacement.
By default the server verifies signatures with SRPC_AUTH_SECRET. Provide per-client secrets with:
server.setClientKeyFetcher(async clientId => {
return await lookupSecret(clientId);
});Replace the built-in HMAC/key-fetcher authentication with a custom authorizer using:
server.setClientAuthorizer(async (query, request) => {
if (!(await verifyCustomHandshake(query, request))) return false;
if (query.role !== 'worker') return false;
return { authorizedRole: 'worker' };
});The callback receives the raw query map as Record<string, string>, including signed fields and custom metadata. Once configured, it is responsible for all authentication; the default HMAC validation and setClientKeyFetcher() path are not also run. Returning false rejects the connection, true accepts it, and an object accepts the connection and merges that object into normalized stream.meta alongside custom metadata.
Clock drift is controlled by SRPC_AUTH_CLOCK_DRIFT_MS, defaulting to 30 seconds.
Streams
Connected streams expose metadata:
| Field | Description |
|---|---|
id | Server-side stream ID. |
clientStreamId | Client-generated stream ID. |
clientId | Client identity. |
appVersion | Client app version query value. |
protocolVersion | Handshake protocol version. |
capabilities | Client capabilities negotiated during the handshake. |
supersede | Whether the connection requested supersede behavior. |
meta | Query metadata plus authorizer metadata. |
connectedAt | Connection timestamp. |
lastPingAt | Last ping timestamp. |
streamsById contains authenticated pending and active streams, while streamsByClientId contains only the active stream for each client ID. Connection handlers run while the stream is still pending.
The exported SrpcStream<TMeta> type describes these server-side streams. It also exposes the underlying WebSocket and request queue through $ws and $queue; treat those fields as low-level protocol surfaces and prefer SrpcServer.invoke(), handlers, and SrpcByteStream for application work.
Established-stream disconnect callbacks receive disconnect, supersede, timeout, or badArg. A rejected protocol-v2, protocol-v3, or protocol-v4 duplicate closes with the conflict wire cause and rejects the connecting client with SrpcConflictError before activation, so connection and disconnect callbacks do not run for that rejected stream. supersede identifies a replaced connection, timeout identifies ping inactivity or an unacknowledged v4 request deadline, and badArg identifies malformed or out-of-sequence messages. Outstanding invocations reject when their stream disconnects.
Byte Streams
SrpcByteStream multiplexes Node Duplex streams over an SRPC connection.
import { SrpcByteStream } from '@zyno-io/ts-server-foundation';
const sender = SrpcByteStream.createSender(stream);
sender.write(Buffer.from('chunk'));
sender.end();
const receiver = SrpcByteStream.createReceiver(stream, senderId);
receiver.on('data', chunk => {
// handle chunk
});Byte stream operations are carried in the SRPC envelope byteStreamOperation field. Pending receiver data is bounded and expires if a receiver is never attached.
Data that arrives before createReceiver() is buffered for at most five seconds. A pending receiver is limited to 2 MiB, all pending receivers on one parent stream share a 2 MiB total limit, and at most 1,024 pending receiver IDs are retained. Exceeding a byte limit turns that pending receiver into an error. An operation for a 1,025th unknown ID is a deterministic resource/protocol failure that fences the parent SRPC transport; write, finish, and destroy are never silently dropped. A remote destroy is retained independently from its optional reason, so destroy(undefined) remains a clean terminal operation when the receiver later attaches and is not echoed back to the peer. These are protocol safety limits, not configurable application buffering.
Observers
import { registerSrpcObserver } from '@zyno-io/ts-server-foundation';
const stop = registerSrpcObserver(entry => {
console.log(entry.type, entry.at);
});Observers receive connection entries with { type, stream, at }, disconnection entries with { type, stream, cause, at }, and message entries with { type, stream, direction, data, at }. Message direction is relative to the server. Ping, byte-stream, request, and reply envelopes are observable. Observer exceptions are isolated from SRPC behavior. Call the returned function to unregister the observer.
Traffic Logging
Set logTraffic: true on an SrpcServer or SrpcClient to log inbound and outbound envelopes at info level with their direction and message type, excluding pingPong-only heartbeat envelopes. Heartbeat logging defaults to off, including when body logging is enabled. To also log the decoded body, opt in explicitly:
logTraffic: {
bodies: true;
}To include heartbeat envelopes, use logTraffic: { pingPong: true }, or combine both options:
logTraffic: {
pingPong: true,
bodies: true
}Only envelopes whose sole payload is pingPong are suppressed, including heartbeat pongs with reply: true. Envelope metadata such as requestId and trace, and undefined fields from generated codecs, do not prevent heartbeat suppression. Envelopes containing pingPong alongside another payload, byteStreamOperation, or a defined error or userError remain logged so other traffic is visible, including with codecs that allow multiple payload fields. These options affect only traffic logs; heartbeat handling, activation, and observer notifications are unchanged.
Traffic bodies can contain application data, so enable body logging only where that data is appropriate for the configured log sink and retention policy.
Observability
SRPC propagates the envelope trace context in both directions. Server and client invocation spans are named srpc:invokeClient and srpc:invokeServer; request handlers continue those traces as srpc:handleClientRequest and srpc:handleServerRequest.
Notification send spans are named srpc:notifyClient and srpc:notifyServer; receiving handlers continue their traces as srpc:handleClientNotification and srpc:handleServerNotification. Notification logs include notificationType in the srpc context.
Logs emitted while invoking or handling a request include an srpc context with the client and stream IDs, request ID, request type, and trace ID when present. Connection, handshake, protocol, and byte-stream diagnostics use the same structured identifiers without logging application payloads. Configure verbosity on the supplied logger, or use Traffic Logging when message bodies are appropriate for the log sink.
Errors And Timeouts
Throw new SrpcError(message, true) from either a server handler or a client-side handler to mark an expected user-facing failure. The reply envelope preserves the message and userError flag in both directions, and the invoking peer rejects with an SrpcError whose isUserError value is retained. Other thrown values are serialized as ordinary remote errors and do not acquire the user-error flag.
invoke() defaults to a 30-second timeout on both client and server; a per-call timeout overrides it. Both peers close with badArg for unknown or missing request IDs on request/reply envelopes, decode failures, invalid protocol frames, and outgoing WebSocket backpressure. Before initiating that close, the peer synchronously revokes the affected stream generation: buffered frames can no longer dispatch, delayed handlers and byte-stream writes cannot respond, request and pressure accounting is cleared, stream.connected becomes false, new byte streams are rejected, and close-event cleanup is idempotent. The server applies maxMessageBytes to outbound synchronous responses, asynchronous responses, and byte-stream frames as well as inbound frames. A definitive WebSocket send throw or callback error also revokes before closing. Normal graceful disconnects retain their ordinary close-event semantics and carry the supplied bounded close reason.
Protocol v4 adds receipt acknowledgments: { requestId, requestAck: true } contains no reply, error, or application payload. Servers acknowledge admitted requests before dispatch. Clients race their final response against a three-second receipt delay: a result or error within that window suppresses the receipt; otherwise the client sends one receipt while the handler continues. Pending receipts are canceled when the connection ends. A receipt confirms transport admission, not authorization, success, or completion. Replies and errors continue to use reply: true. Notifications receive no acknowledgment.
For server-to-client calls, disconnectOnRequestTimeout defaults to true, but applies only to explicitly negotiated v4 calls whose timeout budget exceeds the three-second receipt window. Shorter calls still time out normally, but cannot establish that a client has missed its receipt deadline. For eligible calls, if the RPC deadline expires without either a receipt or a final response, the existing timer synchronously revokes the exact stream with cause timeout and close code 4003, before the close handshake or inactivity timer completes. Normal cleanup rejects other pending calls and runs disconnect callbacks once. A replacement generation is preserved. Setting the option to false disables this policy; v1–v3 retain their prior timeout behavior even when it is enabled.
A receipt does not resolve a call, extend its deadline, release its queue accounting, or request a retry. If an acknowledged handler takes too long, invoke() still rejects at its ordinary deadline but the session remains usable. Late receipts and results for expired calls are ignored for the normal bounded tombstone period. A final response before receipt also completes the call normally.
Both envelope codecs must preserve requestAck before a client advertises v4. For protobuf, add bool requestAck (or optional bool requestAck) using an unused field number in each message container and regenerate bindings. TSF's DevConsole schema and the RMM bridge schema reserve field 9 for this purpose. A v4 client or server refuses negotiation when its codecs drop the field. SrpcClient stays on v3 by default so upgrading TSF alone does not advertise a wire version unsupported by an existing server or codec. Versionless clients cannot acquire v4 through defaultUnspecifiedProtocolVersion.
The policy also applies to MeshSrpcServer: the physical connection owner tracks receipts and enforces its existing local RPC deadline. Local, forwarded typed, and service calls share this path. Owner-side expiry continues if the requesting node disappears. Routing or peer-link deadlines alone do not revoke the client, and mesh registry removal uses the existing ownership-safe cleanup.
Delivery remains indeterminate on timeout, with or without a receipt. A receipt does not prove an operation ran, and a missing receipt does not prove it did not. Commands are not automatically retried.