Updates
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
# API: hyper-p2p-rpc
|
||||
|
||||
**Protocol:** `hyper-p2p-rpc/v2` (request/reply and streaming on one Protomux channel)
|
||||
|
||||
**Export:** `{ RPCServer, RPCClient, RPC_PROTOCOL, generateId }`
|
||||
|
||||
Note: `STREAM_PROTOCOL` (`hyper-p2p-rpc-stream/v2`) is exported as a constant in source but streaming is implemented on the primary `RPC_PROTOCOL` channel via `chunk` / `done` reply fields.
|
||||
|
||||
## Overview
|
||||
|
||||
`hyper-p2p-rpc` provides typed RPC over an existing duplex socket (typically `@hyperswarm/secret-stream` after Hyperswarm connects). `RPCServer` registers named async handlers on a connection; `RPCClient` issues calls and consumes results. Handlers may return a plain value or an **async iterable**; the server then streams chunks on the same channel before a terminal `done` or `error` frame.
|
||||
|
||||
This module does not join Hyperswarm itself—wire it after you have a socket from presence, session-bridge, or your own swarm setup.
|
||||
|
||||
## RPCServer
|
||||
|
||||
### Constructor
|
||||
|
||||
```js
|
||||
const server = new RPCServer(opts)
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `signingKeyPair` | `KeyPair` \| `null` | `null` | Reserved for future signed RPC; not used in v0.3.1 handlers |
|
||||
| `timeout` | `number` | `30000` | Default RPC timeout in ms for `server.call()` |
|
||||
|
||||
Internal state: `services` (`Map`), `connections` (`Set`), `_channels` (`WeakMap` socket → `{ channel, rpcMsg }`).
|
||||
|
||||
### `register(name, handler, schema = null)`
|
||||
|
||||
Registers a service method.
|
||||
|
||||
- **Parameters:**
|
||||
- `name` — `string` method name
|
||||
- `handler` — `async function (params, ctx) => result | AsyncIterable`
|
||||
- `schema` — stored on the entry but **not validated** in current implementation
|
||||
- **Returns:** `void`
|
||||
- **Throws:**
|
||||
- `TypeError: handler must be function`
|
||||
|
||||
`ctx` object passed to handlers:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `socket` | duplex stream | Connection that received the call |
|
||||
| `peerKey` | `Buffer` \| `null` | `socket.remotePublicKey` if set |
|
||||
|
||||
### `handleConnection(socket)`
|
||||
|
||||
Attaches Protomux `hyper-p2p-rpc/v2` to `socket`. Idempotent per socket (second call is a no-op).
|
||||
|
||||
- **Returns:** `void`
|
||||
- **Throws:** —
|
||||
|
||||
Emits `connection` with `socket` when the channel opens.
|
||||
|
||||
### `call(socket, method, params = {}, timeoutMs = this.defaultTimeout)`
|
||||
|
||||
Server-initiated RPC to a peer on an already-handled socket (adds a temporary reply listener on the same channel).
|
||||
|
||||
- **Returns:** `Promise<result>` — resolved with `reply.result`
|
||||
- **Throws:**
|
||||
- `Error: Socket not connected to RPC server`
|
||||
- `Error: RPC_TIMEOUT`
|
||||
- `Error: <reply.error>` — remote error string (e.g. `METHOD_NOT_FOUND: <name>`)
|
||||
|
||||
### `close()`
|
||||
|
||||
Destroys all tracked sockets, clears services, emits `close`.
|
||||
|
||||
- **Returns:** `void`
|
||||
- **Throws:** —
|
||||
|
||||
### `getStats()`
|
||||
|
||||
- **Returns:** `{ ops: number, errors: number }` — shallow copy (counters default `0`)
|
||||
- **Throws:** —
|
||||
|
||||
## RPCClient
|
||||
|
||||
### Constructor
|
||||
|
||||
```js
|
||||
const client = new RPCClient(socket, opts)
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `timeout` | `number` | `30000` | Default timeout for `call()` and `callStream()` |
|
||||
|
||||
Requires a duplex `socket` compatible with `Protomux.from(socket)`. Opens `hyper-p2p-rpc/v2` immediately in `_setupControlChannel`.
|
||||
|
||||
### `call(method, params = {}, timeoutMs = this.defaultTimeout)`
|
||||
|
||||
- **Returns:** `Promise<result>` — `reply.result` from server
|
||||
- **Throws:**
|
||||
- `Error: RPC_TIMEOUT`
|
||||
- `Error: <reply.error>` — includes `METHOD_NOT_FOUND: <method>` and handler exception messages
|
||||
- `Error: Connection closed` — if socket closes while pending
|
||||
|
||||
### `callStream(method, params = {}, timeoutMs = this.defaultTimeout)`
|
||||
|
||||
Invokes a handler that returns an async iterable; resolves to an async iterable of chunks.
|
||||
|
||||
- **Returns:** `Promise<AsyncIterable>` — object with `[Symbol.asyncIterator]`
|
||||
- **Throws:**
|
||||
- `Error: STREAM_TIMEOUT` — no `{ stream: true }` ack before timeout
|
||||
- `Error: <reply.error>` — server or stream failure
|
||||
- `Error: stream read timeout` — no chunk/done within `timeoutMs` while reading
|
||||
- `Error: Connection closed`
|
||||
|
||||
Iterator behavior:
|
||||
|
||||
- Yields each `reply.chunk` value
|
||||
- Stops when `reply.done === true`
|
||||
- Throws `new Error(data.error)` if a stream frame carries `error`
|
||||
|
||||
### `close()`
|
||||
|
||||
Calls `socket.destroy()`.
|
||||
|
||||
- **Returns:** `void`
|
||||
- **Throws:** —
|
||||
|
||||
## Wire reply shapes (client view)
|
||||
|
||||
| Frame | Fields | Meaning |
|
||||
|-------|--------|---------|
|
||||
| Success | `{ id, result }` | Unary RPC complete |
|
||||
| Error | `{ id, error: string }` | Failed RPC or stream |
|
||||
| Stream ack | `{ id, stream: true }` | Server will send chunks |
|
||||
| Chunk | `{ id, chunk: any }` | One streamed value |
|
||||
| Done | `{ id, done: true }` | Stream finished |
|
||||
|
||||
Request frame: `{ id, method, params }` where `id` is 32 hex chars from `generateId()` (`crypto.randomBytes(16)`).
|
||||
|
||||
## Events
|
||||
|
||||
### RPCServer
|
||||
|
||||
| Event | Payload | When |
|
||||
|-------|---------|------|
|
||||
| `connection` | `socket` | RPC Protomux channel `onopen` |
|
||||
| `close` | — | `close()` |
|
||||
|
||||
### RPCClient
|
||||
|
||||
| Event | Payload | When |
|
||||
|-------|---------|------|
|
||||
| `close` | — | Socket `close`; all pending calls rejected |
|
||||
|
||||
## getStats()
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|-------|------|---------|
|
||||
| `ops` | `number` | Reserved counter (default `0`) |
|
||||
| `errors` | `number` | Reserved counter (default `0`) |
|
||||
|
||||
## Errors
|
||||
|
||||
Stable message substrings: see [`../_shared/ERROR_CODES.md`](../../_shared/ERROR_CODES.md).
|
||||
|
||||
| Message | Where |
|
||||
|---------|--------|
|
||||
| `handler must be function` | `register()` — `TypeError` |
|
||||
| `Socket not connected to RPC server` | `RPCServer.call()` |
|
||||
| `RPC_TIMEOUT` | `RPCServer.call()`, `RPCClient.call()` |
|
||||
| `STREAM_TIMEOUT` | `RPCClient.callStream()` initial ack |
|
||||
| `stream read timeout` | `RPCClient.callStream()` iterator `next()` |
|
||||
| `Connection closed` | `RPCClient` on socket close |
|
||||
| `METHOD_NOT_FOUND: <method>` | Sent in `reply.error`, surfaced as `Error` on client |
|
||||
|
||||
Handler exceptions are sent as `{ id, error: err.message || String(err) }` (not rethrown on server).
|
||||
|
||||
## P2P
|
||||
|
||||
Typical integration:
|
||||
|
||||
1. Establish a Hyperswarm (or paired `SecretStream`) connection between two peers.
|
||||
2. `server.handleConnection(socketA)` and `new RPCClient(socketB)` on the paired ends.
|
||||
3. `client.call('method', params)` or `client.callStream('method', params)`.
|
||||
|
||||
`RPC_PROTOCOL` must match on both sides (`core-infrastructure/hyper-p2p-rpc/v2`). Encoding is `compact-encoding` `c.json` for all RPC frames.
|
||||
|
||||
Unary server flow: handler return value → `{ id, result }`.
|
||||
|
||||
Streaming server flow: async iterable from handler → `{ id, stream: true }`, then `{ id, chunk }` per yield, then `{ id, done: true }` or `{ id, error }`.
|
||||
|
||||
Pairing helper for tests: `lib/pair.js` (`pairSecretStreams`, `waitSecretStreamsConnected`).
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
cd modules/core-infrastructure/hyper-p2p-rpc
|
||||
npm install && npm test
|
||||
```
|
||||
|
||||
`test/test.js` — registration API and close. For full wire coverage, run streaming and integration tests manually:
|
||||
|
||||
```bash
|
||||
bare test/streaming-test.js
|
||||
bare test/integration-two-node.js
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
bare examples/basic.js
|
||||
bare examples/streaming.js
|
||||
```
|
||||
|
||||
## Module exports
|
||||
|
||||
```js
|
||||
const { RPCServer, RPCClient, RPC_PROTOCOL, generateId } = require('hyper-p2p-rpc')
|
||||
```
|
||||
|
||||
`generateId()` returns a 32-character hex string (16 random bytes).
|
||||
@@ -0,0 +1,152 @@
|
||||
# Architecture: hyper-p2p-rpc
|
||||
|
||||
**Category:** Core infrastructure
|
||||
|
||||
**Protocol:** `hyper-p2p-rpc/v2`
|
||||
|
||||
## Layer diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
App[Application handlers] --> SRV[RPCServer]
|
||||
App2[Application caller] --> CLI[RPCClient]
|
||||
SRV --> MUXs[Protomux.from socket]
|
||||
CLI --> MUXc[Protomux.from socket]
|
||||
MUXs --> CH[Channel hyper-p2p-rpc/v2]
|
||||
MUXc --> CH
|
||||
CH --> JSON[compact-encoding c.json frames]
|
||||
SOCK[SecretStream / swarm socket] --> MUXs
|
||||
SOCK --> MUXc
|
||||
```
|
||||
|
||||
## Sequence: unary call
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as RPCClient
|
||||
participant CH as Protomux RPC channel
|
||||
participant S as RPCServer
|
||||
participant H as Handler
|
||||
|
||||
C->>CH: { id, method, params }
|
||||
CH->>S: onmessage(msg)
|
||||
S->>S: services.get(method)
|
||||
alt method missing
|
||||
S->>CH: { id, error: "METHOD_NOT_FOUND: ..." }
|
||||
else handler ok
|
||||
S->>H: handler(params, ctx)
|
||||
H-->>S: result
|
||||
S->>CH: { id, result }
|
||||
end
|
||||
CH->>C: onmessage(reply)
|
||||
C-->>C: resolve(result) or reject(Error)
|
||||
```
|
||||
|
||||
## Sequence: streaming call
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as RPCClient
|
||||
participant CH as Protomux RPC channel
|
||||
participant S as RPCServer
|
||||
participant H as AsyncIterable handler
|
||||
|
||||
C->>CH: { id, method, params }
|
||||
CH->>S: onmessage
|
||||
S->>H: await handler()
|
||||
H-->>S: AsyncIterable
|
||||
S->>CH: { id, stream: true }
|
||||
CH->>C: stream ack → streamReady()
|
||||
loop for await chunk
|
||||
S->>CH: { id, chunk }
|
||||
CH->>C: onStreamMsg → queue / yield
|
||||
end
|
||||
S->>CH: { id, done: true }
|
||||
CH->>C: iterator returns
|
||||
```
|
||||
|
||||
Streaming uses the **same** `hyper-p2p-rpc/v2` channel; `STREAM_PROTOCOL` is not opened as a separate channel in v0.3.1.
|
||||
|
||||
## Wire messages
|
||||
|
||||
Encoding: Protomux message with `encoding: c.json` on protocol `hyper-p2p-rpc/v2`.
|
||||
|
||||
### Client → server (request)
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `id` | `string` | yes | 32 hex chars; correlates replies |
|
||||
| `method` | `string` | yes | Registered service name |
|
||||
| `params` | `object` | no | Defaults to `{}` in handler |
|
||||
|
||||
### Server → client (reply)
|
||||
|
||||
| Pattern | Fields | When |
|
||||
|---------|--------|------|
|
||||
| Unary success | `id`, `result` | Handler returned non-iterable value |
|
||||
| Unary error | `id`, `error` | Unknown method, handler throw, or stream failure |
|
||||
| Stream start | `id`, `stream: true` | Handler returned async iterable |
|
||||
| Stream data | `id`, `chunk` | Each `yield` from iterable |
|
||||
| Stream end | `id`, `done: true` | Iterable exhausted |
|
||||
| Stream error | `id`, `error` | Iterable loop threw |
|
||||
|
||||
| `error` value | Cause |
|
||||
|---------------|--------|
|
||||
| `METHOD_NOT_FOUND: <method>` | No `register()` entry |
|
||||
| `<handler message>` | `catch` in `_onRpcMessage` or `_streamOnRpcChannel` |
|
||||
|
||||
Messages with non-matching `id` are ignored on the client pending map. Server `call()` adds a one-off reply listener filtered by `id`.
|
||||
|
||||
## State model
|
||||
|
||||
### RPCServer
|
||||
|
||||
| Structure | Contents |
|
||||
|-----------|----------|
|
||||
| `services` | `Map<name, { handler, schema }>` |
|
||||
| `connections` | `Set<socket>` — sockets passed to `handleConnection` |
|
||||
| `_channels` | `WeakMap<socket, { channel, rpcMsg }>` — primary RPC message |
|
||||
| `_streams` | `Map` — reserved (unused in streaming path) |
|
||||
| `defaultTimeout` | ms for `call()` |
|
||||
|
||||
### RPCClient
|
||||
|
||||
| Structure | Contents |
|
||||
|-----------|----------|
|
||||
| `pending` | `Map<id, { resolve, reject, timer, streamReady?, onStreamMsg? }>` |
|
||||
| `socket` / `mux` / `channel` / `rpcMsg` | Single RPC control plane per client |
|
||||
| `defaultTimeout` | ms for `call()` / `callStream()` |
|
||||
|
||||
**ID generation:** `generateId()` → `b4a.toString(crypto.randomBytes(16), 'hex')`.
|
||||
|
||||
**Connection lifecycle:** `socket.on('close')` rejects all pending with `Connection closed` and emits `close`.
|
||||
|
||||
## Handler contracts
|
||||
|
||||
| Return type | Server behavior |
|
||||
|-------------|-----------------|
|
||||
| Plain value / Promise | `{ id, result }` |
|
||||
| Object with `[Symbol.asyncIterator]` | Stream ack + chunks + done |
|
||||
| Thrown error | `{ id, error: message }` |
|
||||
|
||||
`schema` in `register()` is stored for future validation only.
|
||||
|
||||
## Composition
|
||||
|
||||
| Module | Role with RPC |
|
||||
|--------|----------------|
|
||||
| `hyper-p2p-presence` | Discovery and metadata; RPC rides the same swarm socket after connect |
|
||||
| `hyper-p2p-capabilities` | Authorization before exposing RPC methods |
|
||||
| `hyper-p2p-session-bridge` | SecretStream pairing and handoff into `handleConnection` |
|
||||
| `hyper-p2p-protocol-handshake` | Version/features before app-level RPC |
|
||||
|
||||
Recommended order: Hyperswarm connect → optional handshake → **RPCServer.handleConnection** / **RPCClient** on the encrypted duplex.
|
||||
|
||||
See [`../_shared/WAVE6_NETWORK_STACK.md`](../../_shared/WAVE6_NETWORK_STACK.md) — `protocol-handshake` pairs with `rpc` at session layer.
|
||||
|
||||
## Design constraints
|
||||
|
||||
- One RPC channel per socket per side; server `call()` dynamically adds a reply message type on the same channel (client uses the primary `rpcMsg` listener for all reply shapes).
|
||||
- No built-in Hyperswarm topic or `ready()` — transport-agnostic by design.
|
||||
- Timeouts are client-side (and server-side for `RPCServer.call`) via `bare-timers`; no keepalive frames.
|
||||
- Backpressure: streaming sends chunks as fast as the iterable produces them; no windowing.
|
||||
Reference in New Issue
Block a user