Files
zeekayandHanzo Dev e9e9fb6315 Give the node's HTTP server a home beside the VMs it serves
api/server was an earlier copy of node/server/http that had drifted behind it.
Its exported surface was a strict subset of the node's, so this replaces the
contents rather than adding a second package: a second server would have meant
two Server interfaces, two New, two routers, and no answer to which one a chain
should mount on.

What arrives with it is the part api/server never had. Mount is the estate's
only projection from a zip App to an http.Handler, and it installs Authorize
before it serves, so an app cannot reach the router without the node's
authorization decision on it. Chain builds the one address a chain answers at,
and chain_test walks this module's own source to hold every caller to it. The
ZAP-RPC listener and the /v1 prefix come across as well; api/server was still
serving /ext.

Two files stay behind because they already have homes here. The node's
traced_handler duplicates api.TraceHandler, which this package was already
calling, so server.go keeps calling it and takes luxfi/trace in place of the
node-local trace shim.

Two tests do not come across, and neither is dropped from the node. mount_test
builds a real Z-Chain from luxfi/chains, which requires luxfi/vm, so importing
it here would point the layering back at itself. probe_test needs the Ops app
on node/service/health, which api/health does not carry. Both keep running
where they are.

constants moves to v1.6.4 to agree with the node on the chain segment. The two
modules disagreed, v1.6.2 still saying bc where the node says chain, and the
address Chain builds has to be the address the node's router serves.

newMetrics now reports a duplicate registration instead of returning a
half-built set, which is what TestMetricsRegistrationFailure had been asking
for and failing to get. That test stays, next to the node's, because the two
ask different questions: one registry twice, or two registries once.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-06 12:21:19 -07:00

