Files
explorer/main.go
T
zeekay 9c1c9c7683 unified explorer: single Go binary with embedded SPA + per-chain graph
Module renamed to github.com/luxfi/explorer (was -unified). Imports
luxfi/indexer/{daemon,evm,storage,explorer} and luxfi/graph/{engine,
indexer,storage} as libraries; embeds the luxfi/explore SPA via go:embed
all:static.

A new ChainSupervisor wraps the existing ChainRegistry: adding a chain
(via config / mDNS / admin API) spawns a per-chain indexer goroutine plus
one graph engine + indexer per enabled subgraph; removing cancels them
and frees the routes from a sync.Map dispatcher mounted on /v1/indexer/,
/v1/explorer/ (legacy alias), and /v1/graph/.

Config schema is fully per-chain: indexer poll interval, list of graph
subgraphs (each can pick a built-in schema like amm, securities, fhe…),
brand (name/coin/accent + on-disk icon/logo paths), token features, and
top-level networks list. /envs.js exposes the chain list and active
brand to the SPA at runtime, so a deploy can rebrand by remounting
chains.yaml + assets — no image rebuild.

Dockerfile clones luxfi/explore + luxfi/indexer + luxfi/graph as
siblings inside the build, populates ./static from the SPA build, and
produces one binary. CI checks out the three repos as siblings so the
local replace in go.mod works without tagged releases.
2026-05-05 21:03:52 -07:00

210 lines
6.1 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())
})
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 ""
}