udsrpc

package module
v0.0.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 30, 2026 License: MIT Imports: 16 Imported by: 0

README

go-uds-jsonrpc

Tiny newline-delimited JSON-RPC over Unix domain sockets, for Go.

Go Version Go Reference GitHub release (latest by date) CI

go-uds-jsonrpc is what net/rpc should be in 2026: JSON on the wire, Unix-socket framing, server-pushed events, no gob, no reflection. It sits between net/rpc (too rigid) and gRPC (too heavy) for the very common case of one daemon, several local clients, one machine.

Features

  • Three message shapes — Request, Response, Event (server-pushed) — on one TCP-ish stream of \n-terminated JSON.
  • Cross-platform PID file + IsRunning — Unix uses signal-0; Windows uses OpenProcess + GetExitCodeProcess.
  • XDG-aware socket paths$XDG_RUNTIME_DIR/<app>/ on Linux, ~/Library/Caches/<app>/ on macOS, sensible fallback on others.
  • Server scaffolding — handler registry, panic recovery, broadcast helpers, OnConnect / OnDisconnect hooks, context-driven shutdown.
  • Signal handler — SIGTERM/SIGINT → shutdown, SIGHUP → reload, both wired in one call.
  • Zero dependencies. stdlib-only.

Install

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

Requires Go 1.26+.

Usage

Server
package main

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

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

func main() {
    const app = "myd"

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

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

    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.Println(s.Serve(ctx, l))
}
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)
    }
    var result map[string]bool
    json.Unmarshal(msg.Response.Result, &result)
    fmt.Println("pong:", result["pong"])
}
Push events from server to all clients
go func() {
    for range time.Tick(5 * time.Second) {
        s.Broadcast("Tick", map[string]int64{"unix": time.Now().Unix()})
    }
}()

Clients receive these as Event messages when they call ReceiveMessage().

Wire format

Every message is one JSON object followed by \n:

{"id":1,"method":"Ping","params":{}}
{"id":1,"result":{"pong":true}}
{"type":"Tick","data":{"unix":1748000000}}

DecodeMessage discriminates by inspecting the keys:

  • has "type"Event
  • has "method"Request
  • otherwise → Response

Standard error codes (borrowed from JSON-RPC 2.0):

Code Constant Meaning
-32700 ErrCodeParse Invalid JSON received
-32600 ErrCodeInvalidReq Not a valid Request
-32601 ErrCodeNotFound Method not registered
-32602 ErrCodeInvalidParams Method exists, bad params
-32603 ErrCodeInternal Handler error

Return an *Error from a handler to forward a specific code/message to the client. Any other non-nil error becomes ErrCodeInternal with the message.

Documentation

Full API reference: pkg.go.dev/github.com/floatpane/go-uds-jsonrpc

Contributing

PRs welcome. See CONTRIBUTING.md.

Security

Report vulnerabilities privately via SECURITY.md.

License

MIT. See LICENSE.

Documentation

Overview

Package udsrpc is a tiny newline-delimited JSON-RPC implementation designed for daemon ↔ client communication over Unix domain sockets.

It sits between net/rpc (too rigid, gob-only) and full gRPC (too heavy). Messages are JSON objects separated by newlines; the wire format is language-agnostic, debuggable by hand, and trivial to bridge to any other runtime.

Three message shapes share the wire:

  • Request: {"id": N, "method": "...", "params": {...}}
  • Response: {"id": N, "result": {...}} or {"id": N, "error": {...}}
  • Event: {"type": "...", "data": {...}} (server → client push)

DecodeMessage discriminates between the three by inspecting the keys present in the raw JSON: "type" → Event, "method" → Request, else Response.

Index

Constants

