# 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.                                            |
| `debug`         | Compatibility option; currently declared but not read.     |
| `logLevel`      | `info`, `debug`, or `false`.                               |
| `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,
    protocolVersion: 2
});

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. |
| `protocolVersion` | `2`     | Protocol version sent during handshake.  |

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

With protocol version 2, duplicate `clientId` connections are rejected unless `connect({ supersede: true })` is used.
Protocol version 1 retains the legacy behavior: a new connection replaces the existing connection with the same client ID even without the supersede flag. Prefer version 2 when duplicate ownership must be explicit.

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. The client sends `authv`, `appv`, `ts`, `id`, `cid`, `signature`, `_v`, optional `_supersede`, and custom metadata under `m--<key>` WebSocket query parameters.

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['m--role'] !== 'worker') return false;
    return { authorizedRole: 'worker' };
});
```

The callback receives the raw query map, including signed fields and `m--`-prefixed 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 with the `m--` prefix removed.

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.                          |
| `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 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; exceeding the count limit drops data for additional unknown IDs. 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.

## Errors And Timeouts

On the server, `SrpcError(message, true)` sets `userError` on the reply envelope. The current client rejects that reply as a plain `Error`, so callers can observe the message but not the user-error flag or `SrpcError` class. Errors thrown by client-side handlers are likewise sent without `userError`. Other thrown values are serialized as ordinary remote errors.

`invoke()` defaults to a 30-second timeout on both client and server; a per-call timeout overrides it. The server closes with `badArg` for unknown or missing request IDs and decode failures. After the initial handshake, the client also closes for unknown or missing request IDs, while an undecodable server payload is logged and ignored.

## Telemetry Boundary

The shared envelope reserves a `trace` object with `traceId`, `spanId`, and `traceFlags`, but the current SRPC client and server do not automatically populate, continue, or export spans from that field. Applications that need cross-SRPC trace propagation must place trace context in their message contract and wrap handlers with `withRemoteSpan()` explicitly. HTTP/WebSocket connection establishment can still be observed by the installed HTTP instrumentation.
