Client Transports
The vgi-rpc client library provides HTTP, HTTP-over-Iroh, raw Iroh, subprocess, pipe, and raw-TCP connect functions that all return the same RpcClient interface. Pick the transport that fits your deployment and use call(), stream(), describe(), and close() identically across all of them.
RpcClient interface
Section titled “RpcClient interface”interface RpcClient { call(method: string, params?: Record<string, any>): Promise<Record<string, any> | null>; stream(method: string, params?: Record<string, any>): Promise<StreamSession>; describe(): Promise<ServiceDescription>; close(): void;}HTTP Transport
Section titled “HTTP Transport”Use httpConnect to talk to a running HTTP server:
import { httpConnect } from "@query-farm/vgi-rpc";
const client = httpConnect("http://localhost:8080", { prefix: "/vgi", // URL path prefix (default: "" — root) compressionLevel: 3, // zstd compression (omit to disable) authorization: "Bearer <token>", // sent as the Authorization header onLog: (msg) => console.log(`[${msg.level}] ${msg.message}`),});
const result = await client.call("add", { a: 2, b: 3 });console.log(result); // { result: 5 }
client.close();HTTP transport is stateless — stream continuity is managed via XChaCha20-Poly1305 AEAD-sealed state tokens exchanged in batch metadata.
The authorization option (HTTP only) sets the Authorization request header. All three connect functions also accept an externalLocation option for configuring out-of-band data transfer locations.
HTTP over Iroh (Node/Bun)
Section titled “HTTP over Iroh (Node/Bun)”httpiConnect runs the complete VGI HTTP client over the iroh-http/2 ALPN.
Install the optional native binding first:
bun add @momics/iroh-http-nodeThen connect with the worker’s canonical 64-lowercase-hex EndpointId:
import { httpiConnect } from "@query-farm/vgi-rpc";
const client = await httpiConnect( "httpi://0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef/vgi", { directAddresses: ["192.0.2.10:4433"], // optional discovery hints remoteRelayUrl: "https://relay.example", requestTimeoutMs: 30_000, authorization: "Bearer <token>", },);
const result = await client.call("add", { a: 2, b: 3 });client.close();The connector converts the canonical hex EndpointId to iroh-http’s native
base-32 hostname internally. OPTIONS capability discovery, VGI continuations,
authorization, compression, response-size negotiation, and external-location
fetches all remain handled by the ordinary HTTP client. The native adapter
limits each HTTP response to 256 MiB, so acceptedMaxResponseBytes cannot be
configured above that value.
By default the connector creates, owns, and closes an Iroh node. Pass node to
reuse an application-owned node; it remains open unless closeNode: true is
set. Use externalFetch when external-location URLs need a custom non-Iroh
fetch implementation.
This binding targets Node/Bun. It is not bundled into browser entry points; browser Iroh support uses the Rust/WASM connector.
Raw Iroh (Node/Bun)
Section titled “Raw Iroh (Node/Bun)”Use irohConnect("iroh://<64-hex-endpoint-id>") with the optional
@number0/iroh native binding when both peers speak VGI Arrow mux directly.
Unlike HTTP-over-Iroh, this is connection-stateful and does not use the HTTP
OPTIONS or continuation protocol.
SOCKS5h (Node/Bun)
Section titled “SOCKS5h (Node/Bun)”The Node/Bun entry point provides explicit credential-free SOCKS5h constructors for HTTP and raw TCP:
import { httpConnectSocks5h, tcpConnectSocks5h } from "@query-farm/vgi-rpc";
const http = httpConnectSocks5h( "https://worker.example.internal", "socks5h://127.0.0.1:1080", { connectTimeoutMs: 5_000, requestTimeoutMs: 300_000, maxResponseBytes: 268_435_456, maxResponseHeaderBytes: 65_536, signal: abortController.signal, },);
const tcp = await tcpConnectSocks5h( "worker.example.internal", 9400, "socks5h://127.0.0.1:1080", { connectTimeoutMs: 5_000, signal: abortController.signal },);Only the SOCKS NO AUTH method is offered. Proxy URIs with credentials, paths, queries, or fragments are rejected before dialing. Target domains are IDNA-normalized and sent as SOCKS domain names for proxy-side resolution; IPv4 and IPv6 literals use their native SOCKS address types. One request deadline covers setup, request writes, and the complete response; the smaller setup deadline additionally caps proxy TCP connection, the complete partial-I/O-safe negotiation, and (for HTTPS) TLS setup. Failure or cancellation destroys the socket and never retries directly. TCP_NODELAY is enabled.
The HTTP adapter buffers one response under maxResponseBytes and independently bounds its headers with maxResponseHeaderBytes. It accepts exactly one Content-Length or one final Transfer-Encoding: chunked, rejects ambiguous/conflicting framing, and does not support streaming request bodies. Tune the limits to the largest VGI batch your worker is expected to return.
httpConnectSocks5h routes every HTTP operation owned by the RPC client through the adapter: description, unary and streaming turns, upload-URL acquisition and PUT, and external-location GETs. It does not consult HTTP_PROXY, HTTPS_PROXY, or NO_PROXY. A custom ExternalStorage.upload() implementation is application code rather than an SDK HTTP boundary and is not intercepted.
There is no SOCKS server/listener mode: SOCKS is an outbound dialer. Browser and Worker entry points omit it because their fetch implementations do not expose a TCP dialer.
Subprocess Transport
Section titled “Subprocess Transport”Use subprocessConnect to spawn a server process and communicate over its stdin/stdout pipes:
import { subprocessConnect } from "@query-farm/vgi-rpc";
const client = subprocessConnect(["bun", "run", "server.ts"], { cwd: "./my-project", // working directory env: { DEBUG: "1" }, // extra environment variables stderr: "inherit", // "inherit" | "pipe" | "ignore" (default: "ignore") onLog: (msg) => console.log(msg),});
const result = await client.call("greet", { name: "World" });console.log(result); // { result: "Hello, World!" }
client.close(); // kills the subprocessPipe Transport
Section titled “Pipe Transport”Use pipeConnect for low-level control when you already have a ReadableStream and writable sink:
import { pipeConnect } from "@query-farm/vgi-rpc";
const client = pipeConnect(readable, writable, { onLog: (msg) => console.log(msg),});
const result = await client.call("echo", { text: "hello" });client.close();The pipe transport is single-threaded: only one call() or stream() operation can be in flight at a time. Attempting a concurrent operation throws an error.
Unary Calls
Section titled “Unary Calls”All transports use the same call() method for unary requests:
const result = await client.call("add", { a: 2, b: 3 });// result: { result: 5 }- Default parameter values from the method definition are applied automatically.
- Void methods (empty result schema) return
null.
Streaming
Section titled “Streaming”Producer (async iteration)
Section titled “Producer (async iteration)”For server-streaming methods, use stream() and iterate:
const session = await client.stream("count", { limit: 5 });
// Access the stream header (if the method defines headerSchema)if (session.header) { console.log("Header:", session.header);}
// Iterate over output batchesfor await (const rows of session) { console.log(rows); // [{ n: 0, n_squared: 0 }, { n: 1, n_squared: 1 }, ...]}Exchange (bidirectional)
Section titled “Exchange (bidirectional)”For exchange methods, use exchange() to send input batches and receive output:
const session = await client.stream("scale", { factor: 2 });
const output = await session.exchange([{ value: 10 }]);console.log(output); // [{ value: 20 }]
const output2 = await session.exchange([{ value: 5 }]);console.log(output2); // [{ value: 10 }]
session.close();StreamSession interface
Section titled “StreamSession interface”interface StreamSession { readonly header: Record<string, any> | null; exchange(input: Record<string, any>[]): Promise<Record<string, any>[]>; [Symbol.asyncIterator](): AsyncIterableIterator<Record<string, any>[]>; close(): void;}Service Introspection
Section titled “Service Introspection”Every RpcClient has a describe() method:
const desc = await client.describe();console.log(desc.protocolName); // "Calculator"console.log(desc.protocolVersion); // server-declared protocol version
for (const method of desc.methods) { console.log(`${method.name} (${method.type})`); // method.paramsSchema, method.resultSchema, and (for stream methods) // method.inputSchema / method.outputSchema / method.headerSchema}Listing and describing hosted protocols
Section titled “Listing and describing hosted protocols”A server may host several protocols: its application protocols, then the
framework’s own (vgi_rpc.Reflection.v1, and vgi_rpc.Identity.v1 on an HTTP
server that hosts it). Ask through the client you already hold, on any
transport, with listProtocols() and describeProtocol():
import { describeProtocol, listProtocols, ReflectionNotSupportedError, tcpConnect } from "@query-farm/vgi-rpc";
const client = tcpConnect("127.0.0.1", 9000);try { for (const p of await listProtocols(client)) { console.log(p.name, p.version, p.hash, p.deprecated, p.features); } const secondary = await describeProtocol(client, "conformance.Secondary.v1"); console.log(secondary.methods.map((m) => m.name)); await client.call("echo_string", { value: "still mine" }); // the connection is still yours} catch (e) { if (e instanceof ReflectionNotSupportedError) { // The server does not host reflection; the connection is still usable. } else { throw e; }} finally { client.close();}Both reuse the client’s own connection and never close it. A pipe,
subprocess, TCP or raw Iroh client sends the reflection request down its
existing byte stream (the server routes each request by its protocol key), so,
like any other call on such a client, not while a stream is open on it. An HTTP
or HTTP-over-Iroh client sends it through its own fetch, prefix,
authorization, session scope, compression and response budget. The client may
be bound to any protocol the server hosts; only its connection matters.
listProtocols() returns one frozen HostedProtocol (name, version,
hash, deprecated, deprecationMessage, features) per hosted protocol, in
the server’s order: application protocols in registration order, the primary
first, then the framework’s. hash is the protocol’s canonical SHA-256,
identical in every port for the same wire surface, so a client holding a
cached description for that hash can skip describeProtocol().
describeProtocol(client, name) makes two calls, list_protocols and then
describe(name). A name the server does not host is an ordinary RpcError
with errorKind === "protocol_not_supported".
Servers without reflection. A TypeScript server built with
enableDescribe: false (the default is true), a Python server built without
enable_describe=True (there the default is off), and servers older than
reflection answer “not hosted”: protocol_not_supported, an unknown method,
UNIMPLEMENTED, or over HTTP a bare 404. Both functions throw
ReflectionNotSupportedError for that answer, an RpcError subclass carrying
the server’s error fields, and never infer a listing. The connection remains
usable. Any other failure propagates as itself.
For standalone HTTP introspection without creating a full client:
import { httpIntrospect } from "@query-farm/vgi-rpc";
const desc = await httpIntrospect("http://localhost:8080", { prefix: "/vgi" });For custom transports, parse raw describe batches:
import { parseDescribeResponse } from "@query-farm/vgi-rpc";
const desc = await parseDescribeResponse(batches, onLog);Log Messages
Section titled “Log Messages”All transports accept an onLog callback that receives log messages sent by the server during method execution:
import { httpConnect } from "@query-farm/vgi-rpc";
const client = httpConnect("http://localhost:8080", { onLog: (msg) => { console.log(`[${msg.level}] ${msg.message}`); if (msg.extra) console.log(" extra:", msg.extra); },});interface LogMessage { level: string; message: string; extra?: Record<string, any>;}