View Source
const (
	// ErrCodeParse — invalid JSON was received.
	ErrCodeParse = -32700
	// ErrCodeInvalidReq — request was not a valid Request object.
	ErrCodeInvalidReq = -32600
	// ErrCodeNotFound — method does not exist or is not registered.
	ErrCodeNotFound = -32601
	// ErrCodeInvalidParams — method exists but params are invalid.
	ErrCodeInvalidParams = -32602
	// ErrCodeInternal — internal server error.
	ErrCodeInternal = -32603
)

Standard error codes, borrowed from JSON-RPC 2.0 so existing tooling and client libraries recognize them.

Variables

This section is empty.

Functions

func EnsureRuntimeDir

func EnsureRuntimeDir(appName string) error

EnsureRuntimeDir creates the runtime directory if it doesn't exist, with owner-only permissions (0700).

func HandleSignals

func HandleSignals(onShutdown, onReload func()) (stop func())

HandleSignals installs a SIGTERM/SIGINT/SIGHUP handler in a new goroutine and returns a stop function that cancels the handler.

  • SIGTERM, SIGINT → onShutdown is called once, the handler returns.
  • SIGHUP → onReload is called and the handler keeps running.

Either callback may be nil. Calling the returned stop function detaches the signal handler — it does NOT invoke onShutdown.

func IsRunning

func IsRunning(path string) (int, bool)

IsRunning returns (pid, true) if a process with the PID stored at path is currently alive, else (pid, false). A signal-0 probe is used on Unix; the call is non-destructive.

func PIDPath

func PIDPath(appName string) string

PIDPath returns the conventional PID file path for an app: <RuntimeDir>/<appName>.pid.

func ReadPID

func ReadPID(path string) (int, error)

ReadPID reads the process ID from a PID file.

func RemovePID

func RemovePID(path string) error

RemovePID deletes the PID file.

func RuntimeDir

func RuntimeDir(appName string) string

RuntimeDir returns the OS-appropriate directory for an app's runtime state (socket, PID file). The directory is per-user and per-app.

  • Linux: $XDG_RUNTIME_DIR/<appName>/ (fallback: /tmp/<appName>-<uid>/)
  • macOS: ~/Library/Caches/<appName>/
  • other: os.TempDir()/<appName>-<uid>/

appName should be a short lowercase identifier — typically the daemon binary name.

func SocketPath

func SocketPath(appName string) string

SocketPath returns the conventional Unix socket path for an app: <RuntimeDir>/<appName>.sock.

func WritePID

func WritePID(path string) error

WritePID writes the current process ID to path.

Types

type Conn

type Conn struct {
	// contains filtered or unexported fields
}

Conn wraps a net.Conn with newline-delimited JSON encoding/decoding. Send is serialized by an internal mutex so concurrent goroutines can push to the same connection without interleaving bytes; ReceiveMessage is not safe for concurrent use and is intended to be driven by a single reader goroutine.

func NewConn

func NewConn(c net.Conn) *Conn

NewConn wraps an existing net.Conn.

func (*Conn) Close

func (c *Conn) Close() error

Close closes the underlying connection.

func (*Conn) LocalAddr

func (c *Conn) LocalAddr() net.Addr

LocalAddr returns the local address of the underlying connection.

func (*Conn) ReceiveMessage

func (c *Conn) ReceiveMessage() (Message, error)

ReceiveMessage reads and decodes the next JSON message, returning a discriminated Message. Blocks until a full JSON object is read or the underlying connection closes.

func (*Conn) RemoteAddr

func (c *Conn) RemoteAddr() net.Addr

RemoteAddr returns the remote address of the underlying connection.

func (*Conn) Send

func (c *Conn) Send(v interface{}) error

Send writes a JSON-encoded value followed by a newline. Safe to call from multiple goroutines.

func (*Conn) SendError

func (c *Conn) SendError(id uint64, code int, message string) error

SendError sends an error response with the given code and message.

func (*Conn) SendEvent

func (c *Conn) SendEvent(eventType string, data interface{}) error

SendEvent pushes a server-originated event with no Request to ack.

func (*Conn) SendResponse