390 lines
11 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package server
import (
"errors"
"fmt"
"net/http"
"path"
"sync"
"github.com/go-json-experiment/json"
apitypes "github.com/luxfi/api/types"
"github.com/luxfi/math/set"
)
const HTTPHeaderRoute = apitypes.HTTPHeaderRoute
var (
errUnknownBaseURL = errors.New("unknown base url")
errUnknownEndpoint = errors.New("unknown endpoint")
errAlreadyReserved = errors.New("route is either already aliased or already maps to a handle")
)
// RootInfo contains information returned at the root endpoint
type RootInfo struct {
NodeID string `json:"nodeId,omitempty"`
NetworkID uint32 `json:"networkId,omitempty"`
Version string `json:"version,omitempty"`
Ready bool `json:"ready"`
Chains struct {
C string `json:"c"`
P string `json:"p"`
X string `json:"x"`
} `json:"chains"`
Endpoints struct {
RPC string `json:"rpc"`
Websocket string `json:"ws"`
Info string `json:"info"`
Health string `json:"health"`
} `json:"endpoints"`
}
// RootInfoProvider provides node information for the root endpoint
type RootInfoProvider interface {
GetRootInfo() RootInfo
}
type router struct {
lock sync.RWMutex
routeLock sync.Mutex
reservedRoutes set.Set[string] // Reserves routes so that there can't be alias that conflict
aliases map[string][]string // Maps a route to a set of reserved routes
// headerRoutes contains routes based on http headers
// aliasing is not currently supported
headerRoutes map[string]http.Handler
// legacy url-based routing
routes map[string]map[string]http.Handler // Maps routes to a handler
// paths maps a full url (base+endpoint) to the handler registered at
// exactly that path. Every endpoint appears here.
paths map[string]http.Handler
// mounts maps a full endpoint url (base+endpoint) to its handler. A base is
// a namespace shared by sibling endpoints; an endpoint is a mount, and it
// owns the paths beneath it. Only non-empty endpoints appear here.
mounts map[string]http.Handler
// rootInfoProvider provides node information for GET /
rootInfoProvider RootInfoProvider
}
func newRouter() *router {
return &router{
reservedRoutes: make(set.Set[string]),
aliases: make(map[string][]string),
headerRoutes: make(map[string]http.Handler),
routes: make(map[string]map[string]http.Handler),
paths: make(map[string]http.Handler),
mounts: make(map[string]http.Handler),
}
}
// serveBelowMount answers a request that matched no exact route by handing it
// to the endpoint it lives under, with the mount stripped off.
//
// A VM names the paths it serves relative to its own mount — zkvm's
// /getStatus, aivm's /providers — because it cannot know the chain id the node
// will mount it at. Mounted as a leaf, the handler was only ever reached at the
// mount path itself, read that absolute path as its own, matched none of its
// routes and answered 404. Z-Chain and A-Chain ran, finalized blocks and
// reported metrics while serving nothing.
//
// Exact routes are matched first by construction, so an endpoint can never
// shadow a sibling.
func (r *router) serveBelowMount(writer http.ResponseWriter, request *http.Request) {
path := request.URL.Path
for i := len(path) - 1; i > 0; i-- {
if path[i] != '/' {
continue
}
mount := path[:i]
r.routeLock.Lock()
handler, ok := r.mounts[mount]
r.routeLock.Unlock()
if ok {
http.StripPrefix(mount, handler).ServeHTTP(writer, request)
return
}
}
http.NotFound(writer, request)
}
// servePath dispatches on the URL: the canonical spelling of the path, then
// the route registered at exactly that path, then the endpoint it lives under.
//
// A doubled separator or a dot segment names a place the client could have
// named plainly, so it is sent to the plain name rather than served at two
// addresses. A trailing slash is not noise and is kept — /rpc is the mount
// and /rpc/ is the root beneath it, which are two different requests.
func (r *router) servePath(writer http.ResponseWriter, request *http.Request) {
if clean := canonical(request.URL.Path); clean != request.URL.Path {
here := *request.URL
here.Path = clean
writer.Header().Set("Location", here.String())
writer.WriteHeader(http.StatusMovedPermanently)
return
}
r.routeLock.Lock()
handler, ok := r.paths[request.URL.Path]
r.routeLock.Unlock()
if ok {
handler.ServeHTTP(writer, request)
return
}
r.serveBelowMount(writer, request)
}
// canonical is path.Clean with the trailing slash left on.
func canonical(p string) string {
if p == "" {
return "/"
}
if p[0] != '/' {
p = "/" + p
}
clean := path.Clean(p)
if clean != "/" && p[len(p)-1] == '/' {
clean += "/"
}
return clean
}
func (r *router) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
r.lock.RLock()
defer r.lock.RUnlock()
// Handle root "/" specially for EVM compatibility
if request.URL.Path == "/" {
r.handleRoot(writer, request)
return
}
// /healthz — the platform-standard liveness address, and an alias for
// /v1/health/ops/liveness rather than an answer of its own. A probe that
// computes its own verdict is a probe that can disagree with the node.
if request.URL.Path == "/healthz" {
alias := request.Clone(request.Context())
alias.URL.Path = baseURL + "/health" + Ops + "/liveness"
r.servePath(writer, alias)
return
}
route, ok := request.Header[HTTPHeaderRoute]
if !ok {
// If there is no routing header, fall-back to the legacy path-based
// routing
r.servePath(writer, request)
return
}
// Request specified the routing header key but did not provide a
// corresponding value
if len(route) != 1 {
writer.WriteHeader(http.StatusBadRequest)
return
}
handler, ok := r.headerRoutes[route[0]]
if !ok {
writer.WriteHeader(http.StatusNotFound)
return
}
handler.ServeHTTP(writer, request)
}
// handleRoot handles requests to "/" - GET returns node info, POST proxies to C-chain RPC
func (r *router) handleRoot(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
r.handleRootGET(w, req)
case http.MethodPost:
r.handleRootPOST(w, req)
case http.MethodOptions:
// CORS preflight
w.Header().Set("Allow", "GET, POST, OPTIONS")
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed. Use GET for node info or POST for EVM JSON-RPC.", http.StatusMethodNotAllowed)
}
}
// handleRootGET returns node information as JSON
func (r *router) handleRootGET(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
var info RootInfo
if r.rootInfoProvider != nil {
info = r.rootInfoProvider.GetRootInfo()
} else {
// Default info when provider not set
info = RootInfo{
Ready: true,
Chains: struct {
C string `json:"c"`
P string `json:"p"`
X string `json:"x"`
}{
C: Chain("", "C") + "/rpc",
P: Chain("", "P"),
X: Chain("", "X"),
},
Endpoints: struct {
RPC string `json:"rpc"`
Websocket string `json:"ws"`
Info string `json:"info"`
Health string `json:"health"`
}{
RPC: Chain("", "C") + "/rpc",
Websocket: Chain("", "C") + "/ws",
Info: baseURL + "/info",
Health: baseURL + "/health",
},
}
}
if err := json.MarshalWrite(w, info); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// handleRootPOST proxies JSON-RPC requests to the C-chain
func (r *router) handleRootPOST(w http.ResponseWriter, req *http.Request) {
// Look up the C-chain RPC handler
handler, err := r.GetHandler(Chain("", "C"), "/rpc")
if err != nil {
// Try alternate path formats
handler, err = r.GetHandler(Chain("", "C")+"/rpc", "")
if err != nil {
// Return proper JSON-RPC error
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"C-chain not available"}}`))
return
}
}
// Forward the request to the C-chain handler
handler.ServeHTTP(w, req)
}
// SetRootInfoProvider sets the provider for root endpoint information
func (r *router) SetRootInfoProvider(provider RootInfoProvider) {
r.lock.Lock()
defer r.lock.Unlock()
r.rootInfoProvider = provider
}
// GetHandler is the handler registered at one base and endpoint.
func (r *router) GetHandler(base, endpoint string) (http.Handler, error) {
r.routeLock.Lock()
defer r.routeLock.Unlock()
urlBase, exists := r.routes[base]
if !exists {
return nil, errUnknownBaseURL
}
handler, exists := urlBase[endpoint]
if !exists {
return nil, errUnknownEndpoint
}
return handler, nil
}
func (r *router) AddHeaderRoute(route string, handler http.Handler) bool {
r.lock.Lock()
defer r.lock.Unlock()
_, ok := r.headerRoutes[route]
if ok {
return false
}
r.headerRoutes[route] = handler
return true
}
func (r *router) AddRouter(base, endpoint string, handler http.Handler) error {
r.lock.Lock()
defer r.lock.Unlock()
r.routeLock.Lock()
defer r.routeLock.Unlock()
return r.addRouter(base, endpoint, handler)
}
func (r *router) addRouter(base, endpoint string, handler http.Handler) error {
if r.reservedRoutes.Contains(base) {
return fmt.Errorf("%w: %s", errAlreadyReserved, base)
}
return r.forceAddRouter(base, endpoint, handler)
}
func (r *router) forceAddRouter(base, endpoint string, handler http.Handler) error {
endpoints := r.routes[base]
if endpoints == nil {
endpoints = make(map[string]http.Handler)
}
url := base + endpoint
if _, exists := endpoints[endpoint]; exists {
return fmt.Errorf("failed to create endpoint as %s already exists", url)
}
endpoints[endpoint] = handler
r.routes[base] = endpoints
if endpoint != "" {
r.mounts[url] = handler
}
// A base and an endpoint can concatenate to a url another pair already
// holds; the pair that got there first keeps it.
if _, taken := r.paths[url]; !taken {
r.paths[url] = handler
}
var err error
if aliases, exists := r.aliases[base]; exists {
for _, alias := range aliases {
if innerErr := r.forceAddRouter(alias, endpoint, handler); err == nil {
err = innerErr
}
}
}
return err
}
func (r *router) AddAlias(base string, aliases ...string) error {
r.lock.Lock()
defer r.lock.Unlock()
r.routeLock.Lock()
defer r.routeLock.Unlock()
for _, alias := range aliases {
if r.reservedRoutes.Contains(alias) {
return fmt.Errorf("%w: %s", errAlreadyReserved, alias)
}
}
for _, alias := range aliases {
r.reservedRoutes.Add(alias)
}
r.aliases[base] = append(r.aliases[base], aliases...)
var err error
if endpoints, exists := r.routes[base]; exists {
for endpoint, handler := range endpoints {
for _, alias := range aliases {
if innerErr := r.forceAddRouter(alias, endpoint, handler); err == nil {
err = innerErr
}
}
}
}
return err
}