Skip to content

Protocol Specification

Complete specification of the HTTP and WebSocket protocols used by @ereo/rpc.

Overview

The RPC system uses two protocols:

  • HTTP - For queries (read) and mutations (write)
  • WebSocket - For subscriptions (real-time streaming)

Both protocols share a common error format and path-based procedure resolution.

HTTP Protocol

Endpoint

All HTTP requests go to a single endpoint (default: /api/rpc).

Query Requests

Queries support both GET and POST methods.

GET requests are cacheable by browsers and CDNs.

GET /api/rpc?path=users.list&input={"limit":10}

Query Parameters:

ParameterTypeRequiredDescription
pathstringYesDot-separated procedure path
inputstringNoJSON-encoded input data

Examples:

# No input
GET /api/rpc?path=health

# With input
GET /api/rpc?path=users.get&input={"id":"123"}

# Nested path
GET /api/rpc?path=v1.admin.users.list

# Complex input
GET /api/rpc?path=posts.search&input={"query":"hello","tags":["tech","news"],"limit":20}

URL Length Warning:

If input data is large (>1500 characters when JSON-encoded), consider using POST to avoid URL length limits.

POST Method

POST can be used for queries when input is large or contains sensitive data.

POST /api/rpc
Content-Type: application/json

{
  "path": ["users", "list"],
  "type": "query",
  "input": { "limit": 10 }
}

Mutation Requests

Mutations always use POST.

POST /api/rpc
Content-Type: application/json

{
  "path": ["users", "create"],
  "type": "mutation",
  "input": {
    "name": "Alice",
    "email": "alice@example.com"
  }
}

Request Body Schema

ts
interface RPCRequest {
  /** Procedure path as array of segments */
  path: string[]

  /** Request type */
  type: 'query' | 'mutation'

  /** Input data (optional) */
  input?: unknown
}

Response Format

All responses are JSON with a consistent structure.

Success Response

ts
{
  "ok": true,
  "data": <procedure return value>
}

Examples:

json
// Simple value
{ "ok": true, "data": { "status": "healthy" } }

// Object
{ "ok": true, "data": { "id": "123", "name": "Alice", "email": "alice@example.com" } }

// Array
{ "ok": true, "data": [{ "id": "1", "title": "Post 1" }, { "id": "2", "title": "Post 2" }] }

// Null
{ "ok": true, "data": null }

Error Response

ts
{
  "ok": false,
  "error": {
    "code": string,
    "message": string,
    "details"?: unknown
  }
}

Examples:

json
// Not found
{
  "ok": false,
  "error": {
    "code": "NOT_FOUND",
    "message": "User not found"
  }
}

// Validation error
{
  "ok": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Input validation failed",
    "details": [
      { "path": ["email"], "message": "Invalid email format", "code": "invalid_string" }
    ]
  }
}

// Unauthorized
{
  "ok": false,
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Please log in to continue"
  }
}

HTTP Status Codes

CodeCondition
200Success (both ok: true and ok: false with client errors)
400Parse error, validation error, method mismatch
401Unauthorized (from RPCError)
403Forbidden (from RPCError)
404Procedure not found, resource not found
429Rate limited
500Internal server error

Error Codes

CodeHTTP StatusDescription
PARSE_ERROR400Invalid JSON in request body
NOT_FOUND404Procedure path doesn't exist
METHOD_NOT_ALLOWED400Subscription called via HTTP
METHOD_MISMATCH400Query called as mutation or vice versa
VALIDATION_ERROR400Input schema validation failed
INTERNAL_ERROR500Unexpected server error
UNAUTHORIZED401Authentication required
FORBIDDEN403Insufficient permissions
RATE_LIMITED429Too many requests
BAD_REQUEST400Generic client error

Headers

Request Headers

Content-Type: application/json  (required for POST)
Authorization: Bearer <token>   (optional, for auth)
X-Request-ID: <uuid>            (optional, for tracing)

Response Headers

Content-Type: application/json

WebSocket Protocol

Connection

Connect to the same endpoint as HTTP with WebSocket upgrade.

ws://localhost:3000/api/rpc
wss://example.com/api/rpc  (production)

Upgrade Request

GET /api/rpc HTTP/1.1
Host: localhost:3000
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: <key>
Sec-WebSocket-Version: 13

Message Format

All messages are JSON strings.

Client Messages

Messages sent from client to server.

Subscribe

Start a subscription.

ts
{
  "type": "subscribe",
  "id": string,      // Unique subscription ID
  "path": string[],  // Procedure path
  "input"?: unknown  // Input data (optional)
}

Examples:

json
// No input
{
  "type": "subscribe",
  "id": "sub_abc123",
  "path": ["notifications", "onNew"]
}

// With input
{
  "type": "subscribe",
  "id": "sub_def456",
  "path": ["chat", "messages"],
  "input": { "roomId": "general" }
}

Unsubscribe

Stop a subscription.

ts
{
  "type": "unsubscribe",
  "id": string  // Subscription ID to stop
}

Example:

json
{
  "type": "unsubscribe",
  "id": "sub_abc123"
}

Ping

Heartbeat to keep connection alive.

ts
{
  "type": "ping"
}

Server Messages

Messages sent from server to client.

Data

New data from a subscription.

ts
{
  "type": "data",
  "id": string,     // Subscription ID
  "data": unknown   // Yielded value
}

