Files
zeekayandHanzo Dev aa87442fdd Record upstream authorship centrally in NOTICE instead of in every file header
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the
Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in
NOTICE, alongside LICENSE. The header lint accepts any copyright line above the
SPDX line, so it no longer demands an upstream one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 17:50:35 -07:00

188 lines
6.1 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package public
import (
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
"github.com/hanzoai/git/modules/httplib"
"github.com/hanzoai/git/modules/log"
"github.com/hanzoai/git/modules/setting"
"github.com/hanzoai/git/modules/util"
"github.com/hanzoai/git/modules/web/routing"
)
const viteDevPortFile = "public/assets/.vite/dev-port"
var viteDevProxy atomic.Pointer[httputil.ReverseProxy]
func getViteDevServerBaseURL() string {
portFile := filepath.Join(setting.StaticRootPath, viteDevPortFile)
portContent, _ := os.ReadFile(portFile)
port := strings.TrimSpace(string(portContent))
if port == "" {
return ""
}
return "http://localhost:" + port
}
func getViteDevProxy() *httputil.ReverseProxy {
if proxy := viteDevProxy.Load(); proxy != nil {
return proxy
}
viteDevServerBaseURL := getViteDevServerBaseURL()
if viteDevServerBaseURL == "" {
return nil
}
target, err := url.Parse(viteDevServerBaseURL)
if err != nil {
log.Error("Failed to parse Vite dev server base URL %s, err: %v", viteDevServerBaseURL, err)
return nil
}
// there is a strange error log (from Golang's HTTP package)
// 2026/03/28 19:50:13 modules/log/misc.go:72:(*loggerToWriter).Write() [I] Unsolicited response received on idle HTTP channel starting with "HTTP/1.1 400 Bad Request\r\n\r\n"; err=<nil>
// maybe it is caused by that the Vite dev server doesn't support keep-alive connections? or different keep-alive timeouts?
transport := &http.Transport{
IdleConnTimeout: 5 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
}
log.Info("Proxying Vite dev server requests to %s", target)
proxy := &httputil.ReverseProxy{
Transport: transport,
Rewrite: func(r *httputil.ProxyRequest) {
r.SetURL(target)
r.Out.Host = target.Host
},
ModifyResponse: func(resp *http.Response) error {
// add a header to indicate the Vite dev server port,
// make developers know that this request is proxied to Vite dev server and which port it is
resp.Header.Add("X-Vite-Dev-Server", viteDevServerBaseURL)
return nil
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
if r.Context().Err() != nil {
return // request cancelled (e.g. client disconnected), silently ignore
}
log.Error("Error proxying to Vite dev server: %v", err)
http.Error(w, "Error proxying to Vite dev server: "+err.Error(), http.StatusBadGateway)
},
}
viteDevProxy.Store(proxy)
return proxy
}
// ViteDevMiddleware proxies matching requests to the Vite dev server.
// It is registered as middleware in non-production mode and lazily discovers
// the Vite dev server port from the port file written by the viteDevServerPortPlugin.
// It is needed because there are container-based development, only the forge web server's port is exposed.
func ViteDevMiddleware(next http.Handler) http.Handler {
markLongPolling := routing.MarkLongPolling()
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
if !isViteDevRequest(req) {
next.ServeHTTP(resp, req)
return
}
proxy := getViteDevProxy()
if proxy == nil {
next.ServeHTTP(resp, req)
return
}
markLongPolling(proxy).ServeHTTP(resp, req)
})
}
var viteDevModeCheck atomic.Pointer[struct {
isDev bool
time time.Time
}]
// IsViteDevMode returns true if the Vite dev server port file exists and the server is alive
func IsViteDevMode() bool {
if setting.IsProd {
return false
}
now := time.Now()
lastCheck := viteDevModeCheck.Load()
if lastCheck != nil && time.Now().Sub(lastCheck.time) < time.Second {
return lastCheck.isDev
}
viteDevServerBaseURL := getViteDevServerBaseURL()
if viteDevServerBaseURL == "" {
return false
}
req := httplib.NewRequest(viteDevServerBaseURL+"/web_src/js/__vite_dev_server_check", "GET")
resp, _ := req.Response()
if resp != nil {
_ = resp.Body.Close()
}
isDev := resp != nil && resp.StatusCode == http.StatusOK
viteDevModeCheck.Store(&struct {
isDev bool
time time.Time
}{
isDev: isDev,
time: now,
})
return isDev
}
// viteDevSourceURL returns the dev server URL for a source file, or "" if it doesn't exist.
func viteDevSourceURL(srcPath string) string {
localPath := util.FilePathJoinAbs(setting.StaticRootPath, srcPath)
if _, err := os.Stat(localPath); err != nil {
return ""
}
return setting.AppSubURL + "/" + srcPath
}
// isViteDevRequest returns true if the request should be proxied to the Vite dev server.
// Ref: Vite source packages/vite/src/node/constants.ts and packages/vite/src/shared/constants.ts
func isViteDevRequest(req *http.Request) bool {
if req.Header.Get("Upgrade") == "websocket" {
wsProtocol := req.Header.Get("Sec-WebSocket-Protocol")
return wsProtocol == "vite-hmr" || wsProtocol == "vite-ping"
}
path := req.URL.Path
// vite internal requests
if strings.HasPrefix(path, "/@vite/") /* HMR client */ ||
strings.HasPrefix(path, "/@fs/") /* out-of-root file access, see vite.config.ts: fs.allow */ ||
strings.HasPrefix(path, "/@id/") /* virtual modules */ {
return true
}
// local source requests (VITE-DEV-SERVER-SECURITY: don't serve sensitive files outside the allowed paths)
if strings.HasPrefix(path, "/node_modules/") ||
strings.HasPrefix(path, "/public/assets/") ||
strings.HasPrefix(path, "/web_src/") {
return true
}
// Vite uses a path relative to project root and adds "?import" to non-JS/CSS asset imports:
// - {WebSite}/public/assets/... (e.g. SVG icons from "{RepoRoot}/public/assets/img/svg/")
// - {WebSite}/assets/<file>.json: exception for frontend-imported repo-root assets:
// - KEEP IN MIND: all static frontend assets are served from "{AssetFS}/assets" to "{WebSite}/assets" by Forge web server
// - "{AssetFS}" is a layered filesystem from "{RepoRoot}/public" or embedded assets, and user's custom files in "{CustomPath}/public"
// - "{RepoRoot}/assets/*.json" just happens to live under the dir name "assets"; it is not related to frontend assets
// - BAD DESIGN: indeed it is a "conflicted and polluted name" sample
switch path {
case "/assets/emoji.json", "/assets/codemirror-languages.json":
return true
}
return false
}