1package ui
2
3import (
4 "embed"
5 "encoding/json"
6 "fmt"
7 "io/fs"
8 "net/http"
9 "os"
10 "path/filepath"
11 "time"
12)
13
14// Dist contains the contents of the built UI under dist/.
15//
16//go:embed dist/*
17var Dist embed.FS
18
19var assets http.FileSystem
20
21func init() {
22 sub, err := fs.Sub(Dist, "dist")
23 if err != nil {
24 // If the build is misconfigured and dist/ is missing, fail fast.
25 panic(err)
26 }
27 assets = http.FS(sub)
28
29 // Check if UI sources are stale compared to the embedded build
30 checkStaleness()
31}
32
33// checkStaleness verifies that the embedded UI build is not stale.
34// If ui/src exists and has files modified after the build, we exit with an error.
35func checkStaleness() {
36 // Read build-info.json from embedded filesystem
37 buildInfoData, err := fs.ReadFile(Dist, "dist/build-info.json")
38 if err != nil {
39 // If build-info.json doesn't exist, the build is old or incomplete.
40 fmt.Fprintf(os.Stderr, "\nError: UI build is stale!\n")
41 fmt.Fprintf(os.Stderr, "\nPlease run 'make serve' instead of 'go run ./cmd/shelley serve'\n")
42 fmt.Fprintf(os.Stderr, "Or rebuild the UI first: cd ui && pnpm run build\n\n")
43 os.Exit(1)
44 return
45 }
46
47 var buildInfo struct {
48 Timestamp int64 `json:"timestamp"`
49 Date string `json:"date"`
50 SrcDir string `json:"srcDir"`
51 }
52 if err := json.Unmarshal(buildInfoData, &buildInfo); err != nil {
53 fmt.Fprintf(os.Stderr, "Warning: failed to parse build-info.json: %v\n", err)
54 return
55 }
56
57 buildTime := time.UnixMilli(buildInfo.Timestamp)
58
59 // Check if source directory exists (we might be in a deployed binary without source)
60 srcDir := buildInfo.SrcDir
61 if srcDir == "" {
62 // Build info doesn't have srcDir, can't check staleness
63 return
64 }
65 if _, err := os.Stat(srcDir); os.IsNotExist(err) {
66 // Source directory doesn't exist, assume we're in production/deployed
67 return
68 }
69
70 // Walk through ui/src and check if any files are newer than the build
71 var newerFiles []string
72 err = filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
73 if err != nil {
74 return err
75 }
76 if !info.IsDir() && info.ModTime().After(buildTime) {
77 newerFiles = append(newerFiles, path)
78 }
79 return nil
80 })
81 if err != nil {
82 fmt.Fprintf(os.Stderr, "Warning: failed to check source file timestamps: %v\n", err)
83 return
84 }
85
86 if len(newerFiles) > 0 {
87 fmt.Fprintf(os.Stderr, "\nError: UI build is stale!\n")
88 fmt.Fprintf(os.Stderr, "Build timestamp: %s\n", buildInfo.Date)
89 fmt.Fprintf(os.Stderr, "\nThe following source files are newer than the build:\n")
90 for _, f := range newerFiles {
91 fmt.Fprintf(os.Stderr, " - %s\n", f)
92 }
93 fmt.Fprintf(os.Stderr, "\nPlease run 'make serve' instead of 'go run ./cmd/shelley serve'\n")
94 fmt.Fprintf(os.Stderr, "Or rebuild the UI first: cd ui && pnpm run build\n\n")
95 os.Exit(1)
96 }
97}
98
99// Assets returns an http.FileSystem backed by the embedded UI assets.
100func Assets() http.FileSystem {
101 return assets
102}
103
104// Checksums returns the content checksums for static assets.
105// These are computed during build and used for ETag generation.
106func Checksums() map[string]string {
107 data, err := fs.ReadFile(Dist, "dist/checksums.json")
108 if err != nil {
109 return nil
110 }
111 var checksums map[string]string
112 if err := json.Unmarshal(data, &checksums); err != nil {
113 return nil
114 }
115 return checksums
116}