func (c *Conn) SendResponse(id uint64, result interface{}) error

SendResponse sends a successful response with the given result. The result is marshaled with encoding/json.

type Error

type Error struct {
	Code    int    `json:"code"`
	Message string `json:"message"`
}

Error is the error payload inside a Response.

func (*Error) Error

func (e *Error) Error() string

Error implements the error interface so an *Error can be returned from handlers directly.

type Event

type Event struct {
	Type string          `json:"type"`
	Data json.RawMessage `json:"data,omitempty"`
}

Event is a server-pushed message with no Request. It has no ID and is not acknowledged.

type HandlerFunc

type HandlerFunc func(ctx context.Context, conn *Conn, params json.RawMessage) (any, error)

HandlerFunc handles a single Request. The returned value is marshaled as the Response.Result. To return a custom error code, return an *Error (the server forwards Code and Message verbatim). Any other non-nil error is sent as ErrCodeInternal with err.Error() as the message.

type Message

type Message struct {
	Request  *Request
	Response *Response
	Event    *Event
}

Message is a discriminated union for wire decoding. Exactly one of Request, Response, or Event is non-nil after DecodeMessage succeeds.

func DecodeMessage

func DecodeMessage(raw json.RawMessage) (Message, error)

DecodeMessage inspects raw and returns a discriminated Message.

Discriminator order: "type" present → Event, "method" present → Request, else → Response. This matches how peers should produce messages — never include both "type" and "method", never set "id" on Events.

type Request

type Request struct {
	ID     uint64          `json:"id"`
	Method string          `json:"method"`
	Params json.RawMessage `json:"params,omitempty"`
}

Request from client to server. The ID is echoed in the matching Response so callers can correlate concurrent in-flight requests.

type Response

type Response struct {
	ID     uint64          `json:"id"`
	Result json.RawMessage `json:"result,omitempty"`
	Error  *Error          `json:"error,omitempty"`
}

Response from server to client. Either Result or Error is populated, never both.

type Server

type Server struct {

	// Logger receives accept errors and recovered panics. Defaults to
	// log.Default(); set to a no-op logger to silence the server.
	Logger *log.Logger
	// contains filtered or unexported fields
}

Server multiplexes incoming Requests on accepted connections to registered HandlerFuncs and tracks all live clients so events can be broadcast.

A zero-value Server is not usable — call NewServer.

func NewServer

func NewServer() *Server

NewServer returns a Server with no handlers registered.

func (*Server) Broadcast

func (s *Server) Broadcast(eventType string, data any)

Broadcast sends an Event to every connected client. Errors per connection are logged but do not abort the broadcast.

func (*Server) BroadcastFunc

func (s *Server) BroadcastFunc(eventType string, data any, predicate func(*Conn) bool)

BroadcastFunc sends an Event to every connected client for which predicate returns true. A nil predicate matches all clients.

func (*Server) Clients

func (s *Server) Clients() []*Conn

Clients returns a snapshot slice of all currently-connected clients. Safe to call concurrently with Serve.

func (*Server) Handle

func (s *Server) Handle(method string, fn HandlerFunc)

Handle registers fn as the handler for method. Calling Handle with the same method twice replaces the previous handler.

func (*Server) OnConnect

func (s *Server) OnConnect(fn func(*Conn))

OnConnect installs a hook called once per accepted connection, before any messages are read. The hook runs synchronously on the accept goroutine; expensive work should be dispatched elsewhere.

func (*Server) OnDisconnect

func (s *Server) OnDisconnect(fn func(*Conn))

OnDisconnect installs a hook called once per connection when it closes (cleanly or after error).

func (*Server) Serve

func (s *Server) Serve(ctx context.Context, l net.Listener) error

Serve accepts connections on l and dispatches their Requests to registered handlers until ctx is canceled or l.Close is called.

On ctx cancel, Serve closes l (so the blocked Accept returns) and returns nil. Other accept errors are returned to the caller.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL