Files
sync-server-v2/docs/demo-app-transport-migration.md
T

5.3 KiB

Demo App Transport Migration

This guide describes the client-facing protocol for the route-agnostic transport architecture.

Connection Model

HTTP, SSE, and WebSocket do not authenticate at the transport layer. Do not send request-auth headers such as X-PublicKey, X-Signature, or X-Timestamp for normal route dispatch.

Authentication is scoped to each resource operation. Every written resource carries its own publicKey, timestamp, and signature.

All route input belongs in the request body. Paths are exact route names; there are no path parameters, query parameters, or alternate HTTP methods.

Request Size Limit

The server accepts at most 1048576 encoded bytes per HTTP request body or complete WebSocket message by default. Deployments can change this with SERVER_MAX_REQUEST_BODY_BYTES. The count is over the Extended JSON wire text, so hexadecimal Uint8Array values use approximately twice their decoded byte length.

HTTP limits use Hono's built-in bodyLimit middleware and return 413. WebSocket messages do not pass through HTTP middleware; Hono's Node adapter uses ws, whose native maxPayload option rejects oversized frames before the application message handler. Response bodies are not limited.

HTTP Requests

All application HTTP routes use POST.

Read:

POST /data/get
Content-Type: application/json

{
  "resourceId": ["resource-a", "resource-b"]
}

Write:

POST /data/write
Content-Type: application/json

{
  "resources": [
    {
      "id": "resource-a",
      "publicKey": "...hex...",
      "timestamp": 1730000000000,
      "signature": "...der-hex...",
      "value": "<Uint8Array: deadbeef>"
    }
  ]
}

Write response:

{
  "resources": [
    {
      "id": "resource-a",
      "publicKey": "...hex...",
      "blob": "<Uint8Array: deadbeef>",
      "timestamp": 1730000001000
    }
  ]
}

If any item has invalid authorization, the whole batch fails.

SSE Subscriptions

The client explicitly requests SSE using Accept: text/event-stream:

POST /data/subscribe
Accept: text/event-stream
Content-Type: application/json

{
  "resourceId": ["resource-a"]
}

A subscription without this Accept header receives a 406 JSON error.

The server keeps the SSE request open while its resource topics remain subscribed. Subscribing sends no current state; it only enables future publications. Request current state separately through /data/get when needed.

SSE publication:

id: 1730000001000
event: instance-changed
data: {"resourceId":"resource-a"}

After SSE begins, failures arrive as error events and the server closes that SSE connection:

event: error
data: {"statusCode":500,"error":"Internal Server Error"}

To change an SSE subscription, abort the existing request and open a new /data/subscribe request with the complete desired resourceId list.

WebSocket

Connect without a connection-auth message:

const ws = new WebSocket("ws://host:port/ws");

Every client message uses this strict envelope. Messages may execute concurrently; the server binds id and body to each individual dispatch so responses remain correctly correlated even when they finish out of order:

type WsRequest = {
  id?: string;
  path: string;
  body?: unknown;
};

Old fields such as event, type, url, method, headers, params, and data are rejected.

Read:

{
  "id": "read-1",
  "path": "/data/get",
  "body": {
    "resourceId": ["resource-a", "resource-b"]
  }
}

Write:

{
  "id": "write-1",
  "path": "/data/write",
  "body": {
    "resources": [
      {
        "id": "resource-a",
        "publicKey": "...hex...",
        "timestamp": 1730000000000,
        "signature": "...der-hex...",
        "value": "<Uint8Array: deadbeef>"
      }
    ]
  }
}

Subscribe:

{
  "id": "sub-1",
  "path": "/data/subscribe",
  "body": {
    "resourceId": ["resource-a"]
  }
}

The subscribe request sends no immediate message. Its dispatch remains active until those topics are removed or the connection closes. Request current state separately through /data/get when needed.

The client should consolidate its local subscribers and send only the topic set needed by the shared WebSocket.

Unsubscribe selected resources without closing the socket:

{
  "id": "unsub-1",
  "path": "/data/unsubscribe",
  "body": {
    "resourceId": ["resource-a"]
  }
}

Unsubscribe is idempotent. The response is a normal correlated response:

{
  "id": "unsub-1",
  "type": "response",
  "statusCode": 200,
  "body": {}
}

Normal response:

{
  "id": "write-1",
  "type": "response",
  "statusCode": 200,
  "body": {}
}

Error response:

{
  "id": "write-1",
  "type": "error",
  "statusCode": 400,
  "error": "Validation Error",
  "details": []
}

Message-level errors do not close the WebSocket. The client decides whether to retry, alter its subscription, or reconnect.

Published events retain the shared stream shape:

{
  "id": "server-event-id",
  "type": "instance-changed",
  "data": {}
}

Signing

For each written resource, sign this canonical payload:

`${timestamp}${resourceId}${canonicalBody(value)}`;

value is the Uint8Array payload. canonicalBody(value) uses the same Extended JSON rules as the server:

"<Uint8Array: deadbeef>"