Files
explorer/main.go
T
Darkhorse7starsandGitHub 01f121746b feat(realtime): add per-channel SSE endpoints for the bundled SPA (#5)
* fix(frontend): 404 on HEAD for SPA-fallback paths

The bundled SPA opens EventSource (SSE) channels for live block / tx /
token streams and pre-flights each with HEAD:

  fetch(url, {method: 'HEAD'}).then(r => r.ok ? openEventSource() : disable());

When the path doesn't match a real asset and falls through to index.html,
the HEAD returns 200 — so the SPA proceeds to open the EventSource. That
immediately aborts because the body is `text/html`, not
`text/event-stream`, and the browser logs:

  EventSource's response has a MIME type ('text/html') that is not
  'text/event-stream'. Aborting the connection.

…for every channel (blocks, internal-txs, tokens, token-transfers,
gas-tracker, stats, validators). Noisy; channel disabled anyway after
the abort.

Return 404 specifically for HEAD requests on the SPA-fallback path. Real
embedded assets still serve fine on HEAD; only the index.html fallback
gets the 404, which lets the SPA's HEAD pre-flight cleanly fail and the
channel disable itself silently. GET/POST/etc. on the same paths still
serve index.html for SPA client-side routing — no visible UX change.

* feat(realtime): add per-channel SSE endpoints for the bundled SPA

The bundled Vite SPA opens EventSource (Server-Sent Events) against
relative paths matching its channel list — `/blocks`,
`/transactions`, `/token-transfers`, `/internal-txs`, `/tokens`,
`/gas-tracker`, `/validators`, `/stats`. None of these existed on
the backend, so every channel pre-flight (`HEAD <path>`) fell
through to the SPA's static fallback, the SPA then opened the
EventSource, and the browser logged:

    EventSource's response has a MIME type ("text/html") that is not
    "text/event-stream". Aborting the connection.

…for each channel on every page load. PR #4 silenced this by 404-ing
the HEAD pre-flight for SPA-fallback paths. This PR goes further:
make the endpoints REAL so the SPA stays connected and receives
broadcasts from the existing RealtimeHub.

Adds:
* `realtime_sse.go` — `sseRegistry` (separate from the WebSocket
  client map), `HandleSSE(channel)` factory returning a per-path
  http.HandlerFunc. Each client gets a 64-deep bounded channel;
  slow consumers drop messages rather than back-pressure the hub.
* `RealtimeHub.Broadcast` — now fans out to both WebSocket clients
  (existing path, unchanged shape) AND SSE clients (new path).
  Message payload is encoded once, wrapped in SSE framing only
  when sending to the SSE registry.
* `main.go` — 8 path → channel mappings, registered on the mux with
  explicit HEAD + GET so the SPA's HEAD pre-flight + EventSource
  open both hit the same handler.

Special handling:
* HEAD requests return 200 + the SSE Content-Type, no body. The
  SPA's `fetch(url, {method: HEAD}).then(r => r.ok)` check passes
  and EventSource opens cleanly.
* `/stats` synthesizes an event every 5s from the hub's connection
  count — useful liveness signal even before broadcasters are
  wired up.
* Other channels stay open with 30s keep-alive comment lines
  (`: ping\n\n`) and wait for hub.Broadcast() to fire. They emit
  no events today (no production broadcasters), but the
  infrastructure is in place — once the indexer is wired up to
  push to the hub, live streaming "just works".

Verified locally:
* `go build .` → clean (34MB binary)
* `go test ./... -run 'Realtime|Hub|SSE'` → ok
* The existing WebSocket subscribe / broadcast flow is unchanged
  (no breaking changes to RealtimeHub's public API or wire format).

---------
2026-05-21 18:57:04 -07:00

232 lines
7.0 KiB
Go

// Package main is the unified Lux explorer: a single Go binary that embeds
// the SPA, indexes one or more chains via github.com/luxfi/indexer, runs a
// per-chain GraphQL engine via github.com/luxfi/graph, and exposes the
// admin/realtime API and frontend assets on one HTTP listener.
//
// Build: go build -o explorer .
// Run: explorer --config /etc/explorer/chains.yaml
//
// Routes:
//
// / SPA (embedded; SPA-routing fallback)
// /envs.js runtime config window.ENV = {...}
// /icon.svg, /logo.svg per-host brand assets (disk override)
// /health service health
// /v1/indexer/* default chain explorer API
// /v1/indexer/{slug}/* per-chain explorer API
// /v1/explorer/{slug}/* legacy alias for /v1/indexer/{slug}
// /v1/graph/{slug}/{subgraph}/graphql per-chain, per-subgraph GraphQL
// /v1/explorer/admin/chains[/{slug}] runtime registry CRUD
// /v1/explorer/realtime WebSocket realtime hub
// /v1/explorer/realtime/stats realtime stats
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
)
var version = "dev"
func main() {
var (
httpAddr = flag.String("http", "", "HTTP listen address (default :8090 / $HTTP_ADDR)")
dataDir = flag.String("data", "", "Data directory (default $DATA_DIR or ~/.explorer/data)")
configPath = flag.String("config", "", "Path to chains.yaml ($EXPLORER_CONFIG)")
enableMDNS = flag.Bool("mdns", envBool("EXPLORER_MDNS", false), "Auto-discover chains via mDNS")
showVer = flag.Bool("version", false, "Show version and exit")
)
flag.Parse()
if *showVer {
fmt.Printf("explorer %s (%s)\n", version, fingerprint())
os.Exit(0)
}
if *httpAddr == "" {
*httpAddr = env("HTTP_ADDR", ":8090")
if p := os.Getenv("PORT"); p != "" && *httpAddr == ":8090" {
*httpAddr = ":" + p
}
}
if *dataDir == "" {
home, _ := os.UserHomeDir()
*dataDir = env("DATA_DIR", filepath.Join(home, ".explorer", "data"))
}
if *configPath == "" {
*configPath = env("EXPLORER_CONFIG", "")
}
if *configPath == "" {
*configPath = findConfig(*dataDir)
}
var cfg Config
if *configPath != "" {
c, err := LoadConfig(*configPath)
if err != nil {
log.Fatalf("[explorer] config: %v", err)
}
cfg = c
}
if cfg.DataDir == "" {
cfg.DataDir = *dataDir
}
if cfg.HTTPAddr == "" {
cfg.HTTPAddr = *httpAddr
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() { <-sigCh; log.Println("[explorer] shutdown"); cancel() }()
registry := NewChainRegistry()
supervisor := NewChainSupervisor(cfg.DataDir)
registry.AttachSupervisor(supervisor)
for _, c := range cfg.Chains {
c.Source = "config"
if err := registry.Add(c); err != nil {
log.Printf("[explorer] skip %s from config: %v", c.Slug, err)
}
}
if chains := os.Getenv("CHAINS"); chains != "" {
registry.LoadFromEnv(chains)
}
if *enableMDNS {
go registry.StartMDNSDiscovery()
}
go registry.hub.Run(ctx)
go supervisor.Wait(ctx)
frontend, err := NewFrontend(cfg, registry)
if err != nil {
log.Fatalf("[explorer] frontend: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"version": version,
"chains": registry.Count(),
})
})
mux.HandleFunc("GET /v1/explorer/admin/chains", registry.HandleList)
mux.HandleFunc("POST /v1/explorer/admin/chains", registry.HandleAdd)
mux.HandleFunc("PUT /v1/explorer/admin/chains/{slug}", registry.HandleUpdate)
mux.HandleFunc("DELETE /v1/explorer/admin/chains/{slug}", registry.HandleRemove)
mux.HandleFunc("/v1/explorer/realtime", registry.hub.HandleRealtime)
mux.HandleFunc("GET /v1/explorer/realtime/stats", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(registry.hub.Stats())
})
// SSE endpoints at the URLs the bundled SPA opens EventSource on.
// See realtime_sse.go for the channel mapping + the back-story on
// why each path needs its own handler. Registering HEAD + GET
// explicitly so the SPA's pre-flight (HEAD-then-EventSource) hits
// the same SSE handler — net/http's ServeMux otherwise returns 405
// "Method Not Allowed" for HEAD on a GET-only route, which the
// SPA's `u.ok` check would reject and disable the channel.
for path, channel := range map[string]string{
"/blocks": "blocks",
"/transactions": "transactions",
"/token-transfers": "token_transfers",
"/internal-txs": "internal_transactions",
"/tokens": "tokens",
"/gas-tracker": "gas_tracker",
"/validators": "validators",
"/stats": "stats",
} {
h := registry.hub.HandleSSE(channel)
mux.HandleFunc("GET "+path, h)
mux.HandleFunc("HEAD "+path, h)
}
supervisor.MountRoutes(mux)
frontend.Mount(mux)
log.Printf("[explorer] %s listening %s data=%s chains=%d mdns=%v %s",
version, cfg.HTTPAddr, cfg.DataDir, registry.Count(), *enableMDNS, fingerprint())
server := &http.Server{Addr: cfg.HTTPAddr, Handler: withSecurity(mux)}
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("[explorer] server: %v", err)
}
}()
<-ctx.Done()
shutCtx, shutCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutCancel()
server.Shutdown(shutCtx)
log.Println("[explorer] stopped")
}
// withSecurity adds the baseline security headers and CORS preflight handler.
func withSecurity(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func envBool(key string, fallback bool) bool {
switch strings.ToLower(os.Getenv(key)) {
case "true", "1", "yes":
return true
case "false", "0", "no":
return false
}
return fallback
}
// findConfig probes for a chains.yaml in the standard locations.
func findConfig(dataDir string) string {
for _, p := range []string{
filepath.Join(dataDir, "chains.yaml"),
"chains.yaml",
"/etc/explorer/chains.yaml",
} {
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}