Warning
Early-stage and under active development. The API and wire behavior may change between releases — pin a version and expect breaking changes before v1.
Go implementation of Cloudflare's Cap'n Web RPC protocol — wire-compatible with the TypeScript reference, validated by a cross-language test suite against capnweb 0.8.0.
No code generation: export any Go struct with methods as an RPC target and call remote methods through typed stubs.
Complete implementation of the capnweb 0.8.0 wire protocol — all eight message
types and every value type in the reference serialize.ts, validated against
the TypeScript implementation in both directions.
Transports
| Original | Ported | Go API |
|---|---|---|
WebSocket (newWebSocketRpcSession) |
Yes | WSDial / WSAccept |
HTTP batch (newHttpBatchRpcSession) |
Yes | BatchHandler / BatchClient |
MessagePort (newMessagePortRpcSession) |
No | browser/Worker-only; no Go analog |
Custom framing is supported via the pluggable Transport interface.
RPC features (all supported)
- Bidirectional RPC — either side can export objects and call the other
- Promise pipelining — chain dependent calls in one round trip
- Pass-by-reference stubs with automatic reference counting
- Server-side
.map()(remap), including captures and nested instructions - Streaming —
ReadableStream/WritableStreamover one connection
Serializable value types (all supported)
- Primitives (string / number / boolean / null),
undefined Infinity/-Infinity/NaN,BigInt,Date(including invalid)Uint8Array(bytes),BlobError— with own enumerable properties,cause, and AggregateErrorerrorsHeaders,Request,Response- Arrays (escaped) and objects, recursively
- Capability stubs (
import/export/promise/pipeline) and streams (readable/writable)
Not ported
- The MessagePort transport (browser/Worker-only).
capnweb-validate, the reference's build-time TypeScript type-validator, has no Go analog. Wire conformance here is enforced instead by golden wire-vectors and the cross-language interop suite.
go get go.flaticols.dev/capnweb-gopackage main
import (
"context"
"net/http"
capnweb "go.flaticols.dev/capnweb-go"
)
type Greeter struct {
capnweb.RpcTargetBase
}
func (g *Greeter) Greet(_ context.Context, name string) (string, error) {
return "Hello, " + name + "!", nil
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
tr, _ := capnweb.WSAccept(w, r, &capnweb.WSAcceptOptions{Origins: []string{"*"}})
sess := capnweb.NewSession(tr, &Greeter{})
sess.Run(r.Context())
})
http.ListenAndServe(":8080", mux)
}mux.Handle("/rpc", capnweb.BatchHandler(&Greeter{}))Each HTTP POST carries a batch of NDJSON messages — all calls and their responses fit in one round trip.
ctx := context.Background()
tr, _ := capnweb.WSDial(ctx, "ws://localhost:8080/ws", nil)
client := capnweb.NewSession(tr, nil)
go client.Run(ctx)
defer client.Close()
main := client.Main()
result, _ := capnweb.Call[string](ctx, main, "Greet", "World")
// result == "Hello, World!"bc := &capnweb.BatchClient{URL: "http://localhost:8080/rpc"}
msgs, _ := bc.Do(ctx, []capnweb.Message{
capnweb.PushMsg{Expr: json.RawMessage(`["import",0,["Greet"],["World"]]`)},
capnweb.PullMsg{ImportID: 1},
})import { newWebSocketRpcSession } from "capnweb";
const stub = newWebSocketRpcSession("ws://localhost:8080/ws");
const result = await stub.Greet("World");
// result === "Hello, World!"// server.ts
import { RpcTarget, newWebSocketRpcSession } from "capnweb";
class Greeter extends RpcTarget {
greet(name: string) { return `Hello, ${name}!`; }
}
// ... set up WebSocketServer, call newWebSocketRpcSession(ws, new Greeter())// client.go
tr, _ := capnweb.WSDial(ctx, "ws://localhost:3000", nil)
client := capnweb.NewSession(tr, nil)
go client.Run(ctx)
main := client.Main()
result, _ := capnweb.Call[string](ctx, main, "greet", "World")Methods that return an RpcTarget are automatically passed by reference:
type Calculator struct{ capnweb.RpcTargetBase }
func (c *Calculator) Add(_ context.Context, a, b float64) (float64, error) {
return a + b, nil
}
type MathService struct{ capnweb.RpcTargetBase }
func (s *MathService) GetCalculator(_ context.Context) (*Calculator, error) {
return &Calculator{}, nil
}// Client
main := client.Main()
calc, _ := capnweb.Call[*capnweb.Stub](ctx, main, "GetCalculator")
result, _ := capnweb.Call[float64](ctx, calc, "Add", 3.0, 4.0)
defer calc.Release(ctx)Chain calls without waiting for intermediate results:
main := client.Main()
calc, _ := main.Pipeline(ctx, "GetCalculator") // push only, no wait
result, _ := capnweb.Call[float64](ctx, calc, "Add", 3.0, 4.0) // push + pull
defer calc.Release(ctx)// Client: create pipe, write chunks
writer, readable, _ := client.CreatePipe(ctx)
go func() {
main := client.Main()
capnweb.Call[string](ctx, main, "Collect", readable)
}()
writer.Write(ctx, "chunk1")
writer.Write(ctx, "chunk2")
writer.Close(ctx)
// Server: read stream
func (s *Service) Collect(_ context.Context, reader *capnweb.StreamReader) (string, error) {
var buf strings.Builder
for {
chunk, err := reader.Read(context.Background())
if err == io.EOF { break }
buf.WriteString(chunk.(string))
}
return buf.String(), nil
}See the issue tracker for known gaps and follow-ups.
MIT