webui: optimize npm ci caching for faster builds

Add caching for npm ci in webui build process by tracking
package-lock.json changes separately from other source files.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: sce58f3444bbcc029k
diff --git a/build/webui.sh b/build/webui.sh
index ec7c62a..ee9659d 100755
--- a/build/webui.sh
+++ b/build/webui.sh
@@ -1,6 +1,8 @@
 #!/bin/bash
 set -e
 
+echo "[$(date '+%H:%M:%S')] Starting webui build..."
+
 # Use a content-based hash of the webui dir to avoid unnecessary rebuilds.
 OUTPUT_DIR="embedded/webui-dist"
 # go:embed ignores files that start with a '.'
@@ -22,11 +24,14 @@
 if [ -f "$HASH_FILE" ] && [ -d "$OUTPUT_DIR" ]; then
     STORED_HASH=$(cat "$HASH_FILE")
     if [ "$CURRENT_HASH" = "$STORED_HASH" ]; then
-        # No changes, skip rebuild
+        echo "No changes, skipping rebuild"
         exit 0
     fi
 fi
 
+echo "[$(date '+%H:%M:%S')] Removing old output directory..."
 rm -rf "$OUTPUT_DIR"
+echo "[$(date '+%H:%M:%S')] Running genwebui..."
 unset GOOS GOARCH && go run ./cmd/genwebui -- "$OUTPUT_DIR"
 echo "$CURRENT_HASH" >"$HASH_FILE"
+echo "[$(date '+%H:%M:%S')] Webui build completed"
diff --git a/webui/esbuild.go b/webui/esbuild.go
index 9f91c61..1493fa7 100644
--- a/webui/esbuild.go
+++ b/webui/esbuild.go
@@ -16,6 +16,7 @@
 	"os/exec"
 	"path/filepath"
 	"strings"
+	"time"
 
 	esbuildcli "github.com/evanw/esbuild/pkg/cli"
 )
@@ -47,6 +48,61 @@
 	return hex.EncodeToString(h.Sum(nil))[:32], nil
 }
 
+// ensureNodeModules runs npm ci only if package-lock.json has changed or node_modules doesn't exist.
+// This optimization saves ~2.4 seconds when only TypeScript or other source files change,
+// since npm ci is only run when dependencies actually change.
+func ensureNodeModules(buildDir string) error {
+	packageLockPath := filepath.Join(buildDir, "package-lock.json")
+	nodeModulesPath := filepath.Join(buildDir, "node_modules")
+	packageLockBackupPath := filepath.Join(buildDir, ".package-lock-installed")
+
+	// Check if node_modules exists
+	if _, err := os.Stat(nodeModulesPath); os.IsNotExist(err) {
+		fmt.Printf("[BUILD] node_modules doesn't exist, running npm ci...\n")
+		return runNpmCI(buildDir, packageLockPath, packageLockBackupPath)
+	}
+
+	// Read current package-lock.json
+	packageLockData, err := os.ReadFile(packageLockPath)
+	if err != nil {
+		return fmt.Errorf("read package-lock.json: %w", err)
+	}
+
+	// Check if package-lock.json has changed by comparing with stored version
+	if storedPackageLockData, err := os.ReadFile(packageLockBackupPath); err == nil {
+		if bytes.Equal(packageLockData, storedPackageLockData) {
+			fmt.Printf("[BUILD] package-lock.json unchanged, skipping npm ci\n")
+			return nil
+		}
+	}
+
+	fmt.Printf("[BUILD] package-lock.json changed, running npm ci...\n")
+	return runNpmCI(buildDir, packageLockPath, packageLockBackupPath)
+}
+
+// runNpmCI executes npm ci and stores the package-lock.json content
+func runNpmCI(buildDir, packageLockPath, packageLockBackupPath string) error {
+	start := time.Now()
+	cmd := exec.Command("npm", "ci", "--omit", "dev")
+	cmd.Dir = buildDir
+	if out, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("npm ci: %s: %v", out, err)
+	}
+	fmt.Printf("[BUILD] npm ci completed in %v\n", time.Since(start))
+
+	// Store a copy of package-lock.json for future comparisons
+	packageLockData, err := os.ReadFile(packageLockPath)
+	if err != nil {
+		return fmt.Errorf("read package-lock.json after npm ci: %w", err)
+	}
+
+	if err := os.WriteFile(packageLockBackupPath, packageLockData, 0o666); err != nil {
+		return fmt.Errorf("write package-lock backup: %w", err)
+	}
+
+	return nil
+}
+
 func cleanBuildDir(buildDir string) error {
 	err := fs.WalkDir(os.DirFS(buildDir), ".", func(path string, d fs.DirEntry, err error) error {
 		if d.Name() == "." {
@@ -55,6 +111,9 @@
 		if d.Name() == "node_modules" {
 			return fs.SkipDir
 		}
+		if d.Name() == ".package-lock-installed" {
+			return nil // Skip file, but don't skip directory
+		}
 		osPath := filepath.Join(buildDir, path)
 		os.RemoveAll(osPath)
 		if d.IsDir() {
@@ -187,17 +246,21 @@
 	// TODO: try downloading "https://sketch.dev/webui/"+filepath.Base(hashZip)
 
 	// We need to do a build.
+	fmt.Printf("[BUILD] Starting webui build process...\n")
+	buildStart := time.Now()
 
 	// Clear everything out of the build directory except node_modules.
+	fmt.Printf("[BUILD] Cleaning build directory...\n")
 	if err := cleanBuildDir(buildDir); err != nil {
 		return nil, err
 	}
 	tmpHashDir := filepath.Join(buildDir, "out")
-	if err := os.Mkdir(tmpHashDir, 0o777); err != nil {
+	if err := os.MkdirAll(tmpHashDir, 0o777); err != nil {
 		return nil, err
 	}
 
 	// Unpack everything from embedded into build dir.
+	fmt.Printf("[BUILD] Unpacking embedded files...\n")
 	if err := unpackFS(buildDir, embedded); err != nil {
 		return nil, err
 	}
@@ -206,10 +269,8 @@
 	// and slow enough to install that the /init requests from the host process
 	// will run out of retries and the whole thing exits. We do need better health
 	// checking in general, but that's a separate issue. Don't do slow stuff here:
-	cmd := exec.Command("npm", "ci", "--omit", "dev")
-	cmd.Dir = buildDir
-	if out, err := cmd.CombinedOutput(); err != nil {
-		return nil, fmt.Errorf("npm ci: %s: %v", out, err)
+	if err := ensureNodeModules(buildDir); err != nil {
+		return nil, fmt.Errorf("ensure node modules: %w", err)
 	}
 
 	// Generate Tailwind CSS
@@ -358,6 +419,7 @@
 		return nil, fmt.Errorf("failed to compress .js/.js.map/.css files: %w", err)
 	}
 
+	fmt.Printf("[BUILD] Build completed in %v\n", time.Since(buildStart))
 	return os.DirFS(tmpHashDir), nil
 }