# SRPC

SRPC is a typed bidirectional RPC layer over WebSocket. It uses generated protobuf message codecs, request/response prefixes, optional metadata, HMAC client authentication, reconnect handling, and multiplexed byte streams.

## Message Shape

SRPC message types must include shared envelope fields plus request/response 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;
        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;
    }
}

message PingPong {}
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;
}
```

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.

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

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 the initial `pingPong`. Registered connection handlers then run in registration order and are awaited. Only after they complete does the stream become active 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. |

The exported `SrpcClientOptions` type describes this object. `connect({ supersede?: boolean })` controls only that connection attempt and is separate from the constructor options.

Protocol-v2 and protocol-v3 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.

After the initial ping handshake, the client sends a ping every 55 seconds. 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.

## 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 uses 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. New clients must use v3. Deploy servers before clients: a v3 client requires a server that recognizes v3. 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 or protocol-v3 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, 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 every inbound and outbound envelope at info level with its direction and message type. To also log the decoded body, opt in explicitly:

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

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

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