mirror of
https://github.com/luxfi/explorer.git
synced 2026-09-24 00:25:12 +00:00
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.
256 lines
7.4 KiB
Go
256 lines
7.4 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// staticFS is the embedded SPA. The Dockerfile populates static/ from a
|
|
// luxfi/explore build before the Go build runs; for local dev a placeholder
|
|
// index.html is committed so go:embed always succeeds.
|
|
//
|
|
//go:embed all:static
|
|
var staticFS embed.FS
|
|
|
|
// Frontend serves the embedded SPA, runtime config (/envs.js), and per-host
|
|
// brand assets (/icon.svg, /logo.svg). Brand assets read from disk on every
|
|
// request so a deploy can swap them without rebuilding the binary.
|
|
type Frontend struct {
|
|
cfg Config
|
|
registry *ChainRegistry
|
|
root fs.FS
|
|
index []byte
|
|
}
|
|
|
|
// NewFrontend returns a frontend handler bound to a config and chain registry.
|
|
func NewFrontend(cfg Config, r *ChainRegistry) (*Frontend, error) {
|
|
root, err := fs.Sub(staticFS, "static")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
idx, err := fs.ReadFile(root, "index.html")
|
|
if err != nil {
|
|
idx = []byte("<!doctype html><title>Explorer</title>")
|
|
}
|
|
return &Frontend{cfg: cfg, registry: r, root: root, index: idx}, nil
|
|
}
|
|
|
|
// Mount installs / (SPA), /envs.js, /icon.svg, /logo.svg on mux.
|
|
func (f *Frontend) Mount(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /envs.js", f.handleEnvs)
|
|
mux.HandleFunc("GET /icon.svg", f.handleIcon)
|
|
mux.HandleFunc("GET /logo.svg", f.handleLogo)
|
|
mux.HandleFunc("/", f.handleSPA)
|
|
}
|
|
|
|
// handleSPA serves embedded static assets and falls back to index.html for
|
|
// any path the SPA owns (client-side routing).
|
|
//
|
|
// HEAD-method note: the bundled SPA opens EventSource (Server-Sent Events)
|
|
// channels for live block / token / tx streams and pre-flights each URL
|
|
// with a HEAD request:
|
|
//
|
|
// fetch(url, {method: 'HEAD'}).then(r => r.ok ? openEventSource() : disable());
|
|
//
|
|
// If we serve a 200 (because of the SPA fallback to index.html), the SPA
|
|
// proceeds to open the EventSource — which then immediately aborts because
|
|
// the response body is text/html, not text/event-stream. Browser logs
|
|
// "EventSource's response has a MIME type ('text/html') that is not
|
|
// 'text/event-stream'" for every channel on every page load — noisy and
|
|
// disables the channel anyway.
|
|
//
|
|
// Fix: return 404 for HEAD requests on the SPA-fallback path. Real assets
|
|
// (anything in `f.root`) still serve normally on HEAD; only the index.html
|
|
// fallback gets the 404 so the SSE pre-flight cleanly fails and the SPA
|
|
// disables that channel on its own. Visible browser load is unchanged
|
|
// because the SPA only HEAD-probes its SSE endpoints, never the SPA URL
|
|
// itself.
|
|
func (f *Frontend) handleSPA(w http.ResponseWriter, r *http.Request) {
|
|
p := strings.TrimPrefix(r.URL.Path, "/")
|
|
if p == "" {
|
|
if r.Method == http.MethodHead {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
f.writeIndex(w)
|
|
return
|
|
}
|
|
file, err := f.root.Open(p)
|
|
if err != nil {
|
|
if r.Method == http.MethodHead {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
f.writeIndex(w)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
stat, err := file.Stat()
|
|
if err != nil || stat.IsDir() {
|
|
if r.Method == http.MethodHead {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
f.writeIndex(w)
|
|
return
|
|
}
|
|
http.ServeFileFS(w, r, f.root, p)
|
|
}
|
|
|
|
func (f *Frontend) writeIndex(w http.ResponseWriter) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Write(f.index)
|
|
}
|
|
|
|
// handleEnvs returns runtime config the SPA reads instead of build-time env
|
|
// vars. This is what makes per-network customization possible without a
|
|
// rebuild: deploy the same image, mount a different chains.yaml, get a
|
|
// different brand + chain list.
|
|
func (f *Frontend) handleEnvs(w http.ResponseWriter, r *http.Request) {
|
|
hostBrand := f.brandForHost(r.Host)
|
|
chains := f.chainListJSON()
|
|
|
|
env := map[string]any{
|
|
"VITE_CHAINS": chains,
|
|
"VITE_NETWORKS": f.cfg.Networks,
|
|
"VITE_BRAND": hostBrand,
|
|
}
|
|
body, _ := json.Marshal(env)
|
|
|
|
var buf bytes.Buffer
|
|
buf.WriteString("window.ENV = ")
|
|
buf.Write(body)
|
|
buf.WriteString(";\n")
|
|
|
|
w.Header().Set("Content-Type", "application/javascript")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Write(buf.Bytes())
|
|
}
|
|
|
|
// handleIcon serves the per-host or default icon.svg from disk.
|
|
func (f *Frontend) handleIcon(w http.ResponseWriter, r *http.Request) {
|
|
f.serveBrandFile(w, r, func(b Brand) string { return b.IconFile }, "icon.svg")
|
|
}
|
|
|
|
// handleLogo serves the per-host or default logo.svg from disk.
|
|
func (f *Frontend) handleLogo(w http.ResponseWriter, r *http.Request) {
|
|
f.serveBrandFile(w, r, func(b Brand) string { return b.LogoFile }, "logo.svg")
|
|
}
|
|
|
|
// serveBrandFile resolves the file path from the matched brand (per-chain or
|
|
// default) and sends it. Falls back to the embedded SPA's named asset if no
|
|
// disk override is configured.
|
|
func (f *Frontend) serveBrandFile(w http.ResponseWriter, r *http.Request, pick func(Brand) string, fallback string) {
|
|
brand := f.brandStruct(r.Host)
|
|
if path := pick(brand); path != "" {
|
|
if data, err := os.ReadFile(path); err == nil {
|
|
w.Header().Set("Content-Type", contentTypeFor(path))
|
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
|
w.Write(data)
|
|
return
|
|
}
|
|
}
|
|
if data, err := fs.ReadFile(f.root, fallback); err == nil {
|
|
w.Header().Set("Content-Type", contentTypeFor(fallback))
|
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
|
w.Write(data)
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}
|
|
|
|
// brandForHost returns a JSON-shaped brand for /envs.js. It strips file paths.
|
|
func (f *Frontend) brandForHost(host string) map[string]any {
|
|
b := f.brandStruct(host)
|
|
return map[string]any{
|
|
"name": b.Name,
|
|
"coin": b.Coin,
|
|
"accentColor": b.AccentColor,
|
|
"iconUrl": b.IconURL,
|
|
"logoUrl": b.LogoURL,
|
|
}
|
|
}
|
|
|
|
// brandStruct returns the Brand for a request host: a chain whose name or
|
|
// slug matches the hostname wins; otherwise the global default.
|
|
func (f *Frontend) brandStruct(host string) Brand {
|
|
host = strings.ToLower(strings.SplitN(host, ":", 2)[0])
|
|
for _, c := range f.cfg.Chains {
|
|
if c.Brand == nil {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(host, c.Slug+".") || host == c.Slug {
|
|
return *c.Brand
|
|
}
|
|
}
|
|
return f.cfg.BrandDefault
|
|
}
|
|
|
|
// chainListJSON returns a SPA-friendly chain list from the live registry,
|
|
// not the static config — chains added via mDNS/admin show up immediately.
|
|
var chainListMu sync.Mutex
|
|
|
|
func (f *Frontend) chainListJSON() []map[string]any {
|
|
chainListMu.Lock()
|
|
defer chainListMu.Unlock()
|
|
out := []map[string]any{}
|
|
if f.registry == nil {
|
|
return out
|
|
}
|
|
for _, c := range f.registry.List() {
|
|
out = append(out, map[string]any{
|
|
"slug": c.Slug,
|
|
"name": c.Name,
|
|
"chainId": c.ChainID,
|
|
"rpc": c.RPC,
|
|
"coin": c.CoinSymbol,
|
|
"type": c.Type,
|
|
"default": c.Default,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// contentTypeFor maps a file extension to a Content-Type, defaulting to
|
|
// application/octet-stream.
|
|
func contentTypeFor(path string) string {
|
|
switch strings.ToLower(filepath.Ext(path)) {
|
|
case ".svg":
|
|
return "image/svg+xml"
|
|
case ".png":
|
|
return "image/png"
|
|
case ".jpg", ".jpeg":
|
|
return "image/jpeg"
|
|
case ".webp":
|
|
return "image/webp"
|
|
case ".ico":
|
|
return "image/x-icon"
|
|
default:
|
|
return "application/octet-stream"
|
|
}
|
|
}
|
|
|
|
// fingerprint returns a short, stable identifier for the embedded asset
|
|
// bundle, useful for cache-busting log lines at startup.
|
|
func fingerprint() string {
|
|
root, err := fs.Sub(staticFS, "static")
|
|
if err != nil {
|
|
return "0"
|
|
}
|
|
count := 0
|
|
_ = fs.WalkDir(root, ".", func(_ string, _ fs.DirEntry, _ error) error {
|
|
count++
|
|
return nil
|
|
})
|
|
return fmt.Sprintf("assets=%d", count)
|
|
}
|