* 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).
---------
Explorer
Single-binary Lux explorer: chain indexer, GraphQL engine, and SPA frontend
in one Go process. Imports luxfi/indexer
and luxfi/graph as libraries; embeds the
luxfi/explore Next.js build via
go:embed.
ghcr.io/luxfi/explorer
-> indexes one or more EVM chains (luxfi/indexer)
-> exposes per-chain GraphQL (luxfi/graph)
-> serves embedded SPA + REST API (luxfi/explore)
Routes
| Path | Purpose |
|---|---|
/ |
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 |
Quick Start
docker run -p 8090:8090 \
-v $(pwd)/chains.example.yaml:/etc/explorer/chains.yaml:ro \
-v explorer-data:/data \
ghcr.io/luxfi/explorer:latest \
--config /etc/explorer/chains.yaml
Or with compose:
docker compose up
Configuration
chains.yaml is the single source of truth. Every aspect of the deploy --
chains, branding, GraphQL subgraphs, network switcher links -- is per-chain
customizable. See chains.example.yaml for the full
schema.
data_dir: /data
http_addr: :8090
brand_default:
name: Lux Explorer
coin: LUX
accent_color: "#5cf"
networks:
- { label: Mainnet, domain: explorer.lux.network, chain_id: 96369 }
chains:
- slug: cchain
name: Lux C-Chain
chain_id: 96369
type: evm
rpc: https://api.lux.network/mainnet/ext/bc/C/rpc
coin: LUX
enabled: true
default: true
indexer:
poll_interval: 30s
graph:
enabled: true
subgraphs:
- { name: amm, schema: amm, enabled: true }
brand:
name: Lux C-Chain
accent_color: "#5cf"
$ENV_VAR substitution in rpc: / ws: lets one file template every
network -- mount it read-only and inject secrets via env vars.
Per-Network Customization
The same image powers every deploy. To rebrand for a different network:
- Mount a different
chains.yaml. - Optionally mount
icon.svg/logo.svgat the path referenced bychains.yaml-- they are read from disk per-request, not embedded. - Set environment variables to fill the
${VAR}placeholders.
No image rebuild needed. The SPA reads chain list and brand at runtime from
/envs.js.
Runtime Registry
Chains can be added, updated, and removed at runtime without restart:
# Add
curl -X POST http://localhost:8090/v1/explorer/admin/chains \
-H 'Content-Type: application/json' \
-d '{"slug":"new","name":"New","chain_id":123,"rpc":"http://node:8545","type":"evm"}'
# List
curl http://localhost:8090/v1/explorer/admin/chains
# Remove
curl -X DELETE http://localhost:8090/v1/explorer/admin/chains/new
Adding a chain spawns its indexer + (optional) graph workers; removing cancels them and frees the routes.
mDNS Discovery
Set EXPLORER_MDNS=true to auto-register chains advertised by local Lux
nodes (_luxd._tcp, _zood._tcp, _hanzod._tcp, _parsd._tcp). The
explorer queries info.getChains on the discovered node and registers each
tracked chain. mDNS-sourced chains never override config-sourced entries.
Storage Layout
/data/
cchain/
query/indexer.db (SQLite WAL, indexer)
{zapdb}/ (KV store)
graph/
amm/graph.db (SQLite WAL, per-subgraph)
zoo/
...
Each chain (and each subgraph) owns an isolated SQLite + KV store. WAL is
optionally streamed to S3 with PQ encryption when REPLICATE_S3_ENDPOINT
is set -- see luxfi/indexer/daemon.
Local Build
# Sibling layout: ~/work/lux/{indexer,graph,explorer}
cd ~/work/lux/explorer
go build -o explorer .
go.mod uses local replace directives pointing at ../indexer and
../graph. Production CI clones all three repos into the same layout
inside the Docker build.
Endpoints (curl examples)
curl http://localhost:8090/health
curl http://localhost:8090/v1/indexer/cchain/blocks/latest
curl -X POST http://localhost:8090/v1/graph/cchain/amm/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ swaps(first:5) { id amount0 amount1 } }"}'
Related
luxfi/indexer-- chain indexer libraryluxfi/graph-- per-chain GraphQL libraryluxfi/explore-- Next.js SPA