Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ type ProtocolError = UncaughtError | InvalidRequestError | CancelError;

`ProtocolError`s, just like service-level errors, are wrapped with a `Result`, which is further wrapped with `TransportMessage` and MUST have a `StreamCancelBit` flag. Please note that these are separate from user-defined errors, which should be treated just like any response message.

There are 4 `Control` payloads:
There are 6 `Control` payloads:

```ts
// Used in cases where we want to send a close without
Expand Down Expand Up @@ -241,11 +241,28 @@ interface ControlHandshakeResponse {
};
}

// Sent by the server to ask the client to re-handshake — re-construct its
// handshake metadata (e.g. fetch a fresh token) over the live connection. Sent
// on the reserved `rehandshake` streamId with no control flags.
interface ControlRehandshakeRequest {
type: 'REHANDSHAKE_REQ';
}

// Sent by the client in response to a ControlRehandshakeRequest, carrying
// freshly constructed handshake metadata for the server to re-validate. Sent on
// the reserved `rehandshake` streamId with no control flags.
interface ControlRehandshakeResponse {
type: 'REHANDSHAKE_RESP';
metadata?: unknown;
}

type Control =
| ControlClose
| ControlAck
| ControlHandshakeRequest
| ControlHandshakeResponse;
| ControlHandshakeResponse
| ControlRehandshakeRequest
| ControlRehandshakeResponse;
```

`Control` is a payload that is wrapped with `TransportMessage`.
Expand Down Expand Up @@ -305,6 +322,7 @@ When a message is received, it MUST be validated before being processed.

- Match the JSON schema for the `TransportMessage` type.
- Have an existing session for the transport `clientId` in the `from` field (see the 'Transports, Sessions, and Connections' heading for more information on sessions and transports).
- The `from` field MUST match the authenticated peer of the session/connection that delivered the message (the identity established at handshake). A message whose `from` names a different client is a protocol violation — it MUST NOT be processed, and the delivering connection MUST be torn down. Without this check a connected client could spoof `from` to act as another client (using that client's metadata/identity).
- The `to` field of the message MUST match the transport's `clientId`.
- Have the expected `seq` number (see the 'Handling Transparent Reconnections' heading for more information on seq/ack).
- Is not an explicit heartbeat (i.e. the `AckBit` is not set).
Expand Down Expand Up @@ -583,6 +601,18 @@ The server will send an error response if either:

When the client receives a status with `ok: false`, it should consider the handshake failed and close the connection.

### Re-handshaking (live credential refresh)

Handshake metadata (e.g. an auth token) can be refreshed over an already-established connection without dropping the session — a "follow-up handshake". This lets a server keep long-lived sessions alive across short-lived credentials.

The exchange reuses the heartbeat mechanism: control messages on the reserved `rehandshake` streamId that update transport-level bookkeeping like any other message but are consumed by the transport and never surfaced to procedure handlers.

1. The server sends a `ControlRehandshakeRequest` to ask the client to re-handshake. When to do this is up to the server (e.g. shortly before a token's expiry).
2. The client re-runs the same metadata construction it used during the original handshake and replies with a `ControlRehandshakeResponse` carrying the new metadata.
3. The server re-validates the metadata exactly as it would during a handshake. On success it replaces the metadata stored for the session (which the application surfaces to its handlers); on failure (malformed, rejected, or no response within the handshake timeout) it tears the session down.

Because the metadata is re-validated on every (re)handshake as well, the re-handshake schedule naturally re-establishes itself after a transparent reconnect.

### Transparent reconnections

River handles disconnections and reconnections in a transparent manner wherever possible when
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,38 @@ async handler(ctx, ...args) {
}
```

#### Re-handshaking (refreshing handshake metadata)

For long-lived sessions with short-lived credentials (e.g. a JWT), the server can ask a connected client to re-handshake — a follow-up handshake that refreshes its metadata without dropping the session. The client re-runs the same `construct` function it used during the original handshake, and the server re-runs `validate` on the result and replaces the stored metadata.

`ctx.metadata` is live: a handler that re-reads it (including a long-running stream or subscription that was already in flight when the re-handshake happened) observes the new value. This is the point of re-handshaking — the operations that outlive a token are exactly the ones that need the new token. If you want a value fixed for the lifetime of a call, destructure it once (e.g. `const { token } = ctx.metadata`). Because `validate` receives the previous parsed metadata, it can enforce that a re-handshake stays the same principal.

A re-handshake can be triggered manually from the server transport:

```ts
serverTransport.requestRehandshake('client-id');
```

More commonly, let the server schedule re-handshakes automatically by passing a third argument to `createServerHandshakeOptions` — a `Date` for when the credential expires. The server re-handshakes shortly before it:

```ts
createServerHandshakeOptions(
handshakeSchema,
(metadata) => ({
parsedToken: metadata.token,
expiresAt: getExpiry(metadata.token),
}),
// when this credential expires
(parsed) => parsed.expiresAt,
);
```

The server fires the re-handshake one `handshakeTimeoutMs` before the expiry you return, so the exchange resolves by then: either the refresh lands, or — if the client never answers — its deadline elapses and the session is torn down. Net effect: the session never serves past expiry. (This assumes `handshakeTimeoutMs` is comfortably shorter than the credential's lifetime, which holds for any realistic token.)

If the client fails to return valid metadata (rejected by `validate`, malformed, or no response before the handshake timeout), the server tears the session down. The client then reconnects with a fresh handshake, which re-establishes the schedule.

Re-handshaking is scheduling, not request gating — it doesn't pause in-flight requests — so still reject already-expired credentials in `validate` and/or by checking the live `ctx.metadata` in your handlers.

## Protobuf Services (Experimental)

River also supports defining services using Protocol Buffers. Instead of TypeBox schemas, you define your service in a `.proto` file and use the generated descriptors directly.
Expand Down
Loading
Loading