feat: init sproutclaw-web — Go+Gin backend + React frontend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 20:16:09 +08:00
commit c33b143176
109 changed files with 18773 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
package static
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
// Register attaches the static file handler to the Gin router.
// distDir is the path to frontend/dist/.
func Register(r *gin.Engine, distDir string) {
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
// Skip API routes they're handled elsewhere
if strings.HasPrefix(path, "/api/") {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
// Try to serve a real file
fsPath := filepath.Join(distDir, filepath.FromSlash(path))
if info, err := os.Stat(fsPath); err == nil && !info.IsDir() {
serveFile(c, fsPath)
return
}
// SPA fallback: serve index.html
indexPath := filepath.Join(distDir, "index.html")
if _, err := os.Stat(indexPath); err != nil {
c.String(http.StatusNotFound, "frontend not built run npm run build:web in frontend/")
return
}
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.File(indexPath)
})
}
func serveFile(c *gin.Context, fsPath string) {
name := filepath.Base(fsPath)
ext := strings.ToLower(filepath.Ext(name))
if name == "index.html" || strings.HasSuffix(name, ".webmanifest") {
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
} else if isHashedAsset(name, ext) {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
} else if ext == ".woff" || ext == ".woff2" || ext == ".ttf" || ext == ".otf" {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
} else {
c.Header("Cache-Control", "public, max-age=3600")
}
// Gin's File() handles Content-Type and ETag automatically
c.File(fsPath)
}
// isHashedAsset returns true if the filename looks like a Vite hashed asset
// (e.g. index-BxYt3k2a.js, chunk-abc123.css).
func isHashedAsset(name, ext string) bool {
switch ext {
case ".js", ".css", ".mjs":
return strings.Contains(name, "-") || strings.Contains(name, ".")
}
return false
}