Api

Browser & gRPC-Web Specifics

Manual frame handling, fetch-based streaming, and CORS notes for browser clients

This page is for browser clients that talk to Hydra App over gRPC-Web without an SDK wrapper — i.e. raw fetch calls with manual protobuf encoding and frame handling.

If you're building a server-side bot in Node, Go, Rust, or Python, you don't need this page. Use a real gRPC client (@grpc/grpc-js, tonic, etc.) — see the Bot Quickstart. gRPC-Web framing is a browser-only concern because browsers can't speak HTTP/2 trailers directly.

Why this is different

Native gRPC over HTTP/2 uses trailers and binary framing that browsers can't access via fetch. gRPC-Web is the workaround: each message is wrapped in a 5-byte length-prefixed frame and sent over HTTP/1.1 or HTTP/2 with regular headers.

If your client uses @grpc/grpc-js (Node) or @improbable-eng/grpc-web / nice-grpc-web / a generated protoc-gen-grpc-web client (browser), the framing is handled for you. You only need this page if you're calling Hydra App from the browser without one of those libraries — e.g. building your own minimal client or debugging frame-level issues.


gRPC-Web frame structure

Every gRPC-Web request and response message is wrapped in a 5-byte frame:

┌────────┬─────────────────────────┬─────────────────────────────┐
│ byte 0 │ bytes 1–4               │ bytes 5+                    │
│ flag   │ message length (uint32) │ protobuf-encoded message    │
│        │ big-endian              │                             │
└────────┴─────────────────────────┴─────────────────────────────┘
  • Byte 0 — compression flag. 0 = uncompressed, 1 = compressed. Hydra App always sends uncompressed; you should send 0.
  • Bytes 1–4 — message length as a 32-bit big-endian unsigned integer. NOT little-endian.
  • Bytes 5 onwards — the protobuf-serialised message itself.

Encoding a frame

function createGrpcWebFrame(messageBytes: Uint8Array): Uint8Array {
  const frame = new Uint8Array(5 + messageBytes.length)
  const view = new DataView(frame.buffer)

  view.setUint8(0, 0)                            // compression flag
  view.setUint32(1, messageBytes.length, false)  // length, big-endian
  frame.set(messageBytes, 5)                     // message body

  return frame
}

Parsing a response frame

function parseGrpcWebFrame(buffer: ArrayBuffer): Uint8Array {
  if (buffer.byteLength < 5) {
    throw new Error('response too small for a gRPC-Web frame')
  }
  const view = new DataView(buffer)
  const compressed = view.getUint8(0)
  if (compressed !== 0) {
    throw new Error('compressed responses are not supported by Hydra App')
  }
  const messageLength = view.getUint32(1, false)
  return new Uint8Array(buffer, 5, messageLength)
}

Server-streaming responses (e.g. SubscribeMarketEvents) send multiple frames concatenated in the response body. Parse them in a loop — see Streaming via fetch below.


A minimal unary call

Putting it together for app.GetNetworks:

import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"
import { GetNetworksRequest, GetNetworksResponse } from './proto/app'

const HYDRA_URL = 'http://localhost:5003'

async function getNetworks() {
  // 1. Encode the request.
  const reqBytes = GetNetworksRequest.encode({}).finish()

  // 2. Frame it.
  const frame = new Uint8Array(5 + reqBytes.length)
  const view  = new DataView(frame.buffer)
  view.setUint8(0, 0)
  view.setUint32(1, reqBytes.length, false)
  frame.set(reqBytes, 5)

  // 3. POST it. The path is /<package>.<Service>/<Method>.
  const response = await fetch(`${HYDRA_URL}/hydra_app.AppService/GetNetworks`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/grpc-web+proto',
      'Accept':       'application/grpc-web+proto',
      'X-Grpc-Web':   '1',
    },
    body: frame,
  })

  // 4. Check the gRPC status — see "Reading gRPC status" below.
  const grpcStatus = response.headers.get('grpc-status')
  if (grpcStatus !== null && grpcStatus !== '0') {
    throw new Error(`gRPC ${grpcStatus}: ${response.headers.get('grpc-message')}`)
  }

  // 5. Strip the 5-byte frame, decode the protobuf body.
  const buffer = await response.arrayBuffer()
  const body   = new Uint8Array(buffer, 5)
  return GetNetworksResponse.decode(new BinaryReader(body)).networks
}

Reading gRPC status

In gRPC-Web, the status code is delivered in two possible places:

WhereWhen
Response headers (grpc-status, grpc-message)Most successful and many error cases — set when the response can be returned in one shot.
Trailers embedded in the response bodyWhen the server has streamed data first and only knows the final status afterward. Trailers are encoded as a frame with byte 0 = 0x80.

