# 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.

```protobuf
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.

```ts
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

```bash
corepack yarn tsf-gen-proto resources/proto/service.proto src/generated/proto
```

Options:

| 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

```ts
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](#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

```ts
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

```ts
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](#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:

```ts
server.setClientKeyFetcher(async clientId => {
    return await lookupSecret(clientId);
});
```

Replace the built-in HMAC/key-fetcher authentication with a custom authorizer using:

```ts
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.

```ts
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

```ts
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:

```ts
logTraffic: {
    bodies: true;
}
```

To include heartbeat envelopes, use `logTraffic: { pingPong: true }`, or combine both options:

```ts
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](#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.
