Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

netargv

Go Report Card Go Reference License: MIT

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.SetReadDeadline or io.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

Install

go get lowbit.dev/netargv

Wire format

A 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"

Payload

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>

Reading messages

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.

Context cancellation

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.

Fatal error latch

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.

Payload size limit

The default maximum payload size is 2 MB. A declared payload larger than this returns ErrPayloadTooLarge before any bytes are read.

FlagSet

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)

Sending messages

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)

Versioned message registry

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)

Errors

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.

About

A message format that looks like a shell command

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages