The bundled SPA opens a single EventSource at /v1/base/realtime and
routes incoming frames via msg.event off a handler map — see
realtime_sse.go for the wire shape. Per-channel /blocks /transactions
etc. stay for external consumers; the SPA only uses this one path.
client.es.onmessage = o => {
const c = JSON.parse(o.data);
const d = this.handlers.get(c.event);
if (d) for (const m of d) m(c.data);
};
Wire:
- HandleMultiplexedSSE() attaches a wildcard sseClient (channel=`*`)
- Broadcast() now builds two SSE payloads per event:
perChannel: existing `event:<type>\ndata:<msg>\n\n` framing for
/blocks /transactions /etc.
wildcard: `data:{"event":<type>,"chain":<slug>,"data":<...>}\n\n`
for /v1/base/realtime — no `event:` line so SPA's
onmessage gets the JSON envelope and dispatches.
- sseRegistry.fanout dispatches both payloads to matching clients
based on channel ("*" → wildcard, "blocks" → perChannel)
- main.go mounts GET + HEAD /v1/base/realtime (HEAD is the SPA's
pre-flight to decide whether to disable the live channel).
Without this path the SPA's HEAD pre-flight got a 404 (the explorer
SSE-HEAD-silencer from #4) and the client set `disabled=true`, so
no live updates ever reached the home page.
When ChainSupervisor.AttachRealtime is called (from main), each chain's
indexer.Subscriber.OnBroadcast is wired to push block events into the
RealtimeHub on the chain's slug. The hub then fans out to WebSocket
clients (existing path) and SSE subscribers (new in #5).
Requires luxfi/indexer v1.4.4 — pulls in the exported Subscriber()
accessor.
End-to-end chain:
evm.Indexer.indexBlock → Subscriber.BroadcastBlock → OnBroadcast →
RealtimeHub.Broadcast → ws.send + SSE fanout → SPA EventSource
Now /blocks, /transactions, /token-transfers, etc. on the explorer
host actually stream live events as blocks land instead of just
returning HEAD 200 with no payload.
* 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).
---------
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.