package api
import (
"context"
_ "embed"
"html"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/julienschmidt/httprouter"
"stream.place/streamplace/pkg/log"
)
//go:embed dev-frontend-proxy.html
var devFrontendProxyHTML string
// devFrontendProxyStatusPath is handled locally by the node and never proxied,
// so the loading page has something to poll while the dev server is down.
const devFrontendProxyStatusPath = "/__dev_frontend_proxy/status"
// upstreamPortUp reports whether the dev server's TCP port accepts connections.
// That is exactly the condition whose failure makes httputil.ReverseProxy answer
// with a bare 502, so it is what the loading page polls for.
func upstreamPortUp(u *url.URL) bool {
port := u.Port()
if port == "" {
if u.Scheme == "https" {
port = "443"
} else {
port = "80"
}
}
conn, err := net.DialTimeout("tcp", net.JoinHostPort(u.Hostname(), port), 500*time.Millisecond)
if err != nil {
return false
}
_ = conn.Close()
return true
}
// HandleDevFrontendProxyStatus answers 200 once the proxied dev server is
// accepting connections and 503 until then.
func (a *StreamplaceAPI) HandleDevFrontendProxyStatus(ctx context.Context, u *url.URL) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
w.Header().Set("Cache-Control", "no-store")
if upstreamPortUp(u) {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusServiceUnavailable)
}
}
// renderDevFrontendProxyPage bakes the proxy destination into the loading page
// once at startup.
func renderDevFrontendProxyPage(upstream string) []byte {
return []byte(strings.Replace(devFrontendProxyHTML, "UPSTREAM_REPLACE_ME", html.EscapeString(upstream), 1))
}
// isDocumentNavigation reports whether a request is a browser page load that
// should get the loading page. Assets, fetches, and XRPC calls keep a plain
// error so tooling fails loudly instead of trying to parse HTML.
func isDocumentNavigation(r *http.Request) bool {
return r.Method == http.MethodGet && strings.Contains(r.Header.Get("Accept"), "text/html")
}
// devFrontendProxyErrorHandler replaces the reverse proxy's default 502 with a
// page that polls until the dev server is up and then reloads. Without it, a
// slow `pnpm app start` leaves the browser on a flat error page until the
// developer manually retries.
func devFrontendProxyErrorHandler(ctx context.Context, page []byte) func(http.ResponseWriter, *http.Request, error) {
return func(w http.ResponseWriter, r *http.Request, err error) {
if !isDocumentNavigation(r) {
http.Error(w, "frontend dev server unavailable", http.StatusServiceUnavailable)
return
}
log.Debug(ctx, "frontend dev server unreachable, serving loading page", "path", r.URL.Path, "error", err)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusServiceUnavailable)
if _, werr := w.Write(page); werr != nil {
log.Error(ctx, "error writing dev frontend proxy loading page", "error", werr)
}
}
}