A message format that looks like a shell command. Every message is a verb, positional arguments, and named flags — sent over a socket as plain text. No encoder, no decoder, no schema. If something is broken, nc and your eyes are enough.
hello --version=0 --version=1
auth --token=s3cr3t
run --id=abc123 --task=generate-report
progress 42% --id=abc123 --message="Still running"
done success --id=abc123 --duration=1840
- Shell-shaped messages — verb + args + flags; human-readable on the wire
- Binary payloads — inline byte count (
-- N) for arbitrary binary data after the header - Context cancellation — deadline control via
net.Conn.SetReadDeadlineorio.Closer.Close - Fatal error latch — mid-stream errors permanently poison the Reader; no silent partial reads
- Push iterator — range over messages with
for msg, err := range r.Itterate(ctx) - Zero dependencies
go get lowbit.dev/netargvA message is a single logical line, LF-terminated. Long lines may be split across physical lines with a trailing backslash:
run \
--id=abc123 \
--task=generate-report
The first token is the message type. What follows uses shell-word rules: bare
tokens are positional arguments, tokens starting with - or -- are flags.
A flag without a value is implicitly true. The same flag may appear more than
once; values accumulate in order.
hello --version=0 --version=1
Flag values containing spaces must be quoted:
error --message="Task not supported"
A header may declare a binary payload by ending with -- N, where N is the
exact number of bytes that follow immediately after the header line:
blob --name=data.bin -- 1024
<1024 raw bytes>
r := netargv.NewReader(conn)
for {
msg, err := r.ReadNext(ctx)
if err == io.EOF {
break // clean close between messages
}
if err != nil {
// handle or discard r — it is no longer usable after a mid-stream error
return err
}
msg.Verb() // "run"
msg.Args() // []string{}
msg.Flags() // FlagSet
msg.Payload() // []byte, nil if no payload declared
msg.Raw() // original logical line as received
}Or use Itterate to range over messages directly:
r := netargv.NewReader(conn)
for msg, err := range r.Itterate(ctx) {
if err == io.EOF {
break // clean close between messages
}
if err != nil {
return err
}
// use msg
}NewReader accepts any io.Reader and wraps it in a bufio.Reader as needed.
ReadNext honours context cancellation. On net.Conn sources it sets a past
read deadline, which unblocks the Read call immediately — no goroutine cost
on the happy path. On io.Closer sources it calls Close. Bare io.Readers
that support neither will block until the underlying source returns.
Any non-EOF error mid-read permanently poisons the Reader. Subsequent
ReadNext calls return ErrStreamDesynced. This is intentional: a
partially-read TCP stream cannot be recovered safely. Discard the Reader,
close the connection, and redial.
The default maximum payload size is 2 MB. A declared payload larger than this
returns ErrPayloadTooLarge before any bytes are read.
msg.Flags().Has("verbose") // bool
msg.Flags().Get("version") // first value, "" if absent
msg.Flags().Lookup("version") // (value, ok)
msg.Flags().GetRepeated("version") // all values in declaration order
msg.Flags().LookupRepeated("version") // ([]string, ok)This package only reads. Sending is fmt.Fprintf:
fmt.Fprintf(conn, "hello --version=0\n")
fmt.Fprintf(conn, "blob --name=data.bin -- %d\n", len(data))
conn.Write(data)lowbit.dev/verreg is a small generic versioned
registry for dispatching messages by (version, verb) pair. Each version
inherits all entries from the previous one; a new version only declares what
changes.
type Factory func(netargv.Message) (any, error)
reg := verreg.NewRegistry[Factory]()
reg.Register(0, "hello", func(m netargv.Message) (any, error) {
return HelloMessage{Version: m.Flags().Get("version")}, nil
})
reg.Register(1, "hello", helloV1) // v1 overrides this type only
reg.DeprecateVersion(0)
reg.Build()
// After reading a message and completing version negotiation:
factory, err := reg.Resolve(negotiatedVersion, msg.Verb())
if err != nil {
return fmt.Errorf("resolve %q (v%d): %w", msg.Verb(), negotiatedVersion, err)
}
value, err := factory(msg)| Sentinel | Source |
|---|---|
ErrStreamDesynced |
ReadNext — Reader is poisoned from a prior mid-stream error |
ErrParseFailure |
ReadNext — header tokenisation failed (wraps underlying cause) |
ErrMissingPayloadLength |
ReadNext — -- marker present but no byte count follows |
ErrInvalidPayloadLength |
ReadNext — byte count is not a valid non-negative integer |
ErrPayloadTooLarge |
ReadNext — declared length exceeds maxPayloadSize |
All errors wrap their sentinel so errors.Is works across the chain.