wingbits client and cli helpers for mgw310 and other devices
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
// Package client is a client for a Wingbits station (such as the MGW310). It
|
||||
// exposes the readsb JSON files (aircraft, receiver, stats, outline) and the
|
||||
// Wingbits-specific endpoints (network diagnostics, Prometheus metrics and
|
||||
// Tailscale status) as typed Go values, with both one-shot queries and
|
||||
// channel-based streaming.
|
||||
//
|
||||
// Decoded values come from the type-only packages pkg/types/readsb and
|
||||
// pkg/types/wingbits; this package owns the transport and request logic.
|
||||
//
|
||||
// A station serves two HTTP roots:
|
||||
//
|
||||
// - The Wingbits root (default :8088) serves the Wingbits endpoints directly
|
||||
// and the readsb files under readsb/.
|
||||
// - The tar1090 root (default :8504) serves the readsb files under data/,
|
||||
// plus data/outline.json. When configured it overrides the Wingbits root
|
||||
// for all readsb files.
|
||||
package client
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Default ports exposed by an MGW310 station.
|
||||
const (
|
||||
DefaultWingbitsPort = 8088
|
||||
DefaultTar1090Port = 8504
|
||||
// defaultDataPath is the tar1090 path under which readsb files are served.
|
||||
defaultDataPath = "data"
|
||||
// wingbitsReadsbPath is the path under which the Wingbits root serves readsb files.
|
||||
wingbitsReadsbPath = "readsb"
|
||||
)
|
||||
|
||||
// Endpoint describes one HTTP root on a station.
|
||||
type Endpoint struct {
|
||||
// Scheme is "http" (default) or "https".
|
||||
Scheme string
|
||||
// Host is the hostname or IP of the station.
|
||||
Host string
|
||||
// Port is the TCP port; 0 selects the role's default.
|
||||
Port int
|
||||
// DataPath is the URL path prefix under which readsb files are served. For
|
||||
// the tar1090 root this defaults to "data"; for the Wingbits root the
|
||||
// readsb files live under "readsb".
|
||||
DataPath string
|
||||
// TLS configures the transport when Scheme is "https". It is only applied
|
||||
// when the client builds its own http.Client (i.e. no WithHTTPClient).
|
||||
TLS *tls.Config
|
||||
}
|
||||
|
||||
func (e Endpoint) scheme() string {
|
||||
if e.Scheme != "" {
|
||||
return e.Scheme
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
|
||||
func (e Endpoint) hostPort(defPort int) string {
|
||||
port := e.Port
|
||||
if port == 0 {
|
||||
port = defPort
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", e.Host, port)
|
||||
}
|
||||
|
||||
// url builds an absolute URL for path elements joined under the endpoint,
|
||||
// optionally prefixed by the endpoint's data path.
|
||||
func (e Endpoint) url(defPort int, withData bool, elem ...string) string {
|
||||
parts := elem
|
||||
if withData && e.DataPath != "" {
|
||||
parts = append([]string{e.DataPath}, elem...)
|
||||
}
|
||||
u := url.URL{
|
||||
Scheme: e.scheme(),
|
||||
Host: e.hostPort(defPort),
|
||||
Path: "/" + strings.Join(parts, "/"),
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// Client talks to a Wingbits station. Construct it with New or NewMGW310Client.
|
||||
// It is safe for concurrent use.
|
||||
type Client struct {
|
||||
wingbits Endpoint
|
||||
tar1090 *Endpoint
|
||||
http *http.Client
|
||||
streamBuf int
|
||||
userAgent string
|
||||
}
|
||||
|
||||
// Option customizes a Client.
|
||||
type Option func(*Client)
|
||||
|
||||
// WithWingbitsEndpoint sets (and overrides) the Wingbits HTTP root.
|
||||
func WithWingbitsEndpoint(e Endpoint) Option {
|
||||
return func(c *Client) { c.wingbits = e }
|
||||
}
|
||||
|
||||
// WithTar1090Endpoint enables a tar1090 HTTP root. When set it overrides the
|
||||
// Wingbits root for all readsb files (aircraft, receiver, stats) and is the
|
||||
// only source for outline.json.
|
||||
func WithTar1090Endpoint(e Endpoint) Option {
|
||||
return func(c *Client) {
|
||||
if e.DataPath == "" {
|
||||
e.DataPath = defaultDataPath
|
||||
}
|
||||
c.tar1090 = &e
|
||||
}
|
||||
}
|
||||
|
||||
// WithHTTPClient supplies a custom http.Client. When provided, per-endpoint TLS
|
||||
// config is ignored (configure it on the supplied client's transport instead).
|
||||
func WithHTTPClient(h *http.Client) Option {
|
||||
return func(c *Client) { c.http = h }
|
||||
}
|
||||
|
||||
// WithStreamBufferSize sets the buffer size of channels returned by the Stream*
|
||||
// methods. The default is 1.
|
||||
func WithStreamBufferSize(n int) Option {
|
||||
return func(c *Client) {
|
||||
if n >= 0 {
|
||||
c.streamBuf = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithUserAgent overrides the User-Agent header sent with requests.
|
||||
func WithUserAgent(ua string) Option {
|
||||
return func(c *Client) { c.userAgent = ua }
|
||||
}
|
||||
|
||||
// New constructs a Client from options. A Wingbits endpoint with a non-empty
|
||||
// Host must be supplied via WithWingbitsEndpoint.
|
||||
func New(opts ...Option) (*Client, error) {
|
||||
c := &Client{
|
||||
streamBuf: 1,
|
||||
userAgent: "wingbits-go/1.0",
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
if c.wingbits.DataPath == "" {
|
||||
c.wingbits.DataPath = wingbitsReadsbPath
|
||||
}
|
||||
if c.wingbits.Host == "" {
|
||||
return nil, fmt.Errorf("wingbits: a wingbits endpoint host is required")
|
||||
}
|
||||
if c.http == nil {
|
||||
c.http = buildHTTPClient(c.wingbits, c.tar1090)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// NewMGW310Client is a convenience constructor for a standard MGW310 reachable
|
||||
// at host. It wires the Wingbits root to :8088 and a tar1090 root to :8504, then
|
||||
// applies any options (which may override either endpoint, TLS, the HTTP client,
|
||||
// and so on).
|
||||
func NewMGW310Client(host string, opts ...Option) (*Client, error) {
|
||||
base := []Option{
|
||||
WithWingbitsEndpoint(Endpoint{Host: host, Port: DefaultWingbitsPort, DataPath: wingbitsReadsbPath}),
|
||||
WithTar1090Endpoint(Endpoint{Host: host, Port: DefaultTar1090Port, DataPath: defaultDataPath}),
|
||||
}
|
||||
return New(append(base, opts...)...)
|
||||
}
|
||||
|
||||
// readsbEndpoint returns the endpoint and default port serving readsb files,
|
||||
// preferring a configured tar1090 root.
|
||||
func (c *Client) readsbEndpoint() (Endpoint, int) {
|
||||
if c.tar1090 != nil {
|
||||
return *c.tar1090, DefaultTar1090Port
|
||||
}
|
||||
return c.wingbits, DefaultWingbitsPort
|
||||
}
|
||||
|
||||
// buildHTTPClient creates a default client, attaching TLS config from whichever
|
||||
// endpoint supplies one (the tar1090 root takes precedence when both do).
|
||||
func buildHTTPClient(wb Endpoint, tar *Endpoint) *http.Client {
|
||||
var tlsCfg *tls.Config
|
||||
if wb.TLS != nil {
|
||||
tlsCfg = wb.TLS
|
||||
}
|
||||
if tar != nil && tar.TLS != nil {
|
||||
tlsCfg = tar.TLS
|
||||
}
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: tlsCfg,
|
||||
MaxIdleConnsPerHost: 2,
|
||||
}
|
||||
return &http.Client{Timeout: 15 * time.Second, Transport: tr}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/types/readsb"
|
||||
)
|
||||
|
||||
// fixtures map a request path to a captured station response. The readsb files
|
||||
// and the Wingbits files are reused from their respective type packages' testdata
|
||||
// so there is a single source of truth for sample payloads.
|
||||
var fixtures = map[string]string{
|
||||
"/readsb/aircraft.json": "../types/readsb/testdata/aircraft.json",
|
||||
"/data/aircraft.json": "../types/readsb/testdata/aircraft.json",
|
||||
"/data/receiver.json": "../types/readsb/testdata/receiver.json",
|
||||
"/data/stats.json": "../types/readsb/testdata/stats.json",
|
||||
"/data/outline.json": "../types/readsb/testdata/outline.json",
|
||||
"/network/diagnostics": "../types/wingbits/testdata/diagnostics.json",
|
||||
"/tailscale/status": "../types/wingbits/testdata/status.json",
|
||||
"/metrics": "../types/wingbits/testdata/metrics.txt",
|
||||
}
|
||||
|
||||
// newTestClient stands up an httptest server that serves the captured station
|
||||
// fixtures from both the Wingbits (readsb/) and tar1090 (data/) roots.
|
||||
func newTestClient(t *testing.T) *Client {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
for path, file := range fixtures {
|
||||
mux.HandleFunc(path, serveFile(file))
|
||||
}
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
host, port := splitHostPort(t, strings.TrimPrefix(srv.URL, "http://"))
|
||||
c, err := New(
|
||||
WithWingbitsEndpoint(Endpoint{Host: host, Port: port, DataPath: wingbitsReadsbPath}),
|
||||
WithTar1090Endpoint(Endpoint{Host: host, Port: port, DataPath: defaultDataPath}),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func serveFile(file string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
b, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Write(b)
|
||||
}
|
||||
}
|
||||
|
||||
func splitHostPort(t *testing.T, hp string) (string, int) {
|
||||
t.Helper()
|
||||
h, p, ok := strings.Cut(hp, ":")
|
||||
if !ok {
|
||||
t.Fatalf("bad host:port %q", hp)
|
||||
}
|
||||
port, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
t.Fatalf("bad port %q: %v", p, err)
|
||||
}
|
||||
return h, port
|
||||
}
|
||||
|
||||
func TestClientReadsbEndpoints(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
ctx := context.Background()
|
||||
|
||||
all, err := c.Aircraft(ctx)
|
||||
if err != nil || len(all.Aircraft) == 0 {
|
||||
t.Fatalf("aircraft: %v (%d)", err, len(all.Aircraft))
|
||||
}
|
||||
// Filters supplied to the query are applied before returning.
|
||||
pos, err := c.Aircraft(ctx, readsb.WithPosition())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pos.Aircraft) == 0 || len(pos.Aircraft) > len(all.Aircraft) {
|
||||
t.Errorf("filtered %d of %d", len(pos.Aircraft), len(all.Aircraft))
|
||||
}
|
||||
|
||||
rc, err := c.Receiver(ctx)
|
||||
if err != nil || !rc.HasLocation() {
|
||||
t.Fatalf("receiver: %v", err)
|
||||
}
|
||||
if _, err := c.Stats(ctx); err != nil {
|
||||
t.Fatalf("stats: %v", err)
|
||||
}
|
||||
o, err := c.Outline(ctx)
|
||||
if err != nil || len(o.ActualRange.Last24h.Points) == 0 {
|
||||
t.Fatalf("outline: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutlineRequiresTar1090(t *testing.T) {
|
||||
c, err := New(WithWingbitsEndpoint(Endpoint{Host: "127.0.0.1"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.Outline(context.Background()); err == nil {
|
||||
t.Error("expected error without tar1090 endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientWingbitsEndpoints(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
ctx := context.Background()
|
||||
|
||||
d, err := c.Diagnostics(ctx)
|
||||
if err != nil || d.Hostname == "" {
|
||||
t.Fatalf("diagnostics: %v", err)
|
||||
}
|
||||
if !d.AllReachable() {
|
||||
t.Error("expected fixture probes all reachable")
|
||||
}
|
||||
s, err := c.Status(ctx)
|
||||
if err != nil || s.Hostname == "" {
|
||||
t.Fatalf("status: %v", err)
|
||||
}
|
||||
m, err := c.Metrics(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("metrics: %v", err)
|
||||
}
|
||||
wb := m.Wingbits()
|
||||
if wb.Version == "" {
|
||||
t.Error("expected wingbits_version label")
|
||||
}
|
||||
if !wb.NATS.Connected {
|
||||
t.Error("expected NATS connected in fixture")
|
||||
}
|
||||
if wb.Beast.Received == 0 {
|
||||
t.Error("expected beast received counter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamAircraft(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
ch := c.StreamAircraft(ctx, time.Hour)
|
||||
select {
|
||||
case u := <-ch:
|
||||
if u.Err != nil || u.Value == nil || len(u.Value.Aircraft) == 0 {
|
||||
t.Fatalf("first update bad: %v", u.Err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("no first update")
|
||||
}
|
||||
cancel()
|
||||
for range ch { // channel must drain and close after cancellation
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/types/readsb"
|
||||
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/types/wingbits"
|
||||
)
|
||||
|
||||
// Aircraft fetches and decodes aircraft.json. When one or more filters are
|
||||
// supplied, only matching aircraft are retained in the returned report.
|
||||
func (c *Client) Aircraft(ctx context.Context, filters ...readsb.AircraftFilter) (*readsb.AircraftReport, error) {
|
||||
ep, port := c.readsbEndpoint()
|
||||
report, err := getJSON[readsb.AircraftReport](ctx, c, ep.url(port, true, "aircraft.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
report.Aircraft = report.Filter(filters...)
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// Receiver fetches and decodes receiver.json.
|
||||
func (c *Client) Receiver(ctx context.Context) (*readsb.Receiver, error) {
|
||||
ep, port := c.readsbEndpoint()
|
||||
return getJSON[readsb.Receiver](ctx, c, ep.url(port, true, "receiver.json"))
|
||||
}
|
||||
|
||||
// Stats fetches and decodes stats.json.
|
||||
func (c *Client) Stats(ctx context.Context) (*readsb.Stats, error) {
|
||||
ep, port := c.readsbEndpoint()
|
||||
return getJSON[readsb.Stats](ctx, c, ep.url(port, true, "stats.json"))
|
||||
}
|
||||
|
||||
// Outline fetches and decodes outline.json (the receiver range polygon). It is
|
||||
// only served by the tar1090 root, so a tar1090 endpoint must be configured.
|
||||
func (c *Client) Outline(ctx context.Context) (*readsb.Outline, error) {
|
||||
if c.tar1090 == nil {
|
||||
return nil, fmt.Errorf("wingbits: outline.json requires a tar1090 endpoint")
|
||||
}
|
||||
return getJSON[readsb.Outline](ctx, c, c.tar1090.url(DefaultTar1090Port, true, "outline.json"))
|
||||
}
|
||||
|
||||
// Diagnostics fetches and decodes /network/diagnostics from the Wingbits root.
|
||||
func (c *Client) Diagnostics(ctx context.Context) (*wingbits.Diagnostics, error) {
|
||||
return getJSON[wingbits.Diagnostics](ctx, c, c.wingbits.url(DefaultWingbitsPort, false, "network", "diagnostics"))
|
||||
}
|
||||
|
||||
// Status fetches and decodes the Tailscale status from the Wingbits root.
|
||||
func (c *Client) Status(ctx context.Context) (*wingbits.TailscaleStatus, error) {
|
||||
return getJSON[wingbits.TailscaleStatus](ctx, c, c.wingbits.url(DefaultWingbitsPort, false, "tailscale", "status"))
|
||||
}
|
||||
|
||||
// Metrics fetches /metrics from the Wingbits root and parses the Prometheus
|
||||
// text exposition format into structured metric families.
|
||||
func (c *Client) Metrics(ctx context.Context) (*wingbits.Metrics, error) {
|
||||
body, err := c.get(ctx, c.wingbits.url(DefaultWingbitsPort, false, "metrics"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer body.Close()
|
||||
return wingbits.ParseMetrics(body)
|
||||
}
|
||||
|
||||
// getJSON fetches a URL and decodes its JSON body into a freshly allocated T.
|
||||
func getJSON[T any](ctx context.Context, c *Client, url string) (*T, error) {
|
||||
body, err := c.get(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer body.Close()
|
||||
out := new(T)
|
||||
if err := json.NewDecoder(body).Decode(out); err != nil {
|
||||
return nil, fmt.Errorf("wingbits: decoding %s: %w", url, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// get performs a GET and returns the response body, which the caller must close.
|
||||
func (c *Client) get(ctx context.Context, url string) (io.ReadCloser, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wingbits: building request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", c.userAgent)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wingbits: GET %s: %w", url, err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("wingbits: GET %s: unexpected status %s", url, resp.Status)
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/types/readsb"
|
||||
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/types/wingbits"
|
||||
)
|
||||
|
||||
// Update carries one streamed sample of T, or the error from the fetch that
|
||||
// produced it. Exactly one of Value and Err is meaningful per send.
|
||||
type Update[T any] struct {
|
||||
Value *T
|
||||
Err error
|
||||
}
|
||||
|
||||
// StreamAircraft polls aircraft.json every interval and sends each decoded,
|
||||
// filtered report on the returned channel until ctx is cancelled. The first
|
||||
// sample is sent immediately. The channel is closed when ctx ends.
|
||||
func (c *Client) StreamAircraft(ctx context.Context, interval time.Duration, filters ...readsb.AircraftFilter) <-chan Update[readsb.AircraftReport] {
|
||||
return stream(ctx, c.streamBuf, interval, func(ctx context.Context) (*readsb.AircraftReport, error) {
|
||||
return c.Aircraft(ctx, filters...)
|
||||
})
|
||||
}
|
||||
|
||||
// StreamStats polls stats.json every interval.
|
||||
func (c *Client) StreamStats(ctx context.Context, interval time.Duration) <-chan Update[readsb.Stats] {
|
||||
return stream(ctx, c.streamBuf, interval, c.Stats)
|
||||
}
|
||||
|
||||
// StreamReceiver polls receiver.json every interval.
|
||||
func (c *Client) StreamReceiver(ctx context.Context, interval time.Duration) <-chan Update[readsb.Receiver] {
|
||||
return stream(ctx, c.streamBuf, interval, c.Receiver)
|
||||
}
|
||||
|
||||
// StreamOutline polls outline.json every interval.
|
||||
func (c *Client) StreamOutline(ctx context.Context, interval time.Duration) <-chan Update[readsb.Outline] {
|
||||
return stream(ctx, c.streamBuf, interval, c.Outline)
|
||||
}
|
||||
|
||||
// StreamDiagnostics polls /network/diagnostics every interval.
|
||||
func (c *Client) StreamDiagnostics(ctx context.Context, interval time.Duration) <-chan Update[wingbits.Diagnostics] {
|
||||
return stream(ctx, c.streamBuf, interval, c.Diagnostics)
|
||||
}
|
||||
|
||||
// StreamMetrics polls /metrics every interval.
|
||||
func (c *Client) StreamMetrics(ctx context.Context, interval time.Duration) <-chan Update[wingbits.Metrics] {
|
||||
return stream(ctx, c.streamBuf, interval, c.Metrics)
|
||||
}
|
||||
|
||||
// StreamStatus polls the Tailscale status every interval.
|
||||
func (c *Client) StreamStatus(ctx context.Context, interval time.Duration) <-chan Update[wingbits.TailscaleStatus] {
|
||||
return stream(ctx, c.streamBuf, interval, c.Status)
|
||||
}
|
||||
|
||||
// stream drives a generic poll loop: it calls fetch immediately, then once per
|
||||
// interval tick, forwarding each result as an Update on a buffered channel.
|
||||
func stream[T any](ctx context.Context, buf int, interval time.Duration, fetch func(context.Context) (*T, error)) <-chan Update[T] {
|
||||
ch := make(chan Update[T], buf)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
tick := time.NewTicker(interval)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
send(ctx, ch, fetch)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
// send runs one fetch and delivers the result, respecting cancellation so a
|
||||
// full channel cannot wedge the loop past ctx's lifetime.
|
||||
func send[T any](ctx context.Context, ch chan<- Update[T], fetch func(context.Context) (*T, error)) {
|
||||
v, err := fetch(ctx)
|
||||
select {
|
||||
case ch <- Update[T]{Value: v, Err: err}:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user