Skip to content

Repository files navigation

@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.

npm version npm downloads GitHub Packages Licence Tests Dependencies

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

npm install @hyvmind/drpc

Also mirrored to GitHub Packages as @hyvmind-io/drpc (GitHub Packages requires the scope to match the owning organization — same tarball, different name).

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 disconnects

serveOne 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) over node:net.
  • connectWebSocket(url) — client, on Node 26's built-in WebSocket, zero dependency.
  • upgradeToWebSocket(req, socket, head) / attachWebSocketServer(server) — a hand-rolled RFC 6455 server, no ws dependency.

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-es is a dependency of the code generator tool, never of the library or its generated output.
  • bigint for wire integers. Stream IDs, message IDs, lengths, and error codes are uint64 on the wire. A JS number loses precision above 2^53, and the value that exposes it — a long-lived connection's message counter — is exactly the one nobody tests.
  • AbortSignal is the context.Context equivalent. Cancellation and deadlines map directly; Go's context values are threaded explicitly instead of ambiently.
  • CSP channels. Go's goroutines and chan T are ported to async/await over a hand-rolled Channel<T> with a Go-faithful select.

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:

  1. Byte parity. A Go program using the real drpcwire emits canonical bytes for varints, frames, splits, and error bodies; the TS encoders reproduce all 35 vectors byte-for-byte.
  2. Live interop. A real Go dRPC server, called by this port's Conn over TCP — actual bytes through actual drpcwire framing on both peers.
  3. Generated stubs. Code generated from a .proto drives 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. 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

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.

About

drpc is a lightweight, drop-in replacement for gRPC

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages