Getting Started

Install

go get github.com/floatpane/go-uds-jsonrpc

Requires Go 1.26+.

A complete daemon

package main

import (
    "context"
    "encoding/json"
    "log"
    "net"
    "os"
    "os/signal"
    "syscall"

    udsrpc "github.com/floatpane/go-uds-jsonrpc"
)

const app = "myd"

func main() {
    if err := udsrpc.EnsureRuntimeDir(app); err != nil {
        log.Fatal(err)
    }
    pidPath := udsrpc.PIDPath(app)
    if pid, running := udsrpc.IsRunning(pidPath); running {
        log.Fatalf("already running (PID %d)", pid)
    }
    if err := udsrpc.WritePID(pidPath); err != nil {
        log.Fatal(err)
    }
    defer udsrpc.RemovePID(pidPath)

    sock := udsrpc.SocketPath(app)
    _ = os.Remove(sock)
    l, err := net.Listen("unix", sock)
    if err != nil {
        log.Fatal(err)
    }
    defer l.Close()
    _ = os.Chmod(sock, 0700)

    s := udsrpc.NewServer()
    s.Handle("Ping", func(_ context.Context, _ *udsrpc.Conn, _ json.RawMessage) (any, error) {
        return map[string]bool{"pong": true}, nil
    })

    ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer cancel()

    log.Printf("listening on %s", sock)
    if err := s.Serve(ctx, l); err != nil {
        log.Fatal(err)
    }
}

That's a complete daemon: single-instance, signal-aware, with one RPC method. Run it, then talk to it from another terminal:

echo '{"id":1,"method":"Ping"}' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/myd/myd.sock
# → {"id":1,"result":{"pong":true}}

A client

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net"

    udsrpc "github.com/floatpane/go-uds-jsonrpc"
)

func main() {
    conn, err := net.Dial("unix", udsrpc.SocketPath("myd"))
    if err != nil {
        log.Fatal(err)
    }
    c := udsrpc.NewConn(conn)
    defer c.Close()

    if err := c.Send(&udsrpc.Request{ID: 1, Method: "Ping"}); err != nil {
        log.Fatal(err)
    }
    msg, err := c.ReceiveMessage()
    if err != nil {
        log.Fatal(err)
    }
    if msg.Response.Error != nil {
        log.Fatal(msg.Response.Error)
    }
    var result map[string]bool
    json.Unmarshal(msg.Response.Result, &result)
    fmt.Println("pong:", result["pong"])
}

Server-pushed events

A daemon often needs to notify clients of out-of-band state changes — "new mail arrived", "build finished", "config reloaded". Use Broadcast for that:

go func() {
    for range time.Tick(5 * time.Second) {
        s.Broadcast("Tick", map[string]int64{"unix": time.Now().Unix()})
    }
}()

On the client, the same ReceiveMessage() loop handles both Responses and Events — discriminate on msg.Event != nil:

for {
    msg, err := c.ReceiveMessage()
    if err != nil {
        return
    }
    switch {
    case msg.Response != nil:
        // correlate by msg.Response.ID
    case msg.Event != nil:
        switch msg.Event.Type {
        case "Tick":
            // ...
        }
    }
}
Tip

Wrap Conn in a small client-side type that holds a map of pending request IDs to result channels. The reader goroutine routes each Response to its waiting caller, and Events to a dedicated event channel. The library deliberately stays at the message level — that demuxer is application-specific.