@lesomnus/grpc-dgram
v0.1.0
Published
gRPC over unreliable datagram channels — TypeScript port of gRPC-dgram (the dRPC wire protocol, docs/PROTOCOL.md)
Maintainers
Readme
@lesomnus/grpc-dgram
TypeScript port of gRPC-dgram: gRPC-style RPC over unreliable datagram
channels, implementing the dRPC wire protocol (../docs/PROTOCOL.md).
Wire-compatible with the Go implementation — the §5 golden byte vectors are
shared between the two test suites — so a TS client interoperates with a Go
server and vice versa.
- Zero runtime dependencies. The three wire messages (
Frame,Envelope,Metadata) are hand-encoded; user payloads go through pluggable per-method marshallers (protobuf-es, JSON, anything that produces bytes). A protobuf-es binding (@lesomnus/grpc-dgram/transport/protobuf-es, optional peer dep) derives the whole method descriptor — path, streaming kind, codec — from a generatedprotoc-gen-esservice, so RPC types are never re-declared by hand. The core itself stays dependency-free. - Both endpoints.
Conn(client) andServer, with the full unreliable- mode machinery: seq windows and dedup, epoch/peer_epochincarnation isolation, control-frame retransmission, tombstones + aged watermark, PING/probe liveness, and the §15 resource caps. On a reliable transport all timers are off, sequencing is strict fail-loud (§10.6), and flow control (§4.2.1) keeps one slow consumer from stalling the other calls on the channel — the browser case where a blocked receive path would otherwise wedge the whole event loop: a credit window per stream, and beside it a connection window per peer (limits.maxPeerWindow, 1024 messages by default, advertised on every OPEN by aConnand on every H and T by aServer) that bounds what one peer can pin across all of its calls, as HTTP/2's does. - gRPC-fidelity surface. Binary metadata (
-binkeys carry arbitrary octets; base64 at the TS API, raw bytes on the wire), rich status details on the terminal frame, per-message compression (gzipvia the platform'sCompressionStream, never expanding a payload), per-call recv/send size caps, and the shape/modifier flag split (§7.1) — an unimplemented flag bit fails the call instead of silently corrupting or gapping it. - WebSocket adapter (
@lesomnus/grpc-dgram/transport/websocket), the TS twin of the Gotransport/gorillaadapter: reliable mode, one Envelope per message, and the §4.5 teardown duty carried byonclose/onerrorplus a keepalive — browser-safe (WhatWGWebSocket,binaryType='arraybuffer').dialWebSocket(url)is aConnin one line;new WebSocketTransport(ws)is the path for a socket you brought yourself. - WebTransport datagram adapter (
@lesomnus/grpc-dgram/transport/webtransport), the TS twin of the Gotransport/webtransportadapter: unreliable mode over the session's datagrams, one Envelope per datagram — the browser's datagram channel with no signaling, a URL and nothing else.dialWebTransport(url)is aConnin one line (https://only;serverCertificateHashespins a development certificate),new WebTransportDatagramTransport(wt)the path for a session you brought. The size ceiling follows the session's owndatagrams.maxDatagramSize(1200 B when it reports none), and the §4.5 teardown comes from the session —closedsettling,readyrejecting — not the read loop. Client only: Node has noWebTransport, so the server side is the Go module. - Message-port adapter (
@lesomnus/grpc-dgram/transport/port), the TS twin of the Gotransport/jsportadapter:PortTransport/PortGatewayover anything withpostMessageand amessageevent — aMessagePort, aWorker, a worker's ownself— plusdialWorker(worker), which transfers a port to a worker of yours and hands back aConn, one independent peer per call. Always reliable, since a port in one process loses nothing, and since nothing dies that a port can report, teardown is spoken: an empty message is the goodbye, andclose(cause)carries what only the host knows (a wasm instance that exited, a terminated worker). - A Go server in the browser (
@lesomnus/grpc-dgram/wasm):open()starts a Godrpc.Servercompiled tojs/wasm— in a Worker this package ships, so a computing handler does not freeze the page — and hands back connections to it. It is the JS half ofjsport.Gateway.Serve: the readiness publish, the instantiation, one channel perdial(), and the §4.5 teardown a dying instance cannot perform for itself.../examples/browser-wasmis it running, andtest/wasm.test.tsdrives a real Go server through it. - WebRTC DataChannel adapter (
@lesomnus/grpc-dgram/transport/webrtc), the TS twin of the Gotransport/pionadapter: the protocol mode is derived from the channel's own configuration — an ordered channel with no retransmit or lifetime cap runs reliable, anything else unreliable. ClientTransportand a mixed-mode serverGateway(a reliable control channel and unreliable telemetry channels on one peer connection serve side by side, each peer in its channel's mode). - Node UDP adapter (
@lesomnus/grpc-dgram/transport/node-udp, Node only), the TS twin of the Gotransport/udpadapter —UdpTransport/UdpGateway, withdialUdp(a connected socket and aConnover it, in one line) andlistenUdp. A TS client over this adapter interoperates with a Godrpc.Serveron the wire; this is exercised by a cross-language conformance test (test/conformance.test.ts) that drives a real Go server. - Connect-ES transport (
@lesomnus/grpc-dgram/transport/connect, optional peer dep on@connectrpc/connect): use the standardcreateClient(Service, transport)ergonomics while the traffic runs over drpc.createDrpcTransport(conn)turns a dRPCConninto a ConnectTransport— the conformance suite drives a real Godrpc.Serverthrough a Connect client end to end.
Usage
Getting a connection. Four cases, and the verb says which:
new Conn(new XTransport(ch), opts) when you already hold the channel;
dialUdp / dialWebSocket / dialWebTransport / dialWorker when the
library should make it —
each hands back a Conn, and takes one options bag, since ConnOptions and an
adapter's own options share no key; open(app) when there is no peer yet,
which returns a Sock whose dial() is the connection; and, on the serving
side, a Gateway — listenUdp where the library opens the socket, then
serve when the gateway owns the whole endpoint, or bind + servePeer per
channel handed in from outside.
Transports spells out the whole rule.
With protobuf-es (recommended). Point the binding at a generated service —
paths, streaming kinds, and codecs are all derived from the .proto, so
there is nothing to keep in sync and a TS client interoperates with a Go
server addressing the same methods:
import { fromService } from '@lesomnus/grpc-dgram/transport/protobuf-es'
import { create } from '@bufbuild/protobuf'
import { EchoService, EchoRequestSchema } from './echo_pb' // protoc-gen-es output
const Echo = fromService(EchoService) // { once, many, count, live }, fully typed
await conn.invoke(Echo.once, create(EchoRequestSchema, { text: 'hi' }))
server.register(Echo.once, (req) => create(EchoResponseSchema, { text: req.text }))With a Connect client. If you already use Connect-ES, keep its client ergonomics and swap only the transport:
import { createClient } from '@connectrpc/connect'
import { createDrpcTransport } from '@lesomnus/grpc-dgram/transport/connect'
import { EchoService } from './echo_pb'
const client = createClient(EchoService, createDrpcTransport(conn))
await client.once({ message: 'hi' }) // unary
for await (const m of client.many({ message: 'x' })) // server streamingWithout codegen. Describe methods explicitly with any byte serializer (the core is codec-agnostic — this is what the test suite uses):
import { unaryMethod, bidiMethod, type PayloadCodec } from '@lesomnus/grpc-dgram'
const json = <T>(): PayloadCodec<T> => ({
marshal: (v) => new TextEncoder().encode(JSON.stringify(v)),
unmarshal: (b) => JSON.parse(new TextDecoder().decode(b)),
})
const Once = unaryMethod<Req, Res>('/echo.Echo/Once', { request: json(), response: json() })
const Live = bidiMethod<Req, Res>('/echo.Echo/Live', { request: json(), response: json() })Against a Go server compiled to wasm. The page starts it and calls it; the
Worker, the wasm_exec.js load, the readiness handshake and the teardown are
open()'s (src/wasm, ../transport/jsport on the Go side):
import { open } from '@lesomnus/grpc-dgram/wasm'
const sock = await open('/app.wasm')
const conn = sock.dial() // one connection; call it again for another peer (§6.4)
await conn.invoke(Once, { text: 'hi' })
sock.close() // fails every live call, then terminates the workerClient over an RTCDataChannel:
import { Conn } from '@lesomnus/grpc-dgram'
import { DataChannelTransport } from '@lesomnus/grpc-dgram/transport/webrtc'
const dc = pc.createDataChannel('rpc') // ordered, no caps → reliable mode, no timers
const conn = new Conn(new DataChannelTransport(dc)) // pump attaches itself
const res = await conn.invoke(Once, { text: 'hi' })
const stream = conn.newStream(Live, {})
await stream.send({ text: 'x' })
for await (const msg of stream) console.log(msg)
conn.close() // one close tears everything down, channel includedServer behind a gateway (any number of peers, mixed reliability):
import { Server } from '@lesomnus/grpc-dgram'
import { DataChannelGateway } from '@lesomnus/grpc-dgram/transport/webrtc'
const gw = new DataChannelGateway()
const server = new Server(gw)
server.register(Once, (req) => ({ text: `echo:${req.text}` }))
server.register(Live, async (stream) => {
for await (const msg of stream) await stream.send(msg)
})
pc.ondatachannel = ({ channel }) => {
gw.bind(channel) // synchronously, so no early message is lost
void gw.servePeer(server, channel) // §4.5 teardown on every exit
}Handlers are plain functions per RPC type:
| Type | Signature |
|---|---|
| unary | (req, ctx) => Res \| Promise<Res> |
| server-streaming | (req, stream, ctx) => Promise<void> |
| client-streaming | (stream, ctx) => Res \| Promise<Res> — the return value is the response |
| bidi | (stream, ctx) => Promise<void> |
ctx.signal is an AbortSignal aborted when the call ends for any reason
(client abort, RESET, deadline, liveness expiry, stop()), with the
StatusError cause as its reason; ctx.setHeader / ctx.sendHeader /
ctx.setTrailer follow §11. Handler failures are StatusErrors (anything
else maps to UNKNOWN).
Translation notes (Go ↔ TS)
| Go | TS |
|---|---|
| context.Context cancellation/deadline | CallOptions.signal (AbortSignal) + timeoutMs; handler ctx.signal |
| *status.Status errors | StatusError { code, desc } |
| metadata.MD | Metadata = Record<string, string[]>; a -bin key holds base64 (raw octets on the wire) |
| grpc.ClientConnInterface / generated stubs | conn.invoke(desc, req) / conn.newStream(desc) |
| RegisterService + codegen | server.register(desc, handler) per method |
| TransportInfo / ConnAttacher discovery | same, structural (reliable(), attachConn()) |
| drpc.ErrMessageTooLarge | MessageTooLargeError (adapters throw it or set it as cause) |
| NewPeerContext / NewReliableContext | FrameContext { peer, reliable, signal } argument to handle |
| mutexes + atomics | none needed: state transitions are synchronous between await points |
| WithProtocolStats(obs) (repeatable) | protocolStats: obs or protocolStats: [obs, …] on ConnOptions / ServerOptions; Counters.observe is the ready-made observer |
| ProtocolEventKind (EventSkipped … EventFlowStall, EventFlowResume, EventPeerFlowStall, EventPeerFlowResume) | ProtocolEventKind, the strings Go's String() fixes ('skipped' … 'flow-stall', 'flow-resume', 'peer-flow-stall', 'peer-flow-resume'); Counters keeps flowStall / flowResume and peerFlowStall / peerFlowResume apart, as Go does |
| WithLimits(Limits{MaxPeerWindow: n}) (the §4.2.1 connection window) | limits: { maxPeerWindow: n } on ConnOptions / ServerOptions — advertised as Frame.connWindow on every OPEN (Conn) and on every H and T (Server), the peer honouring the first it hears; floored at W_CONN (1024, exported beside W_INIT) because a client streams on that assumption until the server's first H or T; capped at 2³² − 1 — the wire's uint32 — so Infinity means that much, not off |
| WithChainUnaryInterceptor(…) / ChainUnaryInterceptor(…) and the stream twins | unaryInterceptors: […] / streamInterceptors: […] on ConnOptions / ServerOptions — same order (element 0 outermost), (req, call, next) shape; see docs/typescript.md |
Deliberately not ported (yet): the stats.Handler bridge (a grpc-go type; the
drpc half of the observability surface,
ProtocolStats/Counters, is ported — protocolStats on ConnOptions and
ServerOptions, docs/observability.md).
Batching is the application's policy in both languages and neither ships a
batcher (docs/TODO.md §1); both ship the seam to write one against. Here it
is EnvelopeSender, implemented by every exported adapter class as
sendFrames, with handle delegating to it so an override reaches the same
code the core would. It
mirrors that class's own handle: sendFrames(frames) where handle takes
a frame alone, sendFrames(frames, ctx) where handle takes a
FrameContext — on the gateways the ctx is the address, so a batch may
hold only frames whose contexts name one peer. Subclass the adapter and override handle: the core finds
reliable(), attachConn() and close() structurally, so the prototype
keeps all three. src/transport/node-udp/batcher.test.ts is the worked
example, per-peer gateway batching included.
Receive-path note for browsers: an RTCDataChannel cannot pause delivery, so
adapter-level buffering is unavoidable — but since per-stream flow control the protocol paces
the sender instead of the receiver (§4.2.1 flow control), so a slow consumer
no longer needs the receive path to block at all, and never stalls the other
calls sharing the channel.
Tests
pnpm test — 575 tests mirroring the Go suites: the §5 golden wire vectors
byte-for-byte (including the 2026-07-25 vectors generated from the Go
implementation), e2e for all four RPC types, the §10 timeout system under
deterministic fake-timer loss (blackhole, lost terminals/acks/half-closes,
probes, liveness), the §6.5 restart walkthroughs, §15 caps and §4.2 drop
policies, §4.2.1 flow control — per stream (advertisement, parking, grants,
T_stall, overrun) and per peer (the advertisement on every OPEN / H / T and
its once-only adoption, the W_conn assumption until it arrives, absent =
off, sid = 0 grants, the starvation clause, credit returned for every
non-buffered frame, one stall budget across both windows, the evicted
sender's stash) — compression, size caps and binary metadata, each adapter
(WebRTC/WebSocket/WebTransport/Port/UDP/protobuf-es/Connect) next to its
source, open()
and the worker it ships against a fake Go (src/wasm/), and two
cross-language conformance tests driving a real Go drpc.Server: one over
loopback UDP, one a js/wasm build of the server loaded into the test process
and talked to across a MessageChannel — the second being where reliable mode
is genuinely reliable rather than asserted, so the §4.2.1 windows are exercised
between the two implementations — per stream, and on sid = 0 past W_conn
each way across three streams, which completes only because both ends grant
on the connection window.
Layout. Unit and per-adapter tests are co-located next to their source
(src/wire.test.ts, src/transport/webrtc/index.test.ts, …); cross-cutting
integration tests (e2e, timeout, restart, limits, conformance) stay in
test/. Shared test infrastructure — the echo fixtures and the generated
protoc-gen-es code — lives in src/testing/ (not an entry, not published).
None of it reaches dist/ (tsdown builds only the exports entries).
