{"id":51178331,"url":"https://github.com/ashtonian/mqttv5","last_synced_at":"2026-06-27T05:30:27.243Z","repository":{"id":359447542,"uuid":"1245981840","full_name":"ashtonian/mqttv5","owner":"ashtonian","description":"Fast, idiomatic MQTT v5 client for Go. Stdlib-only core, zero-alloc receive, auto-reconnect + resubscribe, file-backed QoS 1/2 persistence.","archived":false,"fork":false,"pushed_at":"2026-05-30T02:16:02.000Z","size":407,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-05-31T10:26:03.643Z","etag":null,"topics":["go","golang","iot","messaging","mqtt","mqtt-client","mqtt-v5","mqtt5","mqttv5","publish-subscribe","pubsub"],"latest_commit_sha":null,"homepage":null,"language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/ashtonian.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":"NOTICE","maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-21T18:53:29.000Z","updated_at":"2026-05-30T02:16:05.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/ashtonian/mqttv5","commit_stats":null,"previous_names":["ashtonian/mqttv5"],"tags_count":6,"template":false,"template_full_name":null,"purl":"pkg:github/ashtonian/mqttv5","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ashtonian%2Fmqttv5","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ashtonian%2Fmqttv5/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ashtonian%2Fmqttv5/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ashtonian%2Fmqttv5/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/ashtonian","download_url":"https://codeload.github.com/ashtonian/mqttv5/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ashtonian%2Fmqttv5/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34843146,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-27T02:00:06.362Z","response_time":126,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["go","golang","iot","messaging","mqtt","mqtt-client","mqtt-v5","mqtt5","mqttv5","publish-subscribe","pubsub"],"created_at":"2026-06-27T05:30:24.343Z","updated_at":"2026-06-27T05:30:27.234Z","avatar_url":"https://github.com/ashtonian.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# mqttv5\n\n[![Go Reference](https://pkg.go.dev/badge/github.com/ashtonian/mqttv5.svg)](https://pkg.go.dev/github.com/ashtonian/mqttv5)\n[![Go Report Card](https://goreportcard.com/badge/github.com/ashtonian/mqttv5)](https://goreportcard.com/report/github.com/ashtonian/mqttv5)\n[![CI](https://github.com/ashtonian/mqttv5/actions/workflows/ci.yml/badge.svg)](https://github.com/ashtonian/mqttv5/actions/workflows/ci.yml)\n[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)\n\nA fast, ergonomic MQTT v5 client for Go. Single package, stdlib-only\ncore, zero-allocation packet decode, Go-native subscribe\nsurface (`\u003c-chan *Message` / `Queue[*Message]` / callback), and the\nsupervisor (reconnect + replay + resubscribe) baked into every\n`Client`.\n\n```bash\ngo get github.com/ashtonian/mqttv5\n```\n\n- Module: `github.com/ashtonian/mqttv5`\n- License: [Apache 2.0](LICENSE) (with [NOTICE](NOTICE))\n- Go: 1.26+\n- Benchmarks: [benchmarks/README.md](benchmarks/README.md)\n\n## Example\n\n```go\npackage main\n\nimport (\n    \"context\"\n    \"fmt\"\n\n    \"github.com/ashtonian/mqttv5\"\n    jsoncodec \"github.com/ashtonian/mqttv5/codec/json\"\n    \"github.com/ashtonian/mqttv5/wire\"\n)\n\ntype Event struct {\n    Device string  `json:\"device\"`\n    Temp   float64 `json:\"temp\"`\n}\n\nfunc main() {\n    ctx := context.Background()\n\n    client, _ := mqttv5.New(mqttv5.WithBroker(\"mqtt://localhost:1883\"))\n    _ = client.Connect(ctx)\n    defer client.Disconnect(ctx)\n\n    // Generic typed pub/sub via Codec[T] (JSON ships in a sibling\n    // submodule). Supervisor handles reconnect + auto-resubscribe +\n    // QoS 1/2 replay underneath — you just write the consumer loop.\n    events := mqttv5.NewTyped(client, jsoncodec.Codec[Event]{})\n\n    msgs, _, _ := events.Subscribe(ctx, []mqttv5.TopicFilter{{Topic: \"events/#\", QoS: 1}})\n    go func() {\n        for m := range msgs {\n            fmt.Printf(\"%s: %+v\\n\", m.Topic, m.Value) // m.Value already decoded\n            _ = m.Ack() // PUBACK held for QoS 1 until you ack\n        }\n    }()\n\n    _ = events.Publish(ctx, wire.PublishOpts{Topic: \"events/hello\", QoS: 1}, Event{Device: \"a1\", Temp: 22.5})\n}\n```\n\nSee [`examples/`](examples/) for full demos — TLS, multi-broker failover,\npublisher pool, durable queue, raw-bytes subscribe, WebSocket, OAuth\nrotation, and lifecycle observability.\n\n## Why this over [eclipse/paho.golang](https://github.com/eclipse/paho.golang) + [autopaho](https://github.com/eclipse/paho.golang/tree/master/autopaho)\n\n- **Go-idiomatic top to bottom.** Channels (`\u003c-chan *Message`) and\n  queues for delivery, not just global `OnPublishReceived` callbacks.\n  `context.Context` on every operation. Sentinel errors with\n  `errors.Is`. Functional options instead of a 40-field\n  `ClientOptions` struct.\n- **One client. Supervisor baked in.** No `paho` / `autopaho` split —\n  reconnect, replay-in-flight, and auto-resubscribe are always on.\n- **27–106× faster decode, zero allocations.** Topic and payload are\n  zero-copy slices into a pooled frame; properties decode lazily.\n  **~30× less GC pressure** at sustained\n  load (~29 MB/s of garbage vs ~880 MB/s for autopaho at 100k msg/s)\n  — smaller pauses, less p99 jitter.\n- **Multi-broker, kept distinct and composable.** Failover\n  (`WithBrokers`), fan-out across N independent brokers (`ClientGroup`),\n  publish-only pool against one broker (`WithPublisherPool`) — three\n  real patterns, each its own API. Compose them: `WithBrokers` inside\n  a `GroupMember` for HA-per-region, then `WithPublisherPool` on top\n  for throughput.\n- **Publisher writes don't serialise on a mutex.** paho holds one\n  mutex across every connection `Write`, so concurrent publishers\n  contend on each other's syscalls. mqttv5 has each producer hand its\n  packet to a per-connection MPSC channel that a single writer\n  goroutine drains to the socket — no write-lock contention on the hot\n  path. **~1.6× faster** under 8-goroutine QoS 1 fan-in onto one\n  connection. `WithPublisherPool(N)` then runs N such connections, each\n  with its own writer goroutine, to spread write load across cores.\n- **Backpressure as a first-class concept.** Per-subscription\n  `DropNewest` / `DropOldest`, with the dropped message auto-ack'd so\n  the broker stops retransmitting.\n- **Generic typed payloads.** `Codec[T]` boundary; JSON and msgpack\n  codecs ship in separate submodules so the core stays stdlib-only.\n- **Durable outbound queue** (`QueuePublisher` + file-backed\n  `queue/file/`) — enqueue while disconnected, drain on reconnect,\n  survive process restart.\n- **Conformance.** Full MQTT v5: shared subscriptions, topic aliases\n  (in + out), session expiry, retained messages, will + will\n  properties, enhanced authentication (CONNECT + mid-session §4.12),\n  CONNACK capability flags honoured — `Subscribe*` errors before the\n  wire when the broker has disabled the feature.\n- **WebSocket as a sibling module** — `transport/ws` brings ws/wss via\n  `WithDialFunc(ws.DialFunc(opts))` (see [`examples/ws`](examples/ws)).\n  Zero impact on the core's stdlib-only promise.\n\n## Runnable examples\n\nIn [`examples/`](examples/) — one go.mod, run any of them with\n`MQTT_BROKER=mqtt://127.0.0.1:1883`:\n\n| Path | Shows |\n|---|---|\n| [`examples/basic`](examples/basic) | Connect, channel subscribe, publish |\n| [`examples/typed`](examples/typed) | `Typed[T]` + JSON codec |\n| [`examples/reconnect`](examples/reconnect) | Full lifecycle callback set (Up / Down / ConnectError / ReconnectAttempt) surviving a broker restart |\n| [`examples/group`](examples/group) | `ClientGroup` multi-broker fan-out / fan-in |\n| [`examples/ws`](examples/ws) | WebSocket transport — `WithDialFunc(ws.DialFunc(opts))` |\n| [`examples/stats`](examples/stats) | `Client.Stats()` snapshot — bridge into Prometheus / OTel / expvar |\n| [`examples/oauth`](examples/oauth) | `WithConnectPacketBuilder` rotating an OAuth bearer per CONNECT |\n| [`examples/disconnect`](examples/disconnect) | `DisconnectWith` carrying ReasonCode + ReasonString + SessionExpiry override |\n\n```bash\ndocker run -d -p 1883:1883 eclipse-mosquitto\ngo -C examples run ./basic\n```\n\n## Install / submodules\n\nThe core is stdlib-only. Opt-in submodules each have their own\n`go.mod` so importing them doesn't add a runtime dep to the core.\n\n| Submodule | Import | Purpose |\n|---|---|---|\n| core | `github.com/ashtonian/mqttv5` | Client, supervisor, options, in-memory queue |\n| JSON codec | `github.com/ashtonian/mqttv5/codec/json` | `Codec[T]` for `Typed[T]` (stdlib only) |\n| msgpack codec | `github.com/ashtonian/mqttv5/codec/msgpack` | `Codec[T]` via `vmihailenco/msgpack/v5` |\n| File session store | `github.com/ashtonian/mqttv5/store/file` | Crash-safe in-flight QoS 1/2 state |\n| File publish queue | `github.com/ashtonian/mqttv5/queue/file` | Durable outbound publish queue (WAL) |\n| WebSocket transport | `github.com/ashtonian/mqttv5/transport/ws` | ws:// and wss:// — `WithDialFunc(ws.DialFunc(ws.DialOpts{...}))` |\n\n## Three multi-broker patterns\n\nThree distinct shapes, each its own API:\n\n| Goal | API | Connections |\n|---|---|---|\n| **Failover** — one logical client across interchangeable brokers (same data) | `WithBrokers(urls...)` | 1 at a time, supervisor rotates on drop |\n| **Parallel sessions to N independent brokers** | `NewClientGroup(members, opts...)` | N (one per broker), all live |\n| **Publish throughput** — saturate one broker | `WithPublisherPool(N)` | N publish-only to the *same* broker |\n\nThese compose. Use `WithBrokers` inside a `GroupMember.Opts` to get\nHA-per-region fan-out; use `WithPublisherPool` alongside `WithBrokers`\nto get throughput against an HA pair.\n\n`Client.SetBrokers(urls...)` swaps the failover list at runtime —\ntypical use is inside `WithOnServerDisconnect` when the broker sends\n`ReasonServerMoved` with a `ServerReference`:\n\n```go\nmqttv5.WithOnServerDisconnect(func(d *wire.Disconnect) {\n    if ref, ok := d.Properties.String(wire.PropServerReference); ok {\n        _ = cli.SetBrokers(ref)\n    }\n})\n```\n\n### `ClientGroup` policies\n\n`ClientGroup` is for **N parallel sessions to N brokers**, each\ntreated as itself — bridges between independent brokers,\nmulti-tenant SaaS with per-broker credentials, or a clustered broker\nfleet you want N parallel sessions into. If your brokers are\ninterchangeable for the same data, use `WithBrokers` on a single\nClient; `ClientGroup` does not failover between members.\n\nConstruction takes a `GroupMember` list plus group-level options:\n\n```go\ng, _ := mqttv5.NewClientGroup(\n    []mqttv5.GroupMember{\n        {\n            Broker: \"mqtts://emea.example.com:8883\",\n            Name:   \"emea\",\n            Opts:   []mqttv5.Option{mqttv5.WithCredentials(\"emea-svc\", []byte(token1))},\n        },\n        {\n            Broker: \"mqtts://apac.example.com:8883\",\n            Name:   \"apac\",\n            Opts:   []mqttv5.Option{mqttv5.WithCredentials(\"apac-svc\", []byte(token2))},\n        },\n    },\n    mqttv5.WithGroupSharedOpts(\n        mqttv5.WithClientID(\"fleet\"),\n        mqttv5.WithKeepAlive(30),\n    ),\n    mqttv5.WithGroupPublishPolicy(mqttv5.GroupPublishBroadcast),\n)\n```\n\n`GroupMember.Opts` applies **after** `WithGroupSharedOpts`, so\nper-member auth / TLS / ClientID / callbacks win. Member names\ndefault to `member-N` (1-based) when unset.\n\n| Publish policy | Behaviour | Use case |\n|---|---|---|\n| `GroupPublishBroadcast` (default) | Every member receives. Succeeds if any did. | Bridge / mirror — members carry different data |\n| `GroupPublishRoundRobin` | Next healthy member per call. | Distribute publishes across a clustered broker fleet |\n| `GroupPublishHashByTopic` | FNV-1a(topic) → member. Per-topic ordering. | Fleet throughput with per-topic affinity |\n\nSubscribe is always \"all members + merge into one channel/queue\".\nThe returned token map is keyed by member name — pass to\n`UnsubscribeAll` or selectively to `Unsubscribe(name, token)`.\n\n```go\nch, tokens, _ := g.Subscribe(ctx, []mqttv5.TopicFilter{{Topic: \"events/#\", QoS: 1}})\n// tokens[\"emea\"], tokens[\"apac\"]\ndefer g.UnsubscribeAll(ctx, tokens)\n```\n\nConnect / Disconnect / Subscribe run in parallel across members by\ndefault — pass `WithGroupSequentialLifecycle` if you need\ndeterministic ordering. Use `g.Members()` or `g.Member(name)` for\ndirect per-member access (per-member `Stats()`, etc.).\n\n## Subscribe shapes\n\nAll take `[]TopicFilter` so multi-filter SUBSCRIBE is a single packet.\n\n### Channel — manual ack, ordered flush\n\n```go\nmsgs, token, err := cli.Subscribe(ctx,\n    []mqttv5.TopicFilter{{Topic: \"events/#\", QoS: 1}},\n    mqttv5.SubBuffer(256),\n)\nfor m := range msgs {\n    handle(m)\n    _ = m.Ack() // PUBACK released in §4.6 arrival order\n}\n_ = cli.Unsubscribe(ctx, token) // closes msgs\n```\n\nIf the buffer fills, the incoming message is **auto-ack'd and\ndropped** so the broker stops retrying. Observe drops via\n`SubOnDrop(...)`.\n\n### Queue — unbounded, optional `DropOldest`\n\n```go\nq, _, _ := cli.SubscribeQueue(ctx,\n    []mqttv5.TopicFilter{{Topic: \"events/#\", QoS: 1}},\n    mqttv5.SubMaxQueueSize(10_000),\n    mqttv5.SubDropPolicy(mqttv5.DropOldest), // keeps freshest 10k\n)\nfor {\n    m, ok := q.Dequeue(ctx)\n    if !ok { break }\n    handle(m)\n    _ = m.Ack()\n}\n```\n\n`DropOldest` evicts the queue head and acks it before enqueueing —\nonly the queue variant supports it (channels can't peek-and-pop\nwithout racing the consumer).\n\n### Callback — sync, auto-ack\n\n```go\ncli.SubscribeCallback(ctx,\n    []mqttv5.TopicFilter{{Topic: \"ctrl/+\", QoS: 0}},\n    func(m *mqttv5.Message) {\n        // Runs on the read goroutine — MUST be non-blocking.\n        process(m)\n        // Ack auto-fires after return.\n    },\n)\n```\n\n## Typed publish / subscribe\n\n```go\nimport jsoncodec \"github.com/ashtonian/mqttv5/codec/json\"\n\ntype Reading struct { Device string; Temp float64 }\n\ntyped := mqttv5.NewTyped[Reading](cli, jsoncodec.Codec[Reading]{})\n\n_ = typed.Publish(ctx, wire.PublishOpts{Topic: \"sensors/a1\", QoS: 1},\n    Reading{Device: \"a1\", Temp: 22.5})\n\nch, _, _ := typed.Subscribe(ctx,\n    []mqttv5.TopicFilter{{Topic: \"sensors/#\", QoS: 1}})\nfor m := range ch {\n    fmt.Println(m.Topic, m.Value.Temp)\n    _ = m.Ack()\n}\n```\n\nImplement `mqttv5.Codec[T]` for protobuf, Cap'n Proto, FlatBuffers,\ncustom binary — the core has no codec dependency.\n\n## Durable `QueuePublisher`\n\n`QueuePublisher` decouples the caller from broker availability:\n`Publish` returns as soon as the entry is durably stored. A drain\ngoroutine handles the broker round-trip whenever the client is\nconnected.\n\n```go\nimport qfile \"github.com/ashtonian/mqttv5/queue/file\"\n\nq, _ := qfile.Open(\"/var/lib/myapp/outbound\")\npub := mqttv5.NewQueuePublisher(cli, q,\n    mqttv5.WithQueueBatchSize(32),\n    mqttv5.WithQueueMaxSize(1_000_000),\n    mqttv5.WithQueueTTL(24*time.Hour),\n    mqttv5.WithDeadLetter(func(e mqttv5.QueueEntry, err error) {\n        log.Printf(\"dropped %s: %v\", e.Publish.Topic, err)\n    }),\n)\ndefer pub.Close(ctx)\n\n_ = pub.Publish(ctx, wire.PublishOpts{Topic: \"logs\", Payload: data, QoS: 1})\n```\n\nQoS 0 is rejected (`ErrQoS0NotQueueable`) — durable enqueue is\nmeaningless when the broker has no obligation to deliver. Use\n`mqttv5.NewMemoryPublisherQueue()` for in-process buffering without\ncrash safety.\n\n## WebSocket\n\n```go\nimport (\n    \"github.com/ashtonian/mqttv5\"\n    \"github.com/ashtonian/mqttv5/transport/ws\"\n)\n\ncli, _ := mqttv5.New(\n    mqttv5.WithBroker(\"wss://broker.example.com/mqtt\"),\n    mqttv5.WithDialFunc(ws.DialFunc(ws.DialOpts{TLSConfig: tlsCfg})),\n)\n```\n\n`wss://` requires a non-nil `TLSConfig` — `ws.DialFunc` returns\n`ErrMissingTLSConfig` at the first Connect attempt if you pass `nil`.\nNo silent downgrade.\n\n## Options reference\n\n### Client construction\n\n| Option | Default | Effect |\n|---|---|---|\n| `WithBroker(url)` / `WithBrokers(urls...)` | (required) | Broker URL(s). `mqtt`/`tcp`/`mqtts`/`tls`/`ssl` schemes; default ports filled in. `ws`/`wss` via `WithDialFunc(ws.DialFunc(opts))`. |\n| `WithDialFunc(fn)` | — | Replaces the built-in TCP/TLS dial. Takes precedence over `WithDialer`/`WithTLSConfig`. Nil rejected. |\n| `WithClientID(s)` | broker-assigned | MQTT ClientID. Empty = ask broker to assign one via `AssignedClientIdentifier`; `cli.ClientID()` then returns that assigned value after CONNACK. |\n| `WithCredentials(user, pass)` | — | CONNECT username + password. Static. For per-attempt rotation use `WithConnectPacketBuilder`. |\n| `WithKeepAlive(seconds)` | 30 | Keepalive interval. 0 rejected — use `WithoutKeepAlive`. |\n| `WithoutKeepAlive()` | — | Disable PINGREQ entirely. Rarely correct in production. |\n| `WithCleanStart(b)` | true | `CleanStart` on the initial CONNECT. |\n| `WithCleanStartOnReconnect(b)` | false | `CleanStart` on every reconnect. False preserves QoS 1/2 session for resume. |\n| `WithSessionExpiry(seconds)` | 300 (5 min) | Session Expiry Interval (§3.1.2.11.2). Default holds the broker session long enough for QoS 1/2 resume across a typical reconnect blip. Pass 0 to end the session with the connection. |\n| `WithReceiveMaximum(n)` | unset (broker default 65535) | Cap on concurrent inbound QoS 1/2. |\n| `WithMaximumPacketSize(n)` | 0 (no advertised limit) | CONNECT property §3.1.2.11.4 — caps the largest packet the broker may send. **Note:** with the default, a buggy / hostile broker can send arbitrarily large PUBLISHes; set explicitly when broker trust is limited. |\n| `WithInboundTopicAliasMaximum(n)` | 0 (no inbound aliases) | CONNECT property §3.1.2.11.5 — opt into wire compression on inbound PUBLISHes. |\n| `WithRequestResponseInformation(b)` | false | CONNECT property §3.1.2.11.6 — broker returns `ResponseInformation` in CONNACK. |\n| `WithRequestProblemInformation(b)` | true | CONNECT property §3.1.2.11.7 — broker returns `ReasonString` / `UserProperties` on errors. On by default; pass `false` to opt out. |\n| `WithConnectUserProperty(k, v)` / `WithConnectUserProperties(p)` | — | CONNECT user properties; append-style or bulk replace. |\n| `WithConnectTimeout(d)` | 10 s | Dial + CONNECT/CONNACK budget. |\n| `WithPingTimeout(d)` | 10 s | PINGRESP budget. Total dead-connection detection = `KeepAlive + PingTimeout` (40 s with defaults), inside the broker's 1.5×KeepAlive cutoff so the client drives reconnect. |\n| `WithDisconnectFlushTimeout(d)` | 500 ms | Flush budget for the DISCONNECT write on graceful shutdown. |\n| `WithWriteQueueSize(n)` | 256 | Internal MPSC write buffer. |\n| `WithWriteBatch(n)` | 0 (off) | Coalesce up to n pre-encoded packets per writev syscall. Wins under sustained concurrent publishers; measure before enabling. |\n| `WithWriteOverflowPolicy(p)` | `WriteBlock` | QoS 0 only. `WriteBlock` waits for queue room / ctx; `WriteDropNewest` returns `ErrWriteQueueFull` immediately when the writer queue is full. Use for telemetry where head-of-line latency on the producer is worse than occasional loss. QoS 1/2 always block on the broker ack regardless. |\n| `WithWill(opts)` | — | Will message + properties. |\n| `WithReconnectBackoff(b)` | `ExponentialBackoff(1s, 30s, 200ms)` | `ConstantBackoff(d)` also shipped. |\n| `WithTLSConfig(*tls.Config)` | — | TLS for `mqtts://`. |\n| `WithDialer(*net.Dialer)` | default | Override transport `net.Dialer`. |\n| `WithStore(s)` | in-memory | `session.Store` impl (use `store/file` for crash safety). |\n| `WithLogger(*slog.Logger)` | `slog.Default()` | Structured logging. |\n| `WithStats()` | — | Enable in-memory counters for `Client.Stats()` (off by default to keep the hot path branch-predictor friendly). |\n| `WithPublishMode(mode)` | `PublishFireAndForget` | `PublishWaitForFlush` makes QoS 0 wait for `conn.Write`. |\n| `WithPublisherPool(N)` | 0 (off) | N dedicated publish-only conns. |\n| `WithPublisherPoolRouting(p)` | `PoolRoutingRoundRobin` | `PoolRoutingHashByTopic` preserves per-topic ordering. |\n| `WithPublisherPoolClientIDFn(fn)` | `\"%s-pub-%d\"` | Customise per-member ClientIDs. Required when the parent ClientID is empty (broker-assigned). |\n| `WithMaxSubscribeQueueSize(n)` | 0 (unbounded) | Default per-sub queue cap. |\n| `WithDropPolicy(p)` | `DropNewest` | Default drop policy for full sub buffers. |\n| `WithOnConnectionUp(fn)` | — | `func(*wire.Connack)`. Fires after every successful CONNACK; receives a detached clone safe to retain. Must not block. |\n| `WithOnConnectionDown(fn)` | — | `func() bool`. Fires on unexpected disconnect (not user Disconnect). Return false to terminate the supervisor. Must not block. |\n| `WithOnConnectError(fn)` | — | Fires per failed CONNECT attempt (dial err, CONNACK refusal, AUTH-loop err). Observability only. |\n| `WithOnReconnectAttempt(fn)` | — | Fires immediately before each reconnect dial. Receives `(attempt, brokerURL)`. |\n| `WithOnServerDisconnect(fn)` | — | Fires on broker-initiated DISCONNECT with detached `*wire.Disconnect`. May call `Client.SetBrokers(...)` to redirect. |\n| `WithOnReauthenticated(fn)` | — | Fires when a re-auth (§4.12) concludes successfully (broker AUTH `0x00`), client- or broker-driven. Observability only; must not block. |\n| `WithConnectPacketBuilder(fn)` | — | `func(ctx, *wire.ConnectOpts) error`. Mutate CONNECT immediately before serialisation; canonical OAuth-token-rotation hook. |\n| `WithAuthenticator(a)` | — | MQTT v5 enhanced auth (CONNECT + re-auth §4.12). `Begin(ctx)` resolves the credential; client-initiated refresh via `Client.Reauthenticate`. |\n\n### Per-subscribe\n\n`SubscribeOption` applies to `Subscribe`, `SubscribeQueue`,\n`SubscribeCallback`, plus the `Typed[T]` and `ClientGroup` variants.\n\n| Option | Effect |\n|---|---|\n| `SubBuffer(n)` | Channel buffer size (Subscribe only). Default `DefaultSubscribeBuffer` (64). |\n| `SubMaxQueueSize(n)` | Queue cap (SubscribeQueue only). 0 = unbounded. |\n| `SubDropPolicy(p)` | `DropNewest` / `DropOldest`. SubscribeQueue honours both; chan-based `Subscribe` returns `ErrChanDropOldestUnsupported` when DropOldest is set explicitly. |\n| `SubOnDrop(fn)` | Metrics hook fired when a message is dropped + acked. |\n| `SubAutoAck()` | Opt-in: dispatcher acks each delivery before handing it to the consumer; the received `*Message` is a detached copy (Topic/Payload/Properties cloned, safe to retain) and `m.Ack()` is a no-op. Trade: 2 allocs/msg + breaks at-least-once semantics (consumer crash between delivery and processing has nothing to replay). Reach for it on QoS 0 / observational consumers. Ignored by `SubscribeCallback`. |\n\n### Per-`QueuePublisher`\n\n| Option | Effect |\n|---|---|\n| `WithQueueBatchSize(n)` | Drain batch ceiling. Default 16. |\n| `WithQueueMaxSize(n)` | Bound the queue; `Publish` returns `ErrQueueFull` when at cap (DropNewest) or evicts the head (DropOldest). |\n| `WithQueueDropPolicy(p)` | `DropNewest` (default) or `DropOldest`. DropOldest calls `PublisherQueue.EvictHead`; backends that can't evict return `ErrEvictionNotSupported` at construct. |\n| `WithQueueIdleInterval(d)` | Drain-loop wakeup tick when no Enqueue signal arrives. Default 500 ms. |\n| `WithQueuePublishTimeout(d)` | Per-message broker handshake cap inside the drain loop. Default 30 s. |\n| `WithQueueTTL(d)` | Drop entries older than `d` at drain time; mirrors into `MessageExpiryInterval` so the broker also enforces. |\n| `WithDeadLetter(fn)` | Terminal-failure callback (TTL expiry, DropOldest eviction). |\n\n## Observability — `Client.Stats()`\n\n`Client.Stats()` returns a snapshot of in-memory counters. Opt in\nvia `WithStats()` — when off, the hot path skips every atomic\nincrement and `Stats()` returns the zero value.\n\n```go\ncli, _ := mqttv5.New(\n    mqttv5.WithBroker(broker),\n    mqttv5.WithStats(),\n)\n// ...\ns := cli.Stats()\nfmt.Printf(\"sent=%d acked=%d inflight=%d connects=%d failures=%d\\n\",\n    s.PublishesSent, s.PublishesAcked, s.PublishesInflight,\n    s.Connects, s.ConnectFailures)\n```\n\nCounters cover connect/disconnect/publish/subscribe lifecycle, inbound\ndrops, pool fallbacks, and ping timeouts. Bridge each field into your\nown metrics surface (Prometheus / OpenTelemetry / expvar) — the lib\nintentionally has no metrics-library dependency. Full field list in\nthe [`Stats`](https://pkg.go.dev/github.com/ashtonian/mqttv5#Stats)\ngodoc. See [`examples/stats`](examples/stats).\n\n## Graceful disconnect\n\n`Disconnect(ctx)` sends `ReasonNormalDisconnection` with no\nproperties. Use `DisconnectWith(ctx, opts)` to override:\n\n```go\nexpiry := uint32(0)\n_ = cli.DisconnectWith(ctx, wire.DisconnectOpts{\n    ReasonCode:            wire.ReasonAdministrativeAction,\n    ReasonString:          \"planned shutdown\",\n    SessionExpiryInterval: \u0026expiry, // override to drop the session immediately\n})\n```\n\nThe `OnConnectionDown` callback is *not* invoked on a user-initiated\ndisconnect — the call site itself is the \"going down\" signal.\nSee [`examples/disconnect`](examples/disconnect).\n\n## Per-attempt credential rotation\n\n`WithConnectPacketBuilder(fn)` runs immediately before each CONNECT is\nserialised. Use it to refresh an OAuth token, fetch a SigV4-signed\nCONNECT credential, or rotate any other per-attempt secret. The\ncontext is bounded by `ConnectTimeout`.\n\n```go\nmqttv5.WithConnectPacketBuilder(func(ctx context.Context, opts *wire.ConnectOpts) error {\n    tok, err := oauth.FetchToken(ctx)\n    if err != nil {\n        return err // fails this attempt; supervisor retries after backoff\n    }\n    opts.Username = \"service-account\"\n    opts.Password = []byte(tok)\n    return nil\n}),\n```\n\nPair with `WithOnConnectError` for observability — every refusal /\nnetwork failure fires the callback with the per-attempt error.\nSee [`examples/oauth`](examples/oauth).\n\n**Refresh without reconnecting.** `Client.Reauthenticate(ctx)` drives MQTT\nv5 re-authentication (§4.12) on the *live* connection: it sends an AUTH\n`0x19` carrying a fresh `Authenticator.Begin(ctx)` payload, services any\nbroker challenges via `Continue`, and returns when the broker concludes\nwith `0x00` Success — no reconnect, no QoS-state churn. Ideal for a\nlong-lived connection whose bearer (OAuth/JWT) outlives the session: start\na timer from the token lifetime and call `Reauthenticate` ahead of expiry.\n`ctx` bounds the whole operation, including the token fetch in `Begin`.\nCalls are single-flighted per connection; a broker rejection returns\n`ErrReauthRejected` and tears the connection down so the supervisor\nreconnects through the normal CONNECT path. Pair with\n`WithOnReauthenticated` to observe successful refreshes centrally — it\nalso fires for a broker-driven re-auth, which has no return value to\ninspect.\n\nFor mechanisms with mutual authentication (e.g. SCRAM), an `Authenticator`\nmay also implement the optional `ServerFinalVerifier` interface\n(`VerifyServerFinal([]byte) error`): the client hands it the server's\nconcluding `AuthenticationData` — the CONNACK on connect, the AUTH `0x00`\non re-auth — so it can verify the server proved knowledge of the\ncredential. A verification failure aborts the connect, or fails\n`Reauthenticate` and tears the connection down.\n\n```go\n// e.g. 30s before the JWT `exp`:\nif err := cli.Reauthenticate(ctx); err != nil {\n    // ErrReauthRejected → broker refused the new credential;\n    // the supervisor is already reconnecting with a fresh CONNECT.\n}\n```\n\n## Sentinel errors\n\nBranch with `errors.Is(err, ...)`; stable across versions.\n\n| Error | Source | Meaning |\n|---|---|---|\n| `ErrNotConnected` | `Publish`, `Subscribe*`, `Unsubscribe` | No live connection. Retry / wait for reconnect. |\n| `ErrAlreadyConnected` | `Connect` | Connect called twice. |\n| `ErrClosed` | any after `Disconnect` | Client torn down. |\n| `ErrConnectRefused` | `Connect` | Broker non-success CONNACK reason. |\n| `ErrNoAuthenticator` | `Reauthenticate` | Called without `WithAuthenticator`. |\n| `ErrReauthInProgress` | `Reauthenticate` | A re-auth is already in flight on this connection (calls are single-flighted). |\n| `ErrReauthRejected` | `Reauthenticate` | Broker rejected re-auth via DISCONNECT; wrapped with the reason code. Connection torn down; supervisor reconnects. |\n| `ErrUnexpectedPacket` | various | Broker sent an unexpected packet for the current state. Treat as protocol bug. |\n| `ErrMissingBroker` | `New` | No URLs supplied. |\n| `ErrInvalidBrokerURL` | `New`, `SetBrokers` | URL failed to parse or has unsupported scheme. `WithDialFunc` relaxes scheme validation. |\n| `ErrChanDropOldestUnsupported` | `Subscribe` (chan) | Explicit `SubDropPolicy(DropOldest)` on the channel-based Subscribe. Use `SubscribeQueue` for DropOldest. |\n| `ErrWriteQueueFull` | `Publish` (QoS 0) | Writer queue at capacity AND client configured with `WithWriteOverflowPolicy(WriteDropNewest)`. The publish never reached the wire. |\n| `ErrNilHandler` | `SubscribeCallback` | Handler argument was nil. |\n| `ErrSharedSubsUnsupported` | `Subscribe*` | Broker disabled `$share/...` in CONNACK. |\n| `ErrWildcardSubsUnsupported` | `Subscribe*` | Broker disabled `+` / `#` in CONNACK. |\n| `ErrSubscriptionIDsUnsupported` | `Subscribe*` | Broker disabled SubscriptionIdentifier property. |\n| `ErrNoHealthyPublishers` | publisher pool internal | All pool members down — falls back to main conn. |\n| `ErrQueueClosed` | `QueuePublisher.Publish` | After `Close`. |\n| `ErrQueueFull` | `QueuePublisher.Publish` | `WithQueueMaxSize` cap reached (DropNewest). |\n| `ErrQoS0NotQueueable` | `QueuePublisher.Publish` | QoS 0 + durable enqueue is meaningless. |\n| `ErrEvictionNotSupported` | `NewQueuePublisher` | DropOldest requested on a backend that can't evict. |\n| `ws.ErrMissingTLSConfig` | `transport/ws.Dial` / `DialFunc` | `wss://` URL without a TLS config. No silent downgrade. |\n\n## Performance vs autopaho\n\nApple M2 Pro, Go 1.26, eclipse-mosquitto on loopback. Full output:\n[`benchmarks/e2e_results.txt`](benchmarks/e2e_results.txt). `WithStats`\nis off in the published numbers; the in-memory counters compile to a\nnil-check on the hot path when disabled, so enabling them is\nnegligible — re-run the suite if you want exact numbers under your\nload.\n\n### Codec micro (`wire` vs `paho.golang/packets`)\n\n| Decode 256 B, no props | autopaho | mqttv5 | speedup |\n|---|---:|---:|---:|\n| ns/op | 1,326 | **50** | **27×** |\n| allocs/op | 22 | **0** | |\n| B/op | 5,187 | **0** | |\n\n| Decode 256 B + 5 user properties (lazy) | autopaho | mqttv5 | speedup |\n|---|---:|---:|---:|\n| ns/op | 5,732 | **54** | **106×** |\n| allocs/op | 93 | **0** | |\n\nDecode allocation is **constant in payload size** — `Topic` and\n`Payload` are zero-copy slices into a pooled frame.\n\n### End-to-end vs real broker\n\n| Workload, 256 B payload | autopaho | mqttv5 | Result |\n|---|---:|---:|---|\n| Publish QoS 0 single goroutine | ~5–6 µs | ~5–6 µs | Within run-to-run noise (winner flips per run); mqttv5 is **zero-alloc** (0 vs 15) |\n| Publish QoS 1 (waits for PUBACK) | ~154 µs | **~142 µs** | mqttv5 ~8 % faster, 4.8× fewer allocs (11 vs 53) |\n| Publish QoS 1 × 8 goroutines, 1 KiB | ~33 µs | **~20 µs** | mqttv5 **~1.6× faster** under fan-in, 5× fewer allocs (11 vs 56) |\n| RoundTrip (pub → broker → sub) | ~223 µs | **~184 µs** | mqttv5 ~17 % faster, 4.5× fewer allocs (21 vs 95) |\n\nNumbers are means of the per-run figures in\n[`e2e_results.txt`](benchmarks/e2e_results.txt) (`-count=2`) — loopback\nto mosquitto, so they are dominated by the broker round-trip and are\n**noisy run to run** (±20–30 %). Treat the ratios and alloc counts as\nthe stable signal, not the absolute µs. Single-producer QoS 0 is a\n**wash within that noise** — the winner flips between runs, because\nautopaho writes inline under an uncontended mutex while mqttv5 hands the\npacket to its writer goroutine; that handoff is a draw at one producer\nand turns into the win once producers contend (fan-in). mqttv5 stays\nzero-alloc on that path either way. Got a *single* hot publisher and\nwant more single-connection throughput? `WithWriteBatch(32)` coalesces\nqueued packets into one `writev` (~25 % faster than autopaho in this\nloopback test); it's off by default to keep one-packet-per-segment\nframing.\n\n## Reliability semantics\n\n| Behaviour | Detail |\n|---|---|\n| `Connect` | Blocks until CONNACK (or ctx). Supervisor handles all subsequent reconnects in the background. |\n| Reconnect | `ExponentialBackoff(1s, 30s, 200ms)` default. With `WithBrokers`, URLs rotate per attempt; successful connect sticks. |\n| Publish QoS 1/2 across drop | Serialised once at register time; replayed on every reconnect with `DUP=1` (§3.3.1.1). Caller stays blocked on session's `Done` and resumes on the eventual ack. |\n| Subscribe across drop | Every active subscription re-issued on every reconnect. Subs the broker refused (SUBACK reason ≥ 0x80) drop from the resubscribe set. |\n| Re-authentication (§4.12) | Client-initiated via `Reauthenticate(ctx)` (AUTH `0x19` → `Begin`/`Continue` → broker `0x00` Success). Broker AUTH post-CONNACK also routes to `Authenticator.Continue`. The client always replies `0x18` Continue — only the server concludes (`0x00`), and an inbound `0x00` is terminal. Rejection → `ErrReauthRejected` + reconnect via fresh CONNECT; no Authenticator configured = DISCONNECT `0x87`. |\n| CONNACK capability flags | `Shared` / `Wildcard` / `SubscriptionIdentifier` availability honoured — `Subscribe*` errors before the wire if the broker disabled the feature. |\n| Server-initiated DISCONNECT | `WithOnServerDisconnect(fn)` fires with detached `*wire.Disconnect` before the generic `OnConnectionDown`. Callback may call `Client.SetBrokers(...)` to redirect; new list takes effect on next reconnect. |\n| PINGRESP liveness | No PINGRESP within `PingTimeout` → connection treated as dead → supervisor redials. |\n| Manual ack ordering | QoS 1 PUBACK held until `m.Ack()`, flushed in §4.6 arrival order. QoS 2 PUBREC held until `m.Ack()`; PUBCOMP fires automatically when PUBREL arrives. |\n| Multi-handler dispatch | A PUBLISH matching multiple overlapping filters delivers the **same** `*Message` to every handler. `m.Ack()` is refcounted — only the final call releases the frame. |\n| Topic / payload lifetime | Zero-copy slices into a pooled frame; valid until `Ack()` returns. Use `m.CloneTopic()` / `m.ClonePayload()` to retain past `Ack()`. |\n| Topic alias outbound | Auto-allocated on QoS 0 publishes when broker advertises `TopicAliasMaximum \u003e 0`. Skipped for QoS 1/2 so replay carries the full topic. |\n| Disconnect | Best-effort graceful DISCONNECT (bounded by ctx + `cs.dying`), tears down per-conn goroutines, closes consumer channels/queues. Idempotent. |\n\n## Architecture\n\nOne goroutine per connection drives `read → decode → trie match →\nhandlers (sync)`. A dedicated writer goroutine drains an MPSC channel\nof outbound frames — no mutex-around-Write contention. Packets and\nframe buffers come from per-type pools; topic and payload are\nzero-copy slices into the frame, valid until refcounted\n`Message.Ack()`. Properties decode lazily. A supervisor handles\nreconnect with configurable backoff, replays in-flight QoS 1/2\npublishes with `DUP=1`, and re-issues every tracked subscription.\n\n## Build / test / bench\n\n```bash\n# Core — no broker required.\ngo test ./...\ngo test -race ./...\n\n# Codec micro benchmarks (no broker).\ngo -C benchmarks test -bench=. -run=^$ -benchmem -count=3 -benchtime=2s\n\n# End-to-end vs autopaho (needs mosquitto).\ndocker compose -f conformance/docker-compose.yml up -d mosquitto\ngo -C benchmarks test -tags e2e -bench='^BenchmarkE2E_' -benchmem -benchtime=2s -count=2\n\n# Conformance suite (mosquitto + emqx).\ndocker compose -f conformance/docker-compose.yml up -d\ngo -C conformance test -tags conformance -race -v\n```\n\n## Stability\n\n- Wire protocol: MQTT v5 OASIS, stable.\n- Public `Client` / `Config` / option / `Stats` surface is stable;\n  any breaking change is called out in release notes with a mapping.\n- Sentinel errors above are stable; branch on them with `errors.Is`.\n- Submodules version independently — each has its own `go.mod`.\n- `wire/` codec internals are mutable — treat as private.\n\n## Independence\n\nIndependent, clean-room implementation written from the\n[MQTT v5.0 OASIS specification](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html).\nNot a fork of any existing Go MQTT client. The `benchmarks/`\nsubmodule imports `eclipse/paho.golang` for head-to-head comparison\nonly — it is not redistributed.\n\n## License\n\nApache 2.0 — see [LICENSE](LICENSE) for the full text and [NOTICE](NOTICE)\nfor the attribution notice. Per-file headers carry\n`SPDX-License-Identifier: Apache-2.0`.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fashtonian%2Fmqttv5","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fashtonian%2Fmqttv5","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fashtonian%2Fmqttv5/lists"}