Browser & gRPC-Web Specifics
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 send0. - 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:
| Where | When |
|---|---|
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 body | When 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:
- 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. - The trailer frame has flag byte
0x80and contains the finalgrpc-statustext. 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. - 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:
| Header | Required value |
|---|---|
Access-Control-Allow-Origin | Your client origin (or * for development) |
Access-Control-Allow-Methods | POST, OPTIONS |
Access-Control-Allow-Headers | Content-Type, X-Grpc-Web, X-User-Agent, Authorization |
Access-Control-Expose-Headers | grpc-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 stubs | protoc-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 flow | Buf — buf 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
fetchcall to make one specific RPC from the browser.
See also
- Bot Quickstart — server-side bot template (no manual framing)
- Streaming & Subscriptions — reconnection, dedup, ordering
- Errors — full gRPC status code catalog
- API Reference — complete proto reference