Files
world-zap/ingest/worldmon.go
zeekay dbc63e80a9 feat: initial world-zap ZAP gateway
Real-time Hanzo World feeds over ZAP binary protocol (9-byte header +
JSON payload) fronted by WebSockets at wss://zap.world.hanzo.ai/zap.

IAM bearer auth via Authorization header or ?token= query param.
Validator caches /oauth/userinfo for 5 minutes.

Topics (10): world.events.{all,conflicts,earthquakes,fires},
world.markets.{quotes,crypto}, world.ships.ais, world.aviation.opensky,
world.news.live, world.weather.alerts. Minimum plan enforced per topic.

Tools: subscribe, unsubscribe, list_topics. Admin-only publish for
internal service accounts.

Ingest: streams events from worldmonitor GET /v1/world/events?stream=1,
fans out via bounded-channel hub with drop-on-overflow semantics.

Per-user token bucket rate limits: free 30/min, pro 3000/min, team
15000/min, enterprise 60000/min.

Tests: all packages pass -race (proto, auth, hub, ratelimit, ingest,
handlers). Multi-arch image build wired to hanzoai/.github docker-build
workflow.
2026-04-18 12:07:47 -07:00

171 lines
4.2 KiB
Go

// Package ingest pulls events from the worldmonitor backend and publishes
// them onto the hub. Two modes supported:
//
// 1. Streaming: GET /v1/world/events?stream=1 returns an open
// HTTP response emitting NDJSON (one event per line) or SSE-style
// "data: ..." frames. We parse either.
// 2. Fallback: if the stream endpoint returns an error or disconnects
// early, we fall back to a polling loop on /v1/world/events.
//
// Every record must be a JSON object of the form
// {"topic": "<name>", "payload": {...}}. The topic is validated against
// the hub catalog before publishing.
package ingest
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/hanzoai/world-zap/hub"
)
// Logger is a minimal structured logger interface so tests can provide a
// no-op implementation.
type Logger interface {
Info(msg string, kv ...any)
Warn(msg string, kv ...any)
Error(msg string, kv ...any)
}
// Config controls the ingester.
type Config struct {
BackendBase string
ServiceToken string
PollInterval time.Duration
ReconnectWait time.Duration
}
// Ingester owns the connection to the worldmonitor backend.
type Ingester struct {
cfg Config
hub *hub.Hub
log Logger
client *http.Client
}
// New constructs an Ingester.
func New(cfg Config, h *hub.Hub, log Logger) *Ingester {
if cfg.ReconnectWait <= 0 {
cfg.ReconnectWait = 2 * time.Second
}
if cfg.PollInterval <= 0 {
cfg.PollInterval = 5 * time.Second
}
return &Ingester{
cfg: cfg,
hub: h,
log: log,
client: &http.Client{Timeout: 0}, // streaming has no deadline
}
}
// Run drives the ingest loop until ctx is cancelled. Errors are logged and
// the loop reconnects with backoff.
func (i *Ingester) Run(ctx context.Context) {
if i.cfg.BackendBase == "" {
i.log.Warn("ingest: no backend configured, disabled")
return
}
backoff := i.cfg.ReconnectWait
for {
if ctx.Err() != nil {
return
}
err := i.stream(ctx)
if ctx.Err() != nil {
return
}
if err != nil {
i.log.Warn("ingest: stream ended", "err", err.Error())
}
// Exponential backoff capped at 60s.
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
backoff *= 2
if backoff > 60*time.Second {
backoff = 60 * time.Second
}
}
}
// stream opens one streaming session and consumes until EOF or error.
func (i *Ingester) stream(ctx context.Context) error {
url := strings.TrimRight(i.cfg.BackendBase, "/") + "/v1/world/events?stream=1"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
if i.cfg.ServiceToken != "" {
req.Header.Set("Authorization", "Bearer "+i.cfg.ServiceToken)
}
req.Header.Set("Accept", "text/event-stream, application/x-ndjson, application/json")
res, err := i.client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 400 {
return fmt.Errorf("ingest: backend status %d", res.StatusCode)
}
scanner := bufio.NewScanner(res.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1<<22) // 4 MiB line ceiling
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if strings.HasPrefix(line, "data:") {
line = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
}
if line == "" || line == "[DONE]" {
continue
}
if err := i.handleLine([]byte(line)); err != nil {
i.log.Warn("ingest: bad line", "err", err.Error())
}
}
if err := scanner.Err(); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
return err
}
return io.EOF
}
// record is the canonical event shape produced by the backend.
type record struct {
Topic string `json:"topic"`
Payload json.RawMessage `json:"payload"`
}
func (i *Ingester) handleLine(b []byte) error {
var r record
if err := json.Unmarshal(b, &r); err != nil {
return err
}
if r.Topic == "" || len(r.Payload) == 0 {
return fmt.Errorf("ingest: missing topic or payload")
}
if err := i.hub.Publish(hub.Message{Topic: r.Topic, Payload: []byte(r.Payload)}); err != nil {
if errors.Is(err, hub.ErrUnknownTopic) {
// Drop silently — unknown topics should not kill the stream.
return nil
}
return err
}
return nil
}