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
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const { api, destroy: stopClient } = client<AppRouter>(clientChannel, { auth });
const { message } = await api.greet({ name: "World" }); // input & output typed
```

`client()` and `server()` are synchronous. No top-level `await`. The handshake runs lazily on the first procedure call. If the session drops, the failed call surfaces a typed error (`TIMEOUT` / `CHANNEL`) and the next call re-handshakes the client never silently resends (that would double-execute non-idempotent handlers). Transport death alone doesn't touch the session: a reconnecting adapter (see `@dotex/saferpc/channels`) lets in-flight calls complete across a socket blip. Per-call `AbortSignal` (`api.thing(input, { signal })`) and `abortPending()` cancel waiting without tearing anything down.
`client()` and `server()` are synchronous. No top-level `await`. The handshake runs lazily on the first procedure call. If the session drops, the first sent call to hit its reply-timeout surfaces `RPCAbortedError("TIMEOUT")` and resets the session — the next call re-handshakes lazily. A call that provably never left surfaces a plain `RPCError` and resets nothing: never-left is a transport event, the keys are fine, retrying is safe. The client never silently resends either way (that would double-execute non-idempotent handlers). Transport death alone doesn't touch the session: a reconnecting adapter (see `@dotex/saferpc/channels`) lets in-flight calls complete across a socket blip. Per-call `AbortSignal` (`api.thing(input, { signal })`) cancels waiting without tearing anything down.

Because `rpc` carries `Context`, procedures can be authored in any file with a fully-typed `ctx`, and `server()` requires a `context()` that returns the type your procedures expect.

Expand Down Expand Up @@ -107,15 +107,19 @@ Built-in helpers cover Ed25519, ECDSA P-256, JWT, certificate-based, and multifa
## Errors

```typescript
import { RPCError, RemoteRPCError } from "@dotex/saferpc";
import { RPCError, RPCAbortedError, RemoteRPCError } from "@dotex/saferpc";

