@hyvmind/drpc
v1.0.0
Published
A faithful Node/TypeScript implementation of the dRPC wire protocol.
Downloads
456
Readme
@hyvmind/drpc
A complete Node/TypeScript port of Storj's dRPC — the wire
protocol, the client and server, streaming, connection pooling, code generation from .proto,
and the HTTP/Twirp/grpc-web gateway. Every package in storj.io/drpc has a counterpart here,
and the port is verified 1:1 against the real Go implementation: byte-identical wire framing and
a live TypeScript-client ↔ Go-server round trip.
This is unrelated to mjpitz/drpc-node, the Node
implementation Storj's own README lists under "Other Languages" as Incomplete. Different
author, different code, no shared history.
Why dRPC
dRPC is Storj's replacement for gRPC — a small, dependency-light RPC protocol over any bidirectional byte stream. This port exists because the browser and L7 ingresses cannot pass raw TCP, but they do pass WebSocket upgrades and HTTP, and dRPC's framing rides over any of them unchanged. The Go implementation is roughly 3,600 lines with a documented wire format, which is what makes a faithful, verifiable port tractable rather than a research problem.
Install
Not yet published to npm. Until it is, consume it from the repository — pin a commit, since this is pre-1.0 and every revision is a breaking-change channel:
npm install github:hyvmind-io/drpc#<commit>Requires Node 26 or newer. The library has zero runtime dependencies.
Quickstart
A real unary call and a bidirectional stream, client to server, over an in-memory pipe (swap the pipe for TCP or WebSocket and nothing else changes). This is lifted from the test suite — it runs as written.
import {
Conn, Server, Mux, createMemoryPipe,
type Encoding, type DRPCDescription, type DRPCReceiver, type MethodInfo,
} from "@hyvmind/drpc";
const te = new TextEncoder();
const td = new TextDecoder();
// An Encoding is per-message-type: marshal a value to bytes, unmarshal bytes back. Bring your
// own protobuf runtime, or — as here — a trivial identity byte codec.
const bytes: Encoding<Uint8Array> = { marshal: (m) => m, unmarshal: (b) => b };
// A service description maps RPC names to their encoding + handler. `protoc-gen-drpc-ts`
// generates these; by hand it is a small array.
function description(methods: MethodInfo[]): DRPCDescription {
return { numMethods: () => methods.length, method: (n) => methods[n] };
}
function method(rpc: string, receiver: DRPCReceiver): MethodInfo {
return { rpc, encoding: bytes, receiver, method: undefined };
}
// --- server -------------------------------------------------------------------------------
const mux = new Mux();
mux.register({}, description([
method("/echo.Service/Echo", async (_srv, _signal, input) => input), // unary echo
]));
const [clientEnd, serverEnd] = createMemoryPipe();
const server = new Server(mux);
const serving = server.serveOne(serverEnd);
// --- client -------------------------------------------------------------------------------
const conn = new Conn(clientEnd);
const response = await conn.invoke("/echo.Service/Echo", bytes, te.encode("hello dRPC"), bytes);
console.log(td.decode(response)); // "hello dRPC"
await conn.close();
await serving.catch(() => {}); // serveOne drains one transport; it ends when the client disconnectsserveOne handles a single already-accepted transport. To accept many connections off a
listener, use server.serve(acceptor, signal?) — the port of Go's Serve(ctx, net.Listener),
where an Acceptor is anything with accept(): Promise<Transport | undefined> and close()
(drpcmigrate's routed listeners satisfy it). It retries temporary accept errors, logs
per-connection failures via ServerOptions.log, and shuts down cleanly when signal aborts.
Streaming uses conn.newStream and the stream's send/recv:
const stream = await conn.newStream("/echo.Service/EchoStream");
await stream.send(te.encode("one"), bytes);
await stream.send(te.encode("two"), bytes);
await stream.closeSend();
for (;;) {
const msg = await stream.recv(bytes).catch(() => undefined);
if (msg === undefined) break;
console.log(td.decode(msg));
}What's here
Every storj.io/drpc package, ported and tested.
| Area | Modules | Ports |
| --- | --- | --- |
| Wire | frame, varint, reader, writer, split | drpcwire |
| RPC core | conn, stream, manager, mux, server, pool | drpcconn, drpcstream, drpcmanager, drpcmux, drpcserver, drpcpool |
| Contracts | types, errors, encoding, metadata | drpc.go, drpcerr, drpcenc, drpcmetadata |
| Primitives | signal, channel, mutex, tracker | drpcsignal, drpcctx.Tracker |
| Aux | cache, stats, debug | drpccache, drpcstats, drpcdebug |
| Transports | in-memory pipe, TCP, WebSocket client + server | (net.Conn adapters) |
| HTTP gateway | http — Twirp + grpc-web over node:http | drpchttp |
| Listener mux | migrate — one port, many protocols by byte prefix | drpcmigrate |
| Codegen | protoc-gen-drpc-ts — .proto → TS service stubs | cmd/protoc-gen-go-drpc |
Transports
dRPC runs over any bidirectional byte stream. A Transport is deliberately as small as Go's
io.Reader + io.Writer + io.Closer:
interface Transport {
read(): Promise<Uint8Array | undefined>; // undefined = clean EOF
write(data: Uint8Array): Promise<void>;
close(): Promise<void>;
}Four ship in the box:
createMemoryPipe()— a connected pair, with configurable chunking and fault injection. The test fixture that lets the whole stack run with no network.fromNodeSocket(socket)/connectNodeSocket(opts)— TCP (and TLS) overnode:net.connectWebSocket(url)— client, on Node 26's built-inWebSocket, zero dependency.upgradeToWebSocket(req, socket, head)/attachWebSocketServer(server)— a hand-rolled RFC 6455 server, nowsdependency.
HTTP, Twirp, and grpc-web
createHttpHandler turns a dRPC handler into a node:http request listener, so a browser,
Twirp, or grpc-web client can call your service over plain HTTP — the reason this port targets an
L7-friendly carrier at all.
import { createServer } from "node:http";
import { createHttpHandler } from "@hyvmind/drpc";
createServer(createHttpHandler(mux)).listen(8080);The protocol is chosen by Content-Type: application/proto and application/json are Twirp
(unary); application/grpc-web+proto, +json, and the -text base64 variants are grpc-web
(unary + server-streaming). Errors become a Twirp JSON {code, msg} with a mapped HTTP status,
or a grpc-web grpc-status trailer. See docs/http-gateway.md.
One port, many protocols
ListenMux serves dRPC alongside gRPC or HTTP on a single TCP port, routing each connection on
its first bytes — a client that writes DRPC_HEADER reaches the dRPC route, everything else falls
through to a default. See docs/listener-mux.md.
Code generation
protoc-gen-drpc-ts (under tools/) is a protoc/buf plugin that generates a typed client, a
server interface, and a service description from a .proto. Generated code takes its Encoding
by injection, so it imports no protobuf runtime — the library stays zero-dependency even with
codegen in play. See docs/codegen.md.
Design
- Zero runtime dependencies. Node built-ins only.
protobuf-esis a dependency of the code generator tool, never of the library or its generated output. bigintfor wire integers. Stream IDs, message IDs, lengths, and error codes areuint64on the wire. A JSnumberloses precision above 2^53, and the value that exposes it — a long-lived connection's message counter — is exactly the one nobody tests.AbortSignalis thecontext.Contextequivalent. Cancellation and deadlines map directly; Go's context values are threaded explicitly instead of ambiently.- CSP channels. Go's goroutines and
chan Tare ported toasync/awaitover a hand-rolledChannel<T>with a Go-faithfulselect.
Full architecture: docs/architecture.md.
Verification
Correctness means agreeing with Go, not with itself. Three checks prove it, all run against the
real vendored storj.io/drpc:
- Byte parity. A Go program using the real
drpcwireemits canonical bytes for varints, frames, splits, and error bodies; the TS encoders reproduce all 35 vectors byte-for-byte. - Live interop. A real Go dRPC server, called by this port's
Connover TCP — actual bytes through actualdrpcwireframing on both peers. - Generated stubs. Code generated from a
.protodrives a real unary and streaming RPC.
Plus 515 unit and end-to-end tests over an in-memory pipe, real TCP, real WebSocket, and real
HTTP. The interop harness lives in interop/ (Go, test-only, never packaged); its byte fixtures
are committed so the parity tier runs without a Go toolchain.
npm test # typecheck + the full suite (Node 26)
npm run build # emit dist/Status
The runtime and codegen are complete and verified 1:1 against Go. Pre-1.0: the API may still change, and it has not been published to npm. See docs/parity.md for the package-by-package parity map and the two deliberate divergences (Go's ambient context values, and non-UTF-8 metadata in a UTF-16 string).
Documentation
- Getting started
- Transports
- Streaming
- HTTP / Twirp / grpc-web gateway
- Listener multiplexing
- Code generation
- Architecture
- Go parity
Built as a site with mkdocs serve (see mkdocs.yml).
Licence
MIT, matching upstream storj/drpc. See LICENSE. The licence file carries two copyright lines: this is a port, so Storj's notice is retained alongside mine, as MIT's notice clause requires.
