diff --git a/apps/console/scripts/esbuild.mjs b/apps/console/scripts/esbuild.mjs index 70701286b..ae065649a 100644 --- a/apps/console/scripts/esbuild.mjs +++ b/apps/console/scripts/esbuild.mjs @@ -124,6 +124,52 @@ const relayPlugin = { }, }; +const htmlPlugin = { + name: 'html-plugin', + setup(build) { + build.onEnd(async (result) => { + // do not run this in watch mode + if (process.argv[2] === "watch") { + return; + } + + if (result.errors.length > 0) { + return; + } + + if (!result.metafile) { + console.error('Metafile not available'); + return; + } + + const outputs = Object.keys(result.metafile.outputs); + + const jsOutputs = outputs.filter(file => file.endsWith('.js') && file.includes('/App-')); + const cssOutputs = outputs.filter(file => file.endsWith('.css') && file.includes('/App-')); + + if (jsOutputs.length === 0 || cssOutputs.length === 0) { + console.error('Could not find entry point outputs'); + return; + } + + const jsFile = path.basename(jsOutputs[0]); + const cssFile = path.basename(cssOutputs[0]); + + console.log('Found JS file:', jsFile); + console.log('Found CSS file:', cssFile); + + const htmlPath = path.join(process.cwd(), 'dist', 'index.html'); + let html = await fs.readFile(htmlPath, "utf8"); + + html = html.replace('"/App.css"', `"/${cssFile}"`); + html = html.replace('"/App.js"', `"/${jsFile}"`); + + await fs.writeFile(htmlPath, html); + console.log("Successfully updated index.html with hashed filenames"); + }); + } +}; + const defaultOptions = { logLevel: "info", entryPoints: ["src/App.tsx"], @@ -133,10 +179,11 @@ const defaultOptions = { outdir: "dist", assetNames: "assets/[name]-[hash]", chunkNames: "chunks/[name]-[hash]", - entryNames: "[dir]/[name]", + entryNames: "[dir]/[name]-[hash]", splitting: true, format: "esm", sourcemap: "external", + metafile: true, loader: { ".png": "file", ".jpg": "file", @@ -147,6 +194,7 @@ const defaultOptions = { plugins: [ relayPlugin, hotReloading, + htmlPlugin, postCssPlugin({ plugins: [tailwindcssPlugin(), autoprefixer()], }), @@ -163,7 +211,11 @@ const [, , command] = process.argv; if (command === "watch") { runRelayCompilerInWatchMode(); - const ctx = await esbuild.context({ ...defaultOptions, minify: false }); + const ctx = await esbuild.context({ + ...defaultOptions, + minify: false, + entryNames: "[dir]/[name]" + }); await ctx.watch(); let { host, port } = await ctx.serve({ servedir: "dist" }); http diff --git a/pkg/server/web/web.go b/pkg/server/web/web.go index cc8e57fa8..d164b3f17 100644 --- a/pkg/server/web/web.go +++ b/pkg/server/web/web.go @@ -15,8 +15,13 @@ package web import ( + "crypto/md5" + "encoding/hex" + "fmt" "io/fs" "net/http" + "strings" + "time" "github.com/getprobo/probo/apps/console" ) @@ -24,6 +29,7 @@ import ( type Server struct { spaFS http.FileSystem indexContent []byte + indexETag string } func NewServer() (*Server, error) { @@ -49,9 +55,14 @@ func NewServer() (*Server, error) { return nil, err } + // Generate ETag for index.html + hash := md5.Sum(indexContent) + indexETag := hex.EncodeToString(hash[:]) + return &Server{ spaFS: http.FS(subFS), indexContent: indexContent, + indexETag: indexETag, }, nil } @@ -61,14 +72,62 @@ func (s *Server) ServeSPA(w http.ResponseWriter, r *http.Request) { f, err := s.spaFS.Open(path) if err == nil { defer f.Close() + + info, err := f.Stat() + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + etag := fmt.Sprintf(`"%x-%x"`, info.Size(), info.ModTime().Unix()) + + lastModified := info.ModTime().Format(http.TimeFormat) + + w.Header().Set("ETag", etag) + w.Header().Set("Last-Modified", lastModified) + + if matchETag := r.Header.Get("If-None-Match"); matchETag != "" { + if matchETag == etag { + w.WriteHeader(http.StatusNotModified) + return + } + } + + if modifiedSince := r.Header.Get("If-Modified-Since"); modifiedSince != "" { + if t, err := time.Parse(http.TimeFormat, modifiedSince); err == nil { + if info.ModTime().Unix() <= t.Unix() { + w.WriteHeader(http.StatusNotModified) + return + } + } + } + + if strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") || + strings.HasSuffix(path, ".png") || strings.HasSuffix(path, ".jpg") || + strings.HasSuffix(path, ".svg") || strings.HasSuffix(path, ".woff2") { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") // 1 year + } else { + w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour + } + + w.Header().Set("Transfer-Encoding", "chunked") + + // Serve the file http.FileServer(s.spaFS).ServeHTTP(w, r) return } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("ETag", `"`+s.indexETag+`"`) - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + if r.Header.Get("If-None-Match") == `"`+s.indexETag+`"` { + w.WriteHeader(http.StatusNotModified) + return + } + + w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0") - w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) w.Write(s.indexContent) }