For unary calls, headers are almost always set — a null grpc-status header generally means "implicit success" with the result in the body. Treat both grpc-status === '0' and grpc-status === null as success.

const grpcStatus = response.headers.get('grpc-status')
if (grpcStatus !== null && grpcStatus !== '0') {
  throw new Error(`gRPC ${grpcStatus}: ${response.headers.get('grpc-message')}`)
}

For the full list of status codes and what they mean for retry decisions, see Errors.


Streaming via fetch

SubscribeNodeEvents, SubscribeMarketEvents, SubscribeSimpleSwaps, etc. are all server-streaming. Over gRPC-Web, the server keeps the response body open and writes one frame per event. You read the body progressively with a ReadableStream:

import { BinaryReader } from "@bufbuild/protobuf/wire"
import {
  SubscribeNodeEventsRequest,
  NodeEvent,
} from './proto/event'

async function subscribeNodeEvents(network: Network, onEvent: (e: NodeEvent) => void) {
  const reqBytes = SubscribeNodeEventsRequest.encode({ network }).finish()
  const frame    = new Uint8Array(5 + reqBytes.length)
  const view     = new DataView(frame.buffer)
  view.setUint8(0, 0)
  view.setUint32(1, reqBytes.length, false)
  frame.set(reqBytes, 5)

  const response = await fetch(`${HYDRA_URL}/hydra_app.EventService/SubscribeNodeEvents`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/grpc-web+proto',
      'Accept':       'application/grpc-web+proto',
      'X-Grpc-Web':   '1',
    },
    body: frame,
  })
  if (!response.body) throw new Error('no streaming body')

  const reader = response.body.getReader()
  let pending  = new Uint8Array()

  while (true) {
    const { done, value } = await reader.read()
    if (done) return
    if (!value) continue

    // Append the new chunk to whatever was left over from before.
    const merged = new Uint8Array(pending.length + value.length)
    merged.set(pending, 0)
    merged.set(value, pending.length)
    pending = merged

    // Pull every complete frame out of `pending`.
    while (pending.length >= 5) {
      const flag   = pending[0]
      const length = new DataView(pending.buffer, pending.byteOffset, 5).getUint32(1, false)
      if (pending.length < 5 + length) break  // wait for more bytes

      const body = pending.subarray(5, 5 + length)
      pending    = pending.subarray(5 + length)

      if (flag === 0x80) {
        // Trailer frame — body is `\r\n`-separated headers including grpc-status.
        // Stream is ending. Parse if you care; here we just stop.
        return
      }
      onEvent(NodeEvent.decode(new BinaryReader(body)))
    }
  }
}

Three things to call out:

  1. A single reader.read() chunk may contain part of a frame, multiple complete frames, or a frame split across chunks. The buffering loop above handles all three.
  2. The trailer frame has flag byte 0x80 and contains the final grpc-status text. Most browser clients treat it as "stream end" and call it a day; only parse it if you care about distinguishing graceful end from an error tail.
  3. Reconnection is your responsibility — if the connection drops mid-stream, you have to re-issue the request. See the Streaming guide for the full reconnection pattern.

CORS

The browser will send a CORS preflight (OPTIONS) before any cross-origin gRPC-Web call. Hydra App responds correctly to this by default — but if you front it with a custom proxy or run the app on a different origin from your client, make sure the proxy preserves these response headers:

HeaderRequired value
Access-Control-Allow-OriginYour client origin (or * for development)
Access-Control-Allow-MethodsPOST, OPTIONS
Access-Control-Allow-HeadersContent-Type, X-Grpc-Web, X-User-Agent, Authorization
Access-Control-Expose-Headersgrpc-status, grpc-message

Without Access-Control-Expose-Headers: grpc-status, grpc-message, the browser will hide the gRPC status from your code — every successful call will look like an empty body and every error will look like a generic network error. This is the single most common gRPC-Web debugging issue.


When to skip all of this

Most people shouldn't be writing manual gRPC-Web framing code. Reach for one of these instead:

You want…Use
A browser client with generated stubsprotoc-gen-grpc-web (official) or nice-grpc-web (modern). Both handle framing.
A Node.js client@grpc/grpc-js — native gRPC, no Web framing involved.
A polyglot codegen flowBufbuf generate against the protos in /proto and you get TS / Go / Rust / Python at once. See Setup Step 5.

Use the manual approach only when:

  • You're building a library that wraps gRPC-Web for others.
  • You're debugging a frame-level issue (wrong byte order, missing CORS header, malformed trailer).
  • You can't add a build step and need a single hand-rolled fetch call to make one specific RPC from the browser.

See also


Copyright © 2025