Adds pkg/server/quickproxy: longest-prefix rule matching over net/http/httputil.ReverseProxy, falling back to a caller-supplied handler when the upstream is unreachable or returns 404. Any other upstream response streams through unchanged. All HTTP methods are proxied, with a configurable global dial/response-header timeout (quickproxy.WithTimeout, default 10s). GoCore-side wiring (config field, webserver2/proxy.go, server.go route ordering) is tracked separately in that repo.
8.6 KiB
Plan: Proxy endpoint support for pkg/webserver2 static serving
Status: quickproxy package implemented in ResolveSpec. GoCore-side wiring
(config field, webserver2/proxy.go, server.go route ordering) is a
separate repo, not present here, and remains unimplemented.
Goal
pkg/webserver2 (via WithStaticFS) currently serves static files from
WebResDir (/res) and WebStaticDir (/), using ResolveSpec's
pkg/server/staticweb package, with an HTML fallback for SPA routing.
We're adding a proxy layer: requests are tried against a configured proxy target first; if the proxy can't resolve them (unreachable, or upstream returns 404), they fall back to the existing static file handling.
Decisions (from Q&A with user)
- Config shape: a list of prefix rules (like nginx
locationblocks), where one rule may use prefix/to act as a catch-all passthrough. So a single config list covers both "proxy just/api" and "proxy everything not otherwise matched" use cases. - Package placement: new package in the ResolveSpec repo
(
/mnt/vault/ResolveSpec, published asgithub.com/bitechdev/ResolveSpec), namedquickproxy, living alongsidepkg/server/staticwebatpkg/server/quickproxy. GoCore'spkg/webserver2will consume it the same way it consumesstaticwebtoday (wire it up instatic.go), after bumping the ResolveSpec dependency version — mirroring the existingfix(resolve-spec): update ResolveSpec to vX.Y.Zcommit pattern already used in this repo's history. - Fallback trigger: fall back to static files both when the upstream is
unreachable (dial/connect error, timeout) and when the upstream
responds with
404. Any other upstream response (2xx, other 4xx, 5xx) is passed through to the client as-is. - Method scope: proxying applies to all HTTP methods, not just GET (unlike today's static routes, which are GET-only).
New package: github.com/bitechdev/ResolveSpec/pkg/server/quickproxy
Repo location: /mnt/vault/ResolveSpec/pkg/server/quickproxy
Types
package quickproxy
// Rule maps a URL prefix to an upstream target.
// A Rule with URLPrefix "/" acts as a catch-all passthrough.
type Rule struct {
URLPrefix string // e.g. "/api", "/"
Target string // upstream base URL, e.g. "http://localhost:3000"
}
// Service holds the compiled set of rules and does longest-prefix matching.
type Service struct { ... }
func NewService(rules []Rule) (*Service, error)
Matching & fallback behavior
- Longest-prefix match against configured rules, same convention as
staticweb's mount points. - Implemented with
net/http/httputil.ReverseProxyper matched rule (director rewrites scheme/host/path, addsX-Forwarded-Host/X-Forwarded-For). - 404 detection without buffering the body: use
ReverseProxy.ModifyResponseto inspectresp.StatusCodeas soon as headers arrive from upstream, before the body is streamed to the client. If it's 404, return a sentinel error fromModifyResponse— this causesReverseProxyto invokeErrorHandlerinstead of writing anything to the client, so we never commit a partial/wrong response. - Connection failure detection:
ReverseProxy.ErrorHandlercatches both the sentinel 404 error and real transport errors (dial failure, timeout, connection reset). - In both cases,
ErrorHandlerinvokes a caller-supplied fallbackhttp.Handler(the existing static file service) instead of writing an error response. Because nothing has been written to theResponseWriteryet in either failure path, the fallback handler can write a normal response (status, headers, body) as if the proxy had never been tried. - Any other status code (including other 4xx/5xx) streams straight through to the client — no masking of real upstream errors.
Handler API
// Handler returns an http.Handler that tries the configured proxy rules
// first (longest-prefix match), and calls fallback when no rule matches,
// the upstream is unreachable, or the upstream returns 404.
func (s *Service) Handler(fallback http.Handler) http.Handler
This mirrors the shape of staticweb.StaticFileService.Handler() so it
composes the same way in pkg/webserver2.
Config changes (GoCore)
pkg/cfg/settings-model.go — add a new field to Settings, following the
existing flat-JSON convention used by WebStaticDir/WebResDir:
type ProxyRule struct {
URLPrefix string `json:"urlprefix"`
Target string `json:"target"`
}
// in Settings:
WebProxyRules []ProxyRule `json:"webproxyrules"`
Wiring changes (GoCore, pkg/webserver2)
New file pkg/webserver2/proxy.go (parallel to static.go), e.g.:
func (r *Router) WithProxyFS(corestate cfg.State) *Router {
if len(corestate.Cfg.WebProxyRules) == 0 {
return r
}
rules := make([]quickproxy.Rule, 0, len(corestate.Cfg.WebProxyRules))
for _, pr := range corestate.Cfg.WebProxyRules {
rules = append(rules, quickproxy.Rule{URLPrefix: pr.URLPrefix, Target: pr.Target})
}
service, err := quickproxy.NewService(rules)
if err != nil {
logger.Error("could not configure proxy service %v", err)
return r
}
r.proxyService = service // stored for static.go to consume as fallback target
return r
}
Integration point with existing static routing — needs care
This is the trickiest part of the wiring, flagged here rather than decided,
since it affects route registration order in server.go:
- For a proxy rule prefix that doesn't overlap with the static mounts
(e.g.
/api), we can register new routes directly:r.Handle(method, "/api/*path", quickproxy handler)for all methods, with no fallback (there's no static content at/apianyway). - For the catch-all
/proxy rule, it directly overlaps the existingWebStaticDirmount at/. Rather than registering a second competing route,WithStaticFS's existing GET handler for/*pathneeds to becomeproxyService.Handler(staticAuth(service.Handler()))— i.e. the proxy wraps the static handler as its fallback, instead of the two being separate router registrations. This meansWithProxyFSneeds to run beforeWithStaticFS(or the two need to be merged into one method), andserver.go's call chain (WithStaticFS(corestate)) needs adjusting accordingly. - Non-GET methods on the catch-all
/rule have no static fallback to offer (static routes are GET-only today) — they'd just proxy or 404.
Release flow
Since quickproxy lives in the separate ResolveSpec module:
- Implement + test in
/mnt/vault/ResolveSpec. - Tag/publish a new ResolveSpec version.
- Bump
github.com/bitechdev/ResolveSpecin GoCore'sgo.mod(go get github.com/bitechdev/ResolveSpec@vX.Y.Z), same as the recentfix(resolve-spec): update ResolveSpec to v1.1.46commit. - Land the GoCore-side wiring (
cfgfield,proxy.go,server.gocall order change) in the same or a follow-up commit.
Resolved decisions (quickproxy implementation)
- Timeouts: a single global default (
quickproxy.DefaultTimeout, 10s), overridable viaquickproxy.WithTimeout(d)passed toNewService. It bounds dial + response-header wait only; response body streaming is unbounded, so it won't cut off long downloads or upgraded connections. - Auth middleware: out of scope for
quickproxy. Whether/howstaticAuthwraps proxied requests is entirely GoCore's call at the wiring layer, not something this package decides. - Path rewriting: no prefix stripping — the full incoming path/query is
forwarded unchanged to the target host (matches nginx
proxy_passwithout a trailing slash). Only scheme/host are rewritten, andX-Forwarded-Hostis set from the originalHostheader.
Open questions / not yet decided
- WebSocket upgrade proxying (needed if any proxied target does live
reload / HMR, e.g. a frontend dev server) — plain
ReverseProxyhandles this automatically via itsTransport, should confirm it's not disabled. - Whether request bodies need special handling for large uploads proxied
to non-GET targets (streaming vs. buffering —
ReverseProxystreams by default, should be fine, but worth confirming with the intended proxy targets).
Testing plan
quickproxypackage: unit tests in ResolveSpec covering longest-prefix matching, 404-triggers-fallback, connection-error-triggers-fallback, pass-through of non-404 error codes, all-methods support.pkg/webserver2: integration test standing up a fake upstream (httptest.Server) plus a temp static dir, verifying the proxy-then-static-fallback order end to end.