|
| 1 | +// Command uidev is a zero-dependency dev server for the key-manager UI. |
| 2 | +// |
| 3 | +// It serves the UI's static files from disk (so edits show up immediately, |
| 4 | +// unlike the embedded copy baked into the key-manager image), proxies /api/* |
| 5 | +// to the running key-manager (normally a kubectl port-forward on :8080), and |
| 6 | +// live-reloads the browser when any static file changes. Frontend devs edit |
| 7 | +// key-manager/internal/ui/static/* and just refresh-free reload. |
| 8 | +// |
| 9 | +// Run via `make run-dev`, or directly: |
| 10 | +// |
| 11 | +// go run . -static ../../key-manager/internal/ui/static -api http://localhost:8080 -addr :5173 |
| 12 | +package main |
| 13 | + |
| 14 | +import ( |
| 15 | + "flag" |
| 16 | + "fmt" |
| 17 | + "io/fs" |
| 18 | + "log" |
| 19 | + "net/http" |
| 20 | + "net/http/httputil" |
| 21 | + "net/url" |
| 22 | + "os" |
| 23 | + "path/filepath" |
| 24 | + "strings" |
| 25 | + "time" |
| 26 | +) |
| 27 | + |
| 28 | +func main() { |
| 29 | + staticDir := flag.String("static", "../../key-manager/internal/ui/static", "path to the UI static files") |
| 30 | + apiURL := flag.String("api", "http://localhost:8080", "key-manager API base URL to proxy /api/* to") |
| 31 | + addr := flag.String("addr", ":5173", "address to listen on") |
| 32 | + flag.Parse() |
| 33 | + |
| 34 | + absStatic, err := filepath.Abs(*staticDir) |
| 35 | + if err != nil || !dirExists(absStatic) { |
| 36 | + log.Fatalf("static dir not found: %s", *staticDir) |
| 37 | + } |
| 38 | + api, err := url.Parse(*apiURL) |
| 39 | + if err != nil { |
| 40 | + log.Fatalf("invalid -api URL %q: %v", *apiURL, err) |
| 41 | + } |
| 42 | + |
| 43 | + // Proxy /api/* to the key-manager (the dev-mode instance bypasses auth). |
| 44 | + proxy := httputil.NewSingleHostReverseProxy(api) |
| 45 | + |
| 46 | + mux := http.NewServeMux() |
| 47 | + mux.Handle("/api/", proxy) |
| 48 | + mux.HandleFunc("/__livereload", liveReloadHandler(absStatic)) |
| 49 | + mux.HandleFunc("/", staticHandler(absStatic)) |
| 50 | + |
| 51 | + fmt.Printf("UI dev server: http://localhost%s (hot reload)\n", normalizeAddr(*addr)) |
| 52 | + fmt.Printf(" serving: %s\n", absStatic) |
| 53 | + fmt.Printf(" /api/* proxied: %s\n", api) |
| 54 | + if err := http.ListenAndServe(*addr, mux); err != nil { |
| 55 | + log.Fatal(err) |
| 56 | + } |
| 57 | +} |
| 58 | + |
| 59 | +// staticHandler serves files from dir. For HTML responses it injects a small |
| 60 | +// live-reload script before </body> so edits trigger a browser reload. |
| 61 | +func staticHandler(dir string) http.HandlerFunc { |
| 62 | + return func(w http.ResponseWriter, r *http.Request) { |
| 63 | + clean := filepath.Clean(r.URL.Path) |
| 64 | + if clean == "/" || clean == "." { |
| 65 | + clean = "/index.html" |
| 66 | + } |
| 67 | + full := filepath.Join(dir, clean) |
| 68 | + // Keep the response inside the static dir. |
| 69 | + if !strings.HasPrefix(full, dir) { |
| 70 | + http.NotFound(w, r) |
| 71 | + return |
| 72 | + } |
| 73 | + if strings.HasSuffix(clean, ".html") { |
| 74 | + b, err := os.ReadFile(full) |
| 75 | + if err != nil { |
| 76 | + http.NotFound(w, r) |
| 77 | + return |
| 78 | + } |
| 79 | + body := injectLiveReload(string(b)) |
| 80 | + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 81 | + w.Header().Set("Cache-Control", "no-store") |
| 82 | + _, _ = w.Write([]byte(body)) |
| 83 | + return |
| 84 | + } |
| 85 | + w.Header().Set("Cache-Control", "no-store") |
| 86 | + http.ServeFile(w, r, full) |
| 87 | + } |
| 88 | +} |
| 89 | + |
| 90 | +const liveReloadScript = `<script> |
| 91 | +(function () { |
| 92 | + var es = new EventSource("/__livereload"); |
| 93 | + es.onmessage = function (e) { if (e.data === "reload") location.reload(); }; |
| 94 | +})(); |
| 95 | +</script>` |
| 96 | + |
| 97 | +func injectLiveReload(html string) string { |
| 98 | + if i := strings.LastIndex(html, "</body>"); i >= 0 { |
| 99 | + return html[:i] + liveReloadScript + html[i:] |
| 100 | + } |
| 101 | + return html + liveReloadScript |
| 102 | +} |
| 103 | + |
| 104 | +// liveReloadHandler streams a reload event whenever the static dir changes. |
| 105 | +// It polls a cheap fingerprint (file count + sizes + max mtime) so it needs no |
| 106 | +// third-party file-watching dependency. |
| 107 | +func liveReloadHandler(dir string) http.HandlerFunc { |
| 108 | + return func(w http.ResponseWriter, r *http.Request) { |
| 109 | + flusher, ok := w.(http.Flusher) |
| 110 | + if !ok { |
| 111 | + http.Error(w, "streaming unsupported", http.StatusInternalServerError) |
| 112 | + return |
| 113 | + } |
| 114 | + w.Header().Set("Content-Type", "text/event-stream") |
| 115 | + w.Header().Set("Cache-Control", "no-cache") |
| 116 | + w.Header().Set("Connection", "keep-alive") |
| 117 | + |
| 118 | + last := fingerprint(dir) |
| 119 | + ticker := time.NewTicker(400 * time.Millisecond) |
| 120 | + defer ticker.Stop() |
| 121 | + for { |
| 122 | + select { |
| 123 | + case <-r.Context().Done(): |
| 124 | + return |
| 125 | + case <-ticker.C: |
| 126 | + if fp := fingerprint(dir); fp != last { |
| 127 | + last = fp |
| 128 | + fmt.Fprint(w, "data: reload\n\n") |
| 129 | + flusher.Flush() |
| 130 | + } |
| 131 | + } |
| 132 | + } |
| 133 | + } |
| 134 | +} |
| 135 | + |
| 136 | +// fingerprint returns a string that changes whenever a file under dir is added, |
| 137 | +// removed, resized, or modified. |
| 138 | +func fingerprint(dir string) string { |
| 139 | + var b strings.Builder |
| 140 | + _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { |
| 141 | + if err != nil || d.IsDir() { |
| 142 | + return nil |
| 143 | + } |
| 144 | + if info, err := d.Info(); err == nil { |
| 145 | + fmt.Fprintf(&b, "%s:%d:%d;", path, info.Size(), info.ModTime().UnixNano()) |
| 146 | + } |
| 147 | + return nil |
| 148 | + }) |
| 149 | + return b.String() |
| 150 | +} |
| 151 | + |
| 152 | +func dirExists(p string) bool { |
| 153 | + info, err := os.Stat(p) |
| 154 | + return err == nil && info.IsDir() |
| 155 | +} |
| 156 | + |
| 157 | +func normalizeAddr(addr string) string { |
| 158 | + if strings.HasPrefix(addr, ":") { |
| 159 | + return addr |
| 160 | + } |
| 161 | + return ":" + addr |
| 162 | +} |
0 commit comments