Example:

json
{
  "type": "data",
  "id": "sub_abc123",
  "data": {
    "id": "notif_1",
    "title": "New message",
    "body": "You have a new message from Alice"
  }
}

Error

Subscription error.

ts
{
  "type": "error",
  "id": string,
  "error": {
    "code": string,
    "message": string
  }
}

Example:

json
{
  "type": "error",
  "id": "sub_abc123",
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Session expired"
  }
}

Complete

Subscription ended (generator finished).

ts
{
  "type": "complete",
  "id": string  // Subscription ID
}

Example:

json
{
  "type": "complete",
  "id": "sub_abc123"
}

Pong

Response to ping heartbeat.

ts
{
  "type": "pong"
}

WebSocket Error Codes

CodeDescription
PARSE_ERRORInvalid JSON message
NOT_FOUNDSubscription procedure not found
METHOD_MISMATCHCalled non-subscription as subscription
VALIDATION_ERRORInput validation failed
SUBSCRIPTION_ERRORError during subscription execution
DUPLICATE_IDSubscription ID already in use
UNAUTHORIZEDAuthentication failed (middleware)
FORBIDDENAccess denied (middleware)

Connection Lifecycle

Client                          Server
  |                               |
  |-------- Connect ------------->|
  |<------- Accept ---------------|
  |                               |
  |-------- Subscribe ----------->|
  |<------- Data -----------------|
  |<------- Data -----------------|
  |<------- Data -----------------|
  |                               |
  |-------- Ping ---------------->|
  |<------- Pong -----------------|
  |                               |
  |<------- Data -----------------|
  |                               |
  |-------- Unsubscribe --------->|
  |                               |
  |-------- Close --------------->|

Heartbeat Protocol

  1. Client sends ping every N seconds (default: 30s)
  2. Server responds with pong
  3. If client misses 2 consecutive pong responses, connection is considered dead
  4. Client closes connection and initiates reconnect

Reconnection Protocol

  1. On connection loss, client waits delayMs (default: 1s)
  2. Client attempts to reconnect
  3. If failed, wait delayMs * 2 (exponential backoff)
  4. Cap delay at maxDelayMs (default: 30s)
  5. After maxAttempts (default: 10), stop trying
  6. On successful reconnect, resubscribe to all active subscriptions

Subscription Lifecycle

┌─────────────────────────────────────────────────────────────┐
│                  Subscription States                         │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  [Idle] ──subscribe msg──> [Active]                         │
│                               │                              │
│                    ┌──────────┼──────────┐                  │
│                    │          │          │                   │
│                    ▼          ▼          ▼                   │
│                 [Data]    [Error]   [Complete]               │
│                    │          │          │                   │
│                    └──────────┼──────────┘                   │
│                               │                              │
│                               ▼                              │
│                            [Done]                            │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Path Resolution

Procedure paths are resolved from the router definition.

Path Format

  • HTTP GET: Dot-separated string (users.get)
  • HTTP POST/WS: Array of strings (["users", "get"])

Resolution Algorithm

ts
function resolveProcedure(router, path) {
  let current = router._def

  for (const segment of path) {
    if (!current || typeof current !== 'object' || !(segment in current)) {
      return null  // Not found
    }
    current = current[segment]
  }

  if (current && '_type' in current) {
    return current  // Found procedure
  }

  return null  // Path leads to nested router, not procedure
}

Examples

ts
const router = createRouter({
  health: procedure.query(() => ({ status: 'ok' })),
  users: {
    list: procedure.query(async () => []),
    get: procedure.query(z.object({ id: z.string() }), async () => null),
  },
  v1: {
    admin: {
      stats: procedure.query(async () => ({})),
    },
  },
})

// Valid paths
'health'         → health procedure
'users.list'     → users.list procedure
'users.get'      → users.get procedure
'v1.admin.stats' → v1.admin.stats procedure

// Invalid paths
'users'          → Not a procedure (nested router)
'foo'            → Not found
'users.foo'      → Not found
'health.foo'     → Not found (health is procedure, not router)

Security Considerations

Input Validation

  • All input is validated against schemas before reaching handlers
  • Validation errors are sanitized to prevent information leakage
  • Only safe fields (path, message, code) are exposed to clients

Error Sanitization

Internal errors are never exposed to clients:

ts
// Server throws
throw new Error('Database connection failed: host=db.internal password=secret')

// Client receives
{ "ok": false, "error": { "code": "INTERNAL_ERROR", "message": "An unexpected error occurred" } }

Authentication

  • Middleware runs before handlers for every request
  • WebSocket connections receive the original HTTP request for auth
  • Context is preserved across subscription lifetime

Rate Limiting

  • Applied at middleware level per procedure
  • Can be IP-based or user-based
  • Returns RATE_LIMITED error code with 429 status

Implementation Notes

Server Requirements

  • Bun runtime with native WebSocket support
  • Single endpoint for both HTTP and WebSocket
  • JSON parsing for all messages

Client Requirements

  • Fetch API for HTTP requests
  • WebSocket API for subscriptions
  • JSON serialization

Serialization

  • All data is JSON-serialized
  • Dates become ISO strings
  • BigInt, Symbol, functions are not supported
  • Consider using superjson for complex types
  • Client - Client implementation
  • Router - Server implementation
  • Types - TypeScript definitions

Released under the MIT License.