try {
await api.greet({ name: "World" });
} catch (err) {
if (err instanceof RemoteRPCError) {
// The remote peer threw. err.code / err.message / err.data come from there.
} else if (err instanceof RPCAbortedError) {
// The request WAS sent but the outcome is unknown (TIMEOUT, ABORTED,
// SESSION) — it may have executed. Reconcile state before retrying.
} else if (err instanceof RPCError) {
// Local failure: TIMEOUT, SESSION, HANDSHAKE, CLIENT, ...
// Local failure, the request never left the process: CHANNEL, ABORTED,
// SESSION, HANDSHAKE, CLIENT, ... — safe to retry as-is.
// (validation errors like INPUT_VALIDATION run server-side and arrive as RemoteRPCError)
} else {
throw err;
Expand Down
2 changes: 1 addition & 1 deletion index.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ No HTTP. No JSON. No middleware stack. Encryption is the default because the des
2. On the first call, a handshake runs automatically. Ephemeral X25519 keys are exchanged and a session key is derived using the secret as HKDF salt.
3. The server proves it knows the secret with an HMAC over the transcript. The client proves it implicitly by producing valid ciphertext.
4. All calls are encrypted with XSalsa20-Poly1305 AEAD under the session key.
5. If the session drops, the client resets and re-handshakes lazily on the next call. The failed call surfaces a typed error (`TIMEOUT` / `CHANNEL`) — there is no silent resend; the caller decides whether to retry.
5. If the session drops, the first sent call that times out surfaces `RPCAbortedError("TIMEOUT")` and resets the session; the next call re-handshakes lazily. A call that never left surfaces a plain `RPCError("CHANNEL")` without touching the session. There is no silent resend; the caller decides whether to retry.

No certificates. No token refresh. No auth middleware. A shared secret and a channel.

Expand Down
659 changes: 350 additions & 309 deletions spec-channel-lifecycle.md

Large diffs are not rendered by default.

76 changes: 42 additions & 34 deletions spec/api.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions spec/assessment.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ Residual by design (denial of service, out of threat model): each hello still co

On any well-formed hello the server returns `proof = HMAC(HKDF(raw, salt=secret), …)` before the client authenticates. An attacker who completes an ECDH with their own ephemeral knows `raw` and receives the proof, leaving the `secret` as the only unknown — which they can then brute-force **offline**. Infeasible against a random 32-byte key (2²⁵⁶); a fast dictionary attack against a password-derived secret. This is a property of the scheme (PSK as HKDF salt, server-first proof), not a code defect, and is unchanged by make-before-break. Mitigation is documentation: the `secret` must be a CSPRNG key, not a passphrase — see [Security § The secret is a key, not a password](security.md#the-secret-is-a-key-not-a-password).

### 3c. Adapter frame queue — availability trade, by design (0.7.0)
### 3c. Core outbound queue — head-of-line blocking bounded by `sendTimeout`

The shipped reconnecting WS adapter (`@dotex/saferpc/channels`) queues frames submitted while the transport is down and flushes them on reconnect. Two accepted residuals, both availability-only (integrity is untouched — frames are ciphertext or hellos, and the receiving state machines already absorb staleness). First, queue overflow drops the **oldest** frame silently; the affected call times out and the session heals lazily — drop-oldest is chosen so a fresh hello outlives a stale request. Second, a hello queued during an outage can flush **after** the client's handshake attempt already timed out; the server processes it as a normal hello and, under make-before-break, installs a candidate that expires unconfirmed — the live session (if any) is untouched, and the server's reply is dropped by the client's `handshaking`-only gate. No queued-frame TTL is added for this: both endpoints already tolerate it.
The client core now owns the outbound queue; adapters have no internal queues. A frame that fails to send (the adapter threw) enters the core queue and is retried every 250 ms. The queue is head-of-line: if the channel is down, no later frame can pass a stuck earlier one. The bound is `sendTimeout` (default 10 s) — every queued frame fails with a definite `RPCError("CHANNEL")` if a live channel does not appear within that window. The stale-queued-hello residual (a timed-out hello flushing after handshake timeout) is gone: the core revokes queued hellos when the handshake attempt expires.

### 4. No server-side concurrency cap — documented, application concern

Expand Down
10 changes: 7 additions & 3 deletions spec/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,20 +139,24 @@ That is the whole loop. The handshake runs on the first call, every payload is X

## Error handling

Two error classes:
Three error classes:

- `RPCError`: local failure (timeout, session lost, validation error, handshake failure).
- `RPCError`: local failure where the request provably never left (frame expired in the outbound queue, validation error, handshake failure, session destroyed before send). Safe to retry.
- `RPCAbortedError` (subclass of `RPCError`): the request was sent and the outcome is unknown — it may have executed on the server. Retry only idempotent procedures.
- `RemoteRPCError`: error returned from the remote peer (`code`, `message`, `data`).

```typescript
import { RPCError, RemoteRPCError } from "@dotex/saferpc";
import { RPCError, RPCAbortedError, RemoteRPCError } from "@dotex/saferpc";

try {
await api.greet({ name: "World" });
} catch (err) {
if (err instanceof RemoteRPCError) {
if (err.code === "UNAUTHORIZED") await refreshCredentials();
} else if (err instanceof RPCAbortedError) {
// Sent, no reply — may have executed. Retry only if idempotent.
} else if (err instanceof RPCError) {
// Provably never left — retrying cannot double-execute.
if (err.code === "HANDSHAKE") console.warn("auth mismatch?");
} else {
throw err;
Expand Down
65 changes: 28 additions & 37 deletions spec/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Bidirectional byte streams. Each connection maps to one Safe RPC session.

The most common case: browser or service talking to a server over WS. Ships
as code — `@dotex/saferpc/channels` — because WS is the one transport with a
lifecycle trap (see [the adapter lifecycle contract](#adapter-lifecycle-reopen-immediately-queue-what-never-left) below).
lifecycle trap (see [the adapter contract](#adapter-contract-send-or-throw-no-queues-stay-available) below).

```typescript
// Server (Node.js, ws package)
Expand Down Expand Up @@ -54,12 +54,11 @@ const user = await api.getUser({ id: "123" });
`wsChannel(source, opts?)` **owns the socket lifecycle**: it connects
immediately, and when the socket closes it reconnects at once and keeps the
transport open as long as possible (exponential backoff, forever, until
`close()`). While the socket is down, `send` never throws — frames that
provably never left are queued (default cap 256, drop-oldest) and flushed in
order on reconnect; transport errors are swallowed and surfaced only through
the optional `onDown`/`onUp` observability hooks. `source` is a URL string
(uses the global `WebSocket`) or a factory `() => ws` for the `ws` package /
custom construction.
`close()`). While the socket is down `send` throws synchronously — the core
catches that, queues the frame, and retries on its own tick. Transport errors
are surfaced through the optional `onDown`/`onUp` observability hooks.
`source` is a URL string (uses the global `WebSocket`) or a factory `() => ws`
for the `ws` package / custom construction.

`socketChannel(ws)` is the plain single-socket wrapper: no reconnect, no
queue. The server uses it because a server cannot reconnect a client's
Expand Down Expand Up @@ -444,40 +443,32 @@ The rules are the same as everywhere else:
1. `send` accepts `Uint8Array` and gets it to the other side.
2. `receive(cb)` calls `cb` with each incoming `Uint8Array`. It returns an unsubscribe function.
3. The transport is allowed to drop, duplicate, or reorder messages. Safe RPC will time out and surface a typed error (it does not auto-retry — the caller decides whether to resend). It will not behave correctly if your transport silently corrupts bytes. Wrap it in something that fails noisily if you cannot trust it.
4. If the transport can die (sockets), the adapter owns liveness — see the lifecycle contract below.
4. If the transport can die (sockets), the adapter owns liveness — see the [adapter contract](#adapter-contract-send-or-throw-no-queues-stay-available) below. In brief: throw from `send` when the transport is down (the core queues and retries), reconnect eagerly, never queue inside the adapter.

That is the whole API surface. Encryption, framing, key management: all on the Safe RPC side. Your adapter does not need to care.

### Adapter lifecycle: reopen immediately, queue what never left

As of 0.7.0 the client core treats transport death as a non-event: the session
is bound to key material, not to a transport instance, so when a socket dies
the client keeps its keys and its pending calls keep waiting under their own
timers. A call's outcome is decided by exactly two events — a reply that
decrypts, or the call's own timeout. That works only if the adapter holds up
its half of the deal:

- **Reopen immediately.** When the transport closes, reconnect at once and
keep it open as long as possible (backoff, forever). Don't wait for the
next `send` to notice.
- **While down, `send` must not throw.** Queue the frame — it provably never
left, so flushing it after reconnect is safe and is NOT an auto-retry — and
drop any transport error inside the adapter (log it through your own hook
if you want; don't surface it per-call).
- **Never resend a frame that was written to a live transport.** Its outcome
is unknown; resending is exactly the double-execution hazard the no-retry
rule exists to prevent. Only queued-while-down frames may be flushed.
### Adapter contract: send or throw, no queues, stay available

The client core owns the outbound queue. The adapter's job is simpler:

- **`send` MUST throw synchronously** (or reject, for async adapters) when it
cannot hand the frame to a live transport right now. No internal queues, no
buffering, no retry — accepting a frame the adapter cannot deliver lies to
the core about the sent boundary.
- **No queues inside adapters.** Queueing in a transport adapter is an
antipattern: it duplicates state the core already owns and moves the
"never-left" bookkeeping to a place the core cannot see. The core retries
frames it gets back via throw; it cannot know about frames an adapter
accepted and then lost.
- **Stay available.** When the transport closes, reconnect at once and keep
it open as long as possible (backoff, forever, until explicit `close()`).
The channel's job is availability; delivery bookkeeping is the core's.

`wsChannel` in `@dotex/saferpc/channels` implements this contract; use it as
the reference. Two WS-specific traps it absorbs, for anyone writing their
own: browser `WebSocket.send()` on a **CLOSED** socket silently drops the
frame (it only throws while `CONNECTING`) — without a `readyState` check in
`send`, every failure degrades into a stacked RPC + handshake timeout (~35s
with defaults) surfacing as a misleading `HANDSHAKE "Handshake timeout"`; and
a socket can report OPEN before the reconnect flush has run — sending
directly then would reorder frames past the queued ones.

A fail-fast adapter (throw from `send` when the transport is down, get an
immediate `CHANNEL` error) is still a legal, simpler choice — the failed call
rejects at once and the caller decides. What it costs you is exactly the new
property: calls no longer survive a socket blip.
frame (it only throws while `CONNECTING`) — without a `readyState` check,
every failure degrades into a stacked RPC + handshake timeout (~35 s with
defaults) surfacing as a misleading `HANDSHAKE "Handshake timeout"`; and a
socket in a transient non-OPEN state must be detected by checking
`readyState`, not by catching the send exception.
Loading