A minimal, allocation-conscious UDP broadcast discovery package for Go.
Beacon provides a simple primitive for discovering services on a local network. It is built entirely around standard library components, using pure functions to handle broadcasting a probe and listening for replies. It manages the network lifecycle explicitly, without long-lived background workers, hidden state, or forced serialization formats.
- Standard Library Only: Beacon relies exclusively on
netandcontext. There are no external dependencies. - Zero Read Allocations: You provide the byte buffers. Beacon performs zero hidden allocations during the read loop.
- Pure Functions: Control flow is explicit. The package does not manage long-lived state or persistent background daemon workers. The only concurrency used is strictly bound to the execution lifecycle of the function to handle context cancellation.
- Agnostic Payloads: Beacon transports raw bytes. Serialization, handshakes, and retry logic are left to the caller.
go get lowbit.dev/beacon
Beacon exposes exactly two pure functions: Seek and Serve.
The responder blocks and listens for incoming UDP broadcasts. It evaluates incoming bytes purely on the stack using a caller-provided validation function.
package main
import (
"bytes"
"context"
"log"
"lowbit.dev/beacon"
)
func main() {
ctx := context.Background()
// Pre-allocate a single buffer for the entire lifecycle of the server.
buf := make([]byte, 1024)
// Define your probe and payload.
expectedProbe := []byte("PING")
myAddress := []byte("192.168.1.50:8080")
// The validation function allows you to reject bad probes cheaply.
isValid := func(probe []byte) bool {
return bytes.Equal(probe, expectedProbe)
}
log.Println("Listening for broadcasts on :9999...")
// Serve blocks until the context is canceled or an unhandled network error occurs.
err := beacon.Serve(ctx, ":9999", buf, isValid, myAddress)
if err != nil {
log.Fatalf("Server exited: %v", err)
}
}The seeker broadcasts a payload to a target address (typically the broadcast address of the local network) and waits for a reply. Context is used for timeouts, forcing the network read to unblock cleanly.
package main
import (
"context"
"errors"
"log"
"time"
"lowbit.dev/beacon"
)
func main() {
// Enforce a strict timeout on the discovery process.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
probe := []byte("PING")
buf := make([]byte, 1024)
// Broadcast to the local network on the agreed port.
n, addr, err := beacon.Seek(ctx, "255.255.255.255:9999", probe, buf)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
log.Fatal("Discovery timed out: no manager found.")
}
log.Fatalf("Discovery failed: %v", err)
}
payload := string(buf[:n])
log.Printf("Found manager at %s. Payload: %s\n", addr.String(), payload)
}Beacon exposes all failure modes using standard Go sentinel errors. You can inspect failures using errors.Is().
beacon.ErrInvalidAddrbeacon.ErrListenbeacon.ErrWriteProbebeacon.ErrReadProbebeacon.ErrWriteReplybeacon.ErrReadResponse
If Serve encounters a write error while attempting to reply to a client, it will return beacon.ErrWriteReply. The loop will exit, and it is up to the caller to decide how to handle the failure (e.g., wrap Serve in a for loop to retry, log the error, or crash the application).