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
48 changes: 24 additions & 24 deletions spec/api.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# API Reference

Strict reference for every exported symbol. For an end-to-end walkthrough see [Getting Started](getting-started.md). For threat model and crypto details see [Security](security.md). For the wire format see [Protocol](protocol.md).
Reference for every exported symbol. End-to-end walkthrough lives in [Getting Started](getting-started.md), threat model and crypto in [Security](security.md), wire format in [Protocol](protocol.md).

## Import paths

Expand All @@ -26,7 +26,7 @@ import { ... } from "@dotex/erpc/client";
function chain(): Chain;
```

Returns a procedure builder. All methods are immutable and chainable. The chain terminates with `.handler()`, which returns a frozen `Procedure`.
Returns a procedure builder. Every method is immutable and chainable. `.handler()` terminates the chain and returns a frozen `Procedure`.

```typescript
interface Chain<TCtx = {}, TIn = unknown, TOut = unknown> {
Expand Down Expand Up @@ -65,7 +65,7 @@ interface Procedure {
type Router = Record<string, Procedure>;
```

Treat `Procedure` as opaque. The fields are exported only so `server()` can introspect them.
Treat `Procedure` as opaque. The fields are exposed only so `server()` can introspect them.

---

Expand All @@ -79,7 +79,7 @@ function server<T extends Router>(
): { destroy: () => void };
```

Subscribes to `channel` and serves the given router. Returns synchronously.
Subscribes to `channel` and serves the router. Returns synchronously.

### `ServerOptions`

Expand All @@ -91,9 +91,9 @@ Subscribes to `channel` and serves the given router. Returns synchronously.
| `maxMessageBytes` | `number` | `1_048_576` | — |
| `onError` | `(err: unknown) => void` | — | — |

`context` is called per request. The `auth` argument carries whatever `auth.verify` returned for the current session. When `context` is omitted, the request context is the verified auth data (or `{}` if none).
`context` runs per request. The `auth` argument carries whatever `auth.verify` returned for the current session. When `context` is omitted, the request context falls back to the verified auth data (or `{}` if none).

`onError` is called on handshake failures and non-fatal internal errors. The server does **not** destroy on handshake failure — it resets and accepts the next hello.
`onError` fires on handshake failures and non-fatal internal errors. The server does **not** destroy itself on a failed handshake — it resets and accepts the next hello.

### `AuthOptions`

Expand All @@ -110,15 +110,15 @@ interface AuthOptions {
type VerifyResult = { auth?: Ctx } | void;
```

At least one of `psk` or asymmetric (`sign` / `verify`) must be set. Configuring neither throws a `TypeError` at construction.
Set at least one of `psk` or asymmetric (`sign` / `verify`). Configuring neither throws a `TypeError` at construction.

| Field | Called | Notes |
|-------|--------|-------|
| `psk` | Per handshake attempt | Returned bytes must be ≥ 32. Empty PSK used when `psk` is omitted but asymmetric auth is configured. |
| `sign` | Per handshake attempt, if set | Signature payload, ≤ 32 KiB. |
| `verify` | Per handshake attempt, if set | Throw to reject. Returned `auth` is bound to the resulting session. |

Returned `auth` data is sanitized (poison keys stripped) before being passed to `context`.
Returned `auth` data is sanitized (poison keys stripped) before reaching `context`.

### Server lifecycle

Expand All @@ -145,7 +145,7 @@ function client<T extends Router>(
): { api: Client<T>; destroy: () => void };
```

Returns synchronously. The handshake is lazy: it starts on the first `api` call.
Returns synchronously. The handshake stays lazy: it starts on the first `api` call.

### `ClientOptions`

Expand Down Expand Up @@ -188,11 +188,11 @@ idle → handshaking → ready

## Auto-retry

When an call fails on a `ready` session with a local `TIMEOUT` or send error, the client zeros its session key, returns to `idle`, runs a fresh handshake, and resends the request **exactly once**. `RemoteRPCError` (server returned an error) is **not** retried — the server is alive. Concurrent failures share one re-handshake via an epoch counter; no infinite loops. Full state-machine and wire-level semantics in [Protocol § Auto-retry semantics](protocol.md#auto-retry-semantics).
A call that fails on a `ready` session with a local `TIMEOUT` or send error triggers a single retry: the client zeros its session key, returns to `idle`, runs a fresh handshake, and resends the request **exactly once**. `RemoteRPCError` (server returned an error) is **not** retried — the server is alive and answered. Concurrent failures share one re-handshake via an epoch counter, so there are no retry storms. Full state-machine and wire-level semantics in [Protocol § Auto-retry semantics](protocol.md#auto-retry-semantics).

## Replay within a session

Per-message AEAD nonces are random; an attacker who can inject into a live channel can replay a captured ciphertext and the receiver will execute it again. For non-idempotent procedures, add an application-level idempotency key inside `input`, or maintain a request-ID set on the server keyed by the verified principal. Full discussion in [Security § Replay within a session](security.md#replay-within-a-session).
Per-message AEAD nonces are random, not counter-derived. An attacker who can inject into a live channel can replay a captured ciphertext and the receiver will execute it again. For non-idempotent procedures, add an idempotency key inside `input`, or keep a request-ID set on the server keyed by the verified principal. Full discussion in [Security § Replay within a session](security.md#replay-within-a-session).

---

Expand All @@ -211,9 +211,9 @@ The only transport contract. `receive` returns an unsubscribe function. The chan
- Deliver each call to `cb` once, in any order
- Allow `send` and `receive` to run concurrently

It is **allowed** to drop messages, duplicate them, or reorder them — eRPC will time out and retry. Ready-made adapters live in [Integrations](integrations.md).
Dropping, duplicating, or reordering messages is allowed — eRPC will time out and retry. Ready-made adapters live in [Integrations](integrations.md).

> Within a single eRPC session the protocol assumes the `TAG_HELLO` reply arrives before any `TAG_MSG` sent under the resulting session key. Transports that may reorder *across* the hello/reply boundary (multi-path links, fan-out buses) will hang the handshake until the timeout fires. `TAG_MSG`-to-`TAG_MSG` reordering remains safe because every encrypted frame is independently authenticated and the protocol has no ordering requirement on application messages.
> Within a single session the protocol assumes the `TAG_HELLO` reply arrives before any `TAG_MSG` sent under the resulting session key. Transports that can reorder *across* the hello/reply boundary (multi-path links, fan-out buses) will hang the handshake until the timeout fires. `TAG_MSG`-to-`TAG_MSG` reordering stays safe: every encrypted frame is independently authenticated and the protocol imposes no ordering on application messages.

---

Expand All @@ -230,7 +230,7 @@ class RemoteRPCError extends RPCError {}
```

- `RPCError` is thrown for **local** failures: timeout, session destroyed, handshake failure, validation failure, channel error.
- `RemoteRPCError` is thrown when the remote peer's handler returned an error. The `code`, `message`, and `data` come from the remote side and are **untrusted strings** — do not log them at warn/error level without sanitization.
- `RemoteRPCError` is thrown when the remote peer's handler returned an error. The `code`, `message`, and `data` come from the remote side and are **untrusted strings** — sanitize before logging at warn/error level, or before showing them to a user.

### Standard local error codes

Expand All @@ -246,7 +246,7 @@ class RemoteRPCError extends RPCError {}
| `INTERNAL` | Defensive — should not be reachable |
| `MIDDLEWARE` | Middleware misuse (`next()` called twice, bad `extra` arg) |

Handlers may throw `RPCError(...)` with any codethose codes become `RemoteRPCError.code` on the client.
Handlers may throw `RPCError(...)` with any code; those codes surface as `RemoteRPCError.code` on the client.

### Pattern

Expand All @@ -268,7 +268,7 @@ try {

## Middleware and context

Middleware extends the context. Each middleware is `({ ctx, input, next })`. It must call `next(extra?)` exactly once.
Middleware extends the context. Signature is `({ ctx, input, next })`, and `next(extra?)` must be called exactly once.

```typescript
const d = chain();
Expand All @@ -294,9 +294,9 @@ server(router, channel, {
});
```

Calling `next()` twice in the same middleware throws `RPCError("MIDDLEWARE", ...)`. Passing a non-object `extra` does the same.
Calling `next()` twice in the same middleware throws `RPCError("MIDDLEWARE", ...)`. So does passing a non-object `extra`.

The `context` factory runs **per request**, after auth verification, and receives `{ auth }` carrying the data returned by `auth.verify` for the session.
The `context` factory runs **per request**, after auth verification, and receives `{ auth }` carrying the data returned by `auth.verify` for that session.

---

Expand All @@ -310,13 +310,13 @@ HKDF-SHA-256 over `secret` with `sessionId` as salt and the fixed info string `"

Throws `TypeError` if `sessionId` is empty or `secret` is shorter than 32 bytes.

Use to bind each handshake to a session identifier instead of holding a single static PSK.
Use it to bind each handshake to a session identifier instead of relying on a single static PSK.

---

## Built-in auth helpers

Every helper returns a partial `AuthOptions` you can spread into the `auth` block. All bind their proof to the canonical handshake transcript so a captured payload cannot be replayed into a new handshake.
Every helper returns a partial `AuthOptions` you spread into the `auth` block. Each one binds its proof to the canonical handshake transcript, so a captured payload cannot be replayed into a new handshake.

### Client-side

Expand Down Expand Up @@ -358,7 +358,7 @@ import {
| `createCertificateServerAuth({ verifyCertificate, validateSubject? })` | Verifies a presented certificate chain + ECDSA P-256 signature. |
| `createMultifactorServerAuth({ primary, secondary, combineAuth? })` | Composes two verifiers; both must pass. |

All decode auth payloads through the hardened msgpack codec (ext types rejected, prototype-pollution keys stripped, depth-limited). Returned `auth` data is sanitized before reaching `context`.
Auth payloads decode through the hardened msgpack codec: extension types rejected, prototype-pollution keys stripped, recursion depth capped. Returned `auth` data is sanitized before reaching `context`.

---

Expand All @@ -382,13 +382,13 @@ import {
} from "@dotex/erpc";
```

Exported for adapter authors. Application code does not normally need these.
Exported for adapter authors. Application code rarely needs them.

---

## Cleanup

Always call `destroy()` when you are done with a session.
Call `destroy()` when you are done with a session.

```typescript
const { destroy: destroyServer } = server(router, channel, { auth });
Expand All @@ -409,7 +409,7 @@ After `destroy()`:

## Edge runtime compatibility

Both `server()` and `client()` return **synchronously**. No top-level `await`. Pure JavaScript dependencies. Compatible with:
Both `server()` and `client()` return **synchronously**, with no top-level `await`. Dependencies are pure JavaScript. Compatible with:

- Node.js 18+
- Modern browsers
Expand Down
36 changes: 18 additions & 18 deletions spec/getting-started.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# Getting Started

Five minutes from `npm install` to encrypted, typed procedure calls. The shape is always the same: define a router, configure auth, attach a channel, call functions.
Five minutes from `npm install` to encrypted, typed procedure calls. The shape never changes: define a router, configure auth, attach a channel, call functions.

## Install

```bash
npm install @dotex/erpc
```

Peer dependency: a Zod-compatible schema library. eRPC uses `zod` for input/output validation in procedures.
Peer dependency: a Zod-compatible schema library. eRPC validates procedure input and output through `zod`.

## Define procedures

Expand All @@ -30,25 +30,25 @@ const router = {
};
```

The router is a plain object — keys are procedure names, values are procedures. Share it between server and client as a **type**. The client infers the entire API surface from `typeof router` without ever importing the runtime code.
The router is a plain object. Keys are procedure names, values are procedures. Share it between server and client as a **type** — the client infers the whole API surface from `typeof router` and never imports the runtime code.

## Configure authentication

eRPC requires at least one authentication method.

### PSK (shared secret)

Both sides hold the same 32-byte key. Simplest and fastest.
Both sides hold the same 32-byte key. Cheapest mode, no signature ops on the hot path.

```typescript
const sharedSecret = crypto.getRandomValues(new Uint8Array(32));

const auth = { psk: () => sharedSecret };
```

`psk()` may return the same `Uint8Array` across callseRPC reads it and never mutates the buffer. Lifecycle is yours: zero it yourself if and when the secret should disappear from memory.
`psk()` may return the same `Uint8Array` across calls. eRPC reads it and never mutates the buffer. Lifecycle stays with you: zero it yourself if the secret should disappear from memory.

For better security, derive a fresh PSK from a per-session identifier:
A single static PSK works, but a per-session derivation is harder to misuse — leaked traffic only compromises one session, not every past or future one:

```typescript
import { deriveSessionPSK } from "@dotex/erpc";
Expand All @@ -64,7 +64,7 @@ const auth = {

### Asymmetric (signatures)

For public clients or when device-level identity matters. The signer proves identity over the handshake transcript; the verifier rejects bad signatures.
For public clients, or when device-level identity matters. The signer proves identity over the handshake transcript; the verifier rejects bad signatures.

```typescript
const auth = {
Expand Down Expand Up @@ -93,11 +93,11 @@ const auth = createEd25519ServerAuth({
});
```

All built-in helpers (Ed25519, ECDSA, JWT, certificate, multifactor) bind their proof to the canonical handshake transcript automatically. See [Security: Built-in signature helpers](security.md#built-in-signature-helpers).
All built-in helpers (Ed25519, ECDSA, JWT, certificate, multifactor) bind their proof to the canonical handshake transcript. See [Security Built-in signature helpers](security.md#built-in-signature-helpers).

### Both (defense-in-depth)

Combine them when you need both session binding and individual revocation.
Combine them when you need session binding and per-key revocation at the same time.

```typescript
const auth = {
Expand All @@ -120,7 +120,7 @@ const { destroy: destroyServer } = server(router, serverChannel, {
});
```

The server listens on a `Channel`any bidirectional transport that can carry `Uint8Array`. See [Integrations](integrations.md) for ready-made adapters.
The server listens on a `Channel`: any bidirectional transport that can carry `Uint8Array`. Ready-made adapters live in [Integrations](integrations.md).

## Connect the client

Expand All @@ -132,7 +132,7 @@ const { api, destroy: destroyClient } = client<typeof router>(clientChannel, {
});
```

Construction is **synchronous**. The handshake is lazy it runs on the first call, not when the client is created. No top-level `await`.
Construction is **synchronous**. The handshake is lazy: it runs on the first call, not when the client is created. No top-level `await`.

## Make calls

Expand All @@ -141,7 +141,7 @@ const result = await api.greet({ name: "World" });
console.log(result.message); // "Hello, World!"
```

Handshake, encryption, msgpack serialization, schema validation — handled. If the session drops, the client retries once with a fresh handshake. See [API: Auto-Retry](api.md).
Handshake, encryption, msgpack serialization, schema validation — all handled internally. If the session drops, the client retries once with a fresh handshake. See [API: Auto-Retry](api.md).

## End-to-end example

Expand Down Expand Up @@ -225,10 +225,10 @@ server(router, channel, {

## Error handling

Two error types:
Two error types, and the distinction matters because they imply different recovery paths.

- `RPCError` — local failure (timeout, session lost, validation error, handshake failure)
- `RemoteRPCError` — error returned from the remote peer (carries `code`, `message`, `data`)
- `RPCError` — local failure: timeout, session lost, validation error, handshake failure. Worth retrying or surfacing as a transient problem.
- `RemoteRPCError` — the remote peer's handler threw. Carries `code`, `message`, `data`. The other side is alive and made a deliberate decision.

```typescript
import { RPCError, RemoteRPCError } from "@dotex/erpc";
Expand All @@ -250,11 +250,11 @@ try {

## Choosing an auth mode

**PSK** when you control both endpointsserver-to-server, internal services, parent ↔ iframe of the same origin. Fast (no signature ops). Simple.
PSK fits when you control both endpoints: server-to-server, internal services, an iframe talking to its parent on the same origin. No signature work per handshake.

**Asymmetric** when one side is untrusted or there is no shared secretpublic web clients, mobile apps, IoT devices. Per-device revocation.
Asymmetric fits when one side is untrusted or there is no safe place to put a shared secret: public browser clients, mobile apps, IoT devices. Each key revokes independently.

**Both** when you want session binding *and* per-device identity — regulated environments, high-value systems.
Use both when you need session binding *and* per-device identity. Regulated environments, high-value systems. An attacker now has to compromise the derivation secret *and* a device key, and forward secrecy still protects past traffic if either leaks.

## Next steps

Expand Down
Loading
Loading