blob: 246f9cbda0d03faaece708458dfa09979a2e6cea [file] [log] [blame]
// Package webui provides the web interface for the sketch loop.
// It bundles typescript files into JavaScript using esbuild.
package webui
import (
"archive/zip"
"bytes"
"compress/gzip"
"crypto/sha256"
"embed"
"encoding/hex"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
esbuildcli "github.com/evanw/esbuild/pkg/cli"
)
//go:embed package.json package-lock.json src tsconfig.json
var embedded embed.FS
//go:generate go run ../cmd/go2ts -o src/types.ts
func embeddedHash() (string, error) {
h := sha256.New()
err := fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error {
if d.IsDir() {
return nil
}
f, err := embedded.Open(path)
if err != nil {
return err
}
defer f.Close()
if _, err := io.Copy(h, f); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
return nil
})
if err != nil {
return "", fmt.Errorf("embedded hash: %w", err)
}
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) {
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) {
return nil
}
}
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() == "." {
return nil
}
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() {
return fs.SkipDir
}
return nil
})
if err != nil {
return fmt.Errorf("clean build dir: %w", err)
}
return nil
}
func unpackFS(out string, srcFS fs.FS) error {
err := fs.WalkDir(srcFS, ".", func(path string, d fs.DirEntry, err error) error {
if d.Name() == "." {
return nil
}
if d.IsDir() {
if err := os.Mkdir(filepath.Join(out, path), 0o777); err != nil {
return err
}
return nil
}
f, err := srcFS.Open(path)
if err != nil {
return err
}
defer f.Close()
dst, err := os.Create(filepath.Join(out, path))
if err != nil {
return err
}
defer dst.Close()
if _, err := io.Copy(dst, f); err != nil {
return err
}
if err := dst.Close(); err != nil {
return err
}
return nil
})
if err != nil {
return fmt.Errorf("unpack fs into out dir %s: %w", out, err)
}
return nil
}
// TODO: This path being /root/.cache/sketch/webui/skui-....zip means that the Dockerfile
// in createdockerfile.go needs to create the parent directory. Ideally we bundle the built webui
// into the binary and avoid this altogether.
func zipPath() (cacheDir, hashZip string, err error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", "", err
}
hash, err := embeddedHash()
if err != nil {
return "", "", err
}
cacheDir = filepath.Join(homeDir, ".cache", "sketch", "webui")
return cacheDir, filepath.Join(cacheDir, "skui-"+hash+".zip"), nil
}
// generateTailwindCSS generates tailwind.css from global.css and outputs it to the specified directory
func generateTailwindCSS(buildDir, outDir string) error {
// Run tailwindcss CLI to generate the CSS
cmd := exec.Command("npx", "tailwindcss", "-i", "./src/global.css", "-o", filepath.Join(outDir, "tailwind.css"))
cmd.Dir = buildDir
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("tailwindcss generation failed: %s: %v", out, err)
}
return nil
}
// copyMonacoAssets copies Monaco editor assets to the output directory
func copyMonacoAssets(buildDir, outDir string) error {
// Create Monaco directories
monacoEditorDir := filepath.Join(outDir, "monaco", "min", "vs", "editor")
codiconDir := filepath.Join(outDir, "monaco", "min", "vs", "base", "browser", "ui", "codicons", "codicon")
if err := os.MkdirAll(monacoEditorDir, 0o777); err != nil {
return fmt.Errorf("failed to create monaco editor directory: %w", err)
}
if err := os.MkdirAll(codiconDir, 0o777); err != nil {
return fmt.Errorf("failed to create codicon directory: %w", err)
}
// Copy Monaco editor CSS
editorCssPath := "node_modules/monaco-editor/min/vs/editor/editor.main.css"
editorCss, err := os.ReadFile(filepath.Join(buildDir, editorCssPath))
if err != nil {
return fmt.Errorf("failed to read monaco editor CSS: %w", err)
}
if err := os.WriteFile(filepath.Join(monacoEditorDir, "editor.main.css"), editorCss, 0o666); err != nil {
return fmt.Errorf("failed to write monaco editor CSS: %w", err)
}
// Copy Codicon font
codiconFontPath := "node_modules/monaco-editor/min/vs/base/browser/ui/codicons/codicon/codicon.ttf"
codiconFont, err := os.ReadFile(filepath.Join(buildDir, codiconFontPath))
if err != nil {
return fmt.Errorf("failed to read codicon font: %w", err)
}
if err := os.WriteFile(filepath.Join(codiconDir, "codicon.ttf"), codiconFont, 0o666); err != nil {
return fmt.Errorf("failed to write codicon font: %w", err)
}
return nil
}
// Build unpacks and esbuild's all bundleTs typescript files
func Build() (fs.FS, error) {
cacheDir, hashZip, err := zipPath()
if err != nil {
return nil, err
}
buildDir := filepath.Join(cacheDir, "build")
if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
return nil, err
}
if b, err := os.ReadFile(hashZip); err == nil {
// Build already done, serve it out.
return zip.NewReader(bytes.NewReader(b), int64(len(b)))
}
// TODO: try downloading "https://sketch.dev/webui/"+filepath.Base(hashZip)
// We need to do a build.
// Clear everything out of the build directory except node_modules.
if err := cleanBuildDir(buildDir); err != nil {
return nil, err
}
tmpHashDir := filepath.Join(buildDir, "out")
if err := os.MkdirAll(tmpHashDir, 0o777); err != nil {
return nil, err
}
// Unpack everything from embedded into build dir.
if err := unpackFS(buildDir, embedded); err != nil {
return nil, err
}
// Do the build. Don't install dev dependencies, because they can be large
// 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:
if err := ensureNodeModules(buildDir); err != nil {
return nil, fmt.Errorf("ensure node modules: %w", err)
}
// Generate Tailwind CSS
if err := generateTailwindCSS(buildDir, tmpHashDir); err != nil {
return nil, fmt.Errorf("generate tailwind css: %w", err)
}
// Create all bundles
bundleTs := []string{
"src/web-components/sketch-app-shell.ts",
"src/web-components/mobile-app-shell.ts",
"src/web-components/sketch-monaco-view.ts",
"node_modules/monaco-editor/esm/vs/editor/editor.worker.js",
"node_modules/monaco-editor/esm/vs/language/typescript/ts.worker.js",
"node_modules/monaco-editor/esm/vs/language/html/html.worker.js",
"node_modules/monaco-editor/esm/vs/language/css/css.worker.js",
"node_modules/monaco-editor/esm/vs/language/json/json.worker.js",
}
// Additionally create standalone bundles for caching
monacoHash, err := createStandaloneMonacoBundle(tmpHashDir, buildDir)
if err != nil {
return nil, fmt.Errorf("create monaco bundle: %w", err)
}
mermaidHash, err := createStandaloneMermaidBundle(tmpHashDir, buildDir)
if err != nil {
return nil, fmt.Errorf("create mermaid bundle: %w", err)
}
// Bundle all files with Monaco and Mermaid as external (since they may transitively import them)
for _, tsName := range bundleTs {
// Use external Monaco and Mermaid for all TypeScript files to ensure consistency
if strings.HasSuffix(tsName, ".ts") {
if err := esbuildBundleWithExternals(tmpHashDir, filepath.Join(buildDir, tsName), monacoHash, mermaidHash); err != nil {
return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
}
} else {
// Bundle worker files normally (they don't use Monaco or Mermaid)
if err := esbuildBundle(tmpHashDir, filepath.Join(buildDir, tsName), ""); err != nil {
return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
}
}
}
// Copy Monaco editor assets
if err := copyMonacoAssets(buildDir, tmpHashDir); err != nil {
return nil, fmt.Errorf("failed to copy Monaco assets: %w", err)
}
// Copy src files used directly into the new hash output dir.
err = fs.WalkDir(embedded, "src", func(path string, d fs.DirEntry, err error) error {
if d.IsDir() {
if path == "src/web-components/demo" {
return fs.SkipDir
}
return nil
}
if strings.HasSuffix(path, "mockServiceWorker.js") {
return nil
}
// Skip src/tailwind.css as it will be generated
if path == "src/tailwind.css" {
return nil
}
if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".css") || strings.HasSuffix(path, ".js") {
b, err := embedded.ReadFile(path)
if err != nil {
return err
}
dstPath := filepath.Join(tmpHashDir, strings.TrimPrefix(path, "src/"))
if err := os.WriteFile(dstPath, b, 0o777); err != nil {
return err
}
return nil
}
return nil
})
if err != nil {
return nil, err
}
// Copy xterm.css from node_modules
const xtermCssPath = "node_modules/@xterm/xterm/css/xterm.css"
xtermCss, err := os.ReadFile(filepath.Join(buildDir, xtermCssPath))
if err != nil {
return nil, fmt.Errorf("failed to read xterm.css: %w", err)
}
if err := os.WriteFile(filepath.Join(tmpHashDir, "xterm.css"), xtermCss, 0o666); err != nil {
return nil, fmt.Errorf("failed to write xterm.css: %w", err)
}
// Compress all .js, .js.map, and .css files with gzip, leaving the originals in place
err = filepath.Walk(tmpHashDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
// Check if file is a .js or .js.map file
if !strings.HasSuffix(path, ".js") && !strings.HasSuffix(path, ".js.map") && !strings.HasSuffix(path, ".css") {
return nil
}
// Read the original file
origData, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read file %s: %w", path, err)
}
// Create a gzipped file
gzipPath := path + ".gz"
gzipFile, err := os.Create(gzipPath)
if err != nil {
return fmt.Errorf("failed to create gzip file %s: %w", gzipPath, err)
}
defer gzipFile.Close()
// Create a gzip writer
gzWriter := gzip.NewWriter(gzipFile)
defer gzWriter.Close()
// Write the original file content to the gzip writer
_, err = gzWriter.Write(origData)
if err != nil {
return fmt.Errorf("failed to write to gzip file %s: %w", gzipPath, err)
}
// Ensure we flush and close properly
if err := gzWriter.Close(); err != nil {
return fmt.Errorf("failed to close gzip writer for %s: %w", gzipPath, err)
}
if err := gzipFile.Close(); err != nil {
return fmt.Errorf("failed to close gzip file %s: %w", gzipPath, err)
}
// The gzip handler will decompress on-the-fly for browsers that don't support gzip.
if err := os.Remove(path); err != nil {
return fmt.Errorf("failed to remove uncompressed file %s: %w", path, err)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to compress .js/.js.map/.css files: %w", err)
}
return os.DirFS(tmpHashDir), nil
}
func esbuildBundle(outDir, src, metafilePath string) error {
args := []string{
src,
"--bundle",
"--sourcemap",
"--log-level=error",
"--minify",
"--outdir=" + outDir,
"--loader:.ttf=file",
"--loader:.eot=file",
"--loader:.woff=file",
"--loader:.woff2=file",
// This changes where the sourcemap points to; we need relative dirs if we're proxied into a subdirectory.
"--public-path=.",
}
// Add metafile option if path is provided
if metafilePath != "" {
args = append(args, "--metafile="+metafilePath)
}
ret := esbuildcli.Run(args)
if ret != 0 {
return fmt.Errorf("esbuild %s failed: %d", filepath.Base(src), ret)
}
return nil
}
// unpackTS unpacks all the typescript-relevant files from the embedded filesystem into tmpDir.
func unpackTS(outDir string, embedded fs.FS) error {
return fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
tgt := filepath.Join(outDir, path)
if d.IsDir() {
if err := os.MkdirAll(tgt, 0o777); err != nil {
return err
}
return nil
}
if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".md") || strings.HasSuffix(path, ".css") {
return nil
}
data, err := fs.ReadFile(embedded, path)
if err != nil {
return err
}
if err := os.WriteFile(tgt, data, 0o666); err != nil {
return err
}
return nil
})
}
// GenerateBundleMetafile creates metafiles for bundle analysis with esbuild.
//
// The metafiles contain information about bundle size and dependencies
// that can be visualized at https://esbuild.github.io/analyze/
//
// It takes the output directory where the metafiles will be written.
// Returns the file path of the generated metafiles.
func GenerateBundleMetafile(outputDir string) (string, error) {
tmpDir, err := os.MkdirTemp("", "bundle-analysis-")
if err != nil {
return "", err
}
defer os.RemoveAll(tmpDir)
// Create output directory if it doesn't exist
if err := os.MkdirAll(outputDir, 0o755); err != nil {
return "", err
}
cacheDir, _, err := zipPath()
if err != nil {
return "", err
}
buildDir := filepath.Join(cacheDir, "build")
if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
return "", err
}
// Ensure we have a source to bundle
if err := unpackTS(tmpDir, embedded); err != nil {
return "", err
}
// All bundles to analyze
bundleTs := []string{
"src/web-components/sketch-app-shell.ts",
"src/web-components/mobile-app-shell.ts",
"src/web-components/sketch-monaco-view.ts",
}
metafiles := make([]string, len(bundleTs))
for i, tsName := range bundleTs {
// Create a metafile path for this bundle
baseFileName := filepath.Base(tsName)
metaFileName := strings.TrimSuffix(baseFileName, ".ts") + ".meta.json"
metafilePath := filepath.Join(outputDir, metaFileName)
metafiles[i] = metafilePath
// Bundle with metafile generation
outTmpDir, err := os.MkdirTemp("", "metafile-bundle-")
if err != nil {
return "", err
}
defer os.RemoveAll(outTmpDir)
if err := esbuildBundle(outTmpDir, filepath.Join(buildDir, tsName), metafilePath); err != nil {
return "", fmt.Errorf("failed to generate metafile for %s: %w", tsName, err)
}
}
return outputDir, nil
}
// createStandaloneMonacoBundle creates a separate Monaco editor bundle with content-based hash
// This is useful for caching Monaco separately from the main application bundles
func createStandaloneMonacoBundle(outDir, buildDir string) (string, error) {
// Create a temporary entry file that imports Monaco and exposes it globally
monacoEntryContent := `import * as monaco from 'monaco-editor';
window.monaco = monaco;
export default monaco;
`
monacoEntryPath := filepath.Join(buildDir, "monaco-standalone-entry.js")
if err := os.WriteFile(monacoEntryPath, []byte(monacoEntryContent), 0o666); err != nil {
return "", fmt.Errorf("write monaco entry: %w", err)
}
// Calculate hash of monaco-editor package for content-based naming
monacoPackageJson := filepath.Join(buildDir, "node_modules", "monaco-editor", "package.json")
monacoContent, err := os.ReadFile(monacoPackageJson)
if err != nil {
return "", fmt.Errorf("read monaco package.json: %w", err)
}
h := sha256.New()
h.Write(monacoContent)
monacoHash := hex.EncodeToString(h.Sum(nil))[:16]
// Bundle Monaco with content-based filename
monacoOutputName := fmt.Sprintf("monaco-standalone-%s.js", monacoHash)
monacoOutputPath := filepath.Join(outDir, monacoOutputName)
args := []string{
monacoEntryPath,
"--bundle",
"--sourcemap",
"--minify",
"--log-level=error",
"--outfile=" + monacoOutputPath,
"--format=iife",
"--global-name=__MonacoLoader__",
"--loader:.ttf=file",
"--loader:.eot=file",
"--loader:.woff=file",
"--loader:.woff2=file",
"--public-path=.",
}
ret := esbuildcli.Run(args)
if ret != 0 {
return "", fmt.Errorf("esbuild monaco bundle failed: %d", ret)
}
return monacoHash, nil
}
// createStandaloneMermaidBundle creates a separate Mermaid bundle with content-based hash
// This is useful for caching Mermaid separately from the main application bundles
func createStandaloneMermaidBundle(outDir, buildDir string) (string, error) {
// Create a temporary entry file that imports Mermaid and exposes it globally
mermaidEntryContent := `import mermaid from 'mermaid';
window.mermaid = mermaid;
export default mermaid;
`
mermaidEntryPath := filepath.Join(buildDir, "mermaid-standalone-entry.js")
if err := os.WriteFile(mermaidEntryPath, []byte(mermaidEntryContent), 0o666); err != nil {
return "", fmt.Errorf("write mermaid entry: %w", err)
}
// Calculate hash of mermaid package for content-based naming
mermaidPackageJson := filepath.Join(buildDir, "node_modules", "mermaid", "package.json")
mermaidContent, err := os.ReadFile(mermaidPackageJson)
if err != nil {
return "", fmt.Errorf("read mermaid package.json: %w", err)
}
h := sha256.New()
h.Write(mermaidContent)
mermaidHash := hex.EncodeToString(h.Sum(nil))[:16]
// Bundle Mermaid with content-based filename
mermaidOutputName := fmt.Sprintf("mermaid-standalone-%s.js", mermaidHash)
mermaidOutputPath := filepath.Join(outDir, mermaidOutputName)
args := []string{
mermaidEntryPath,
"--bundle",
"--sourcemap",
"--minify",
"--log-level=error",
"--outfile=" + mermaidOutputPath,
"--format=iife",
"--global-name=__MermaidLoader__",
"--loader:.ttf=file",
"--loader:.eot=file",
"--loader:.woff=file",
"--loader:.woff2=file",
"--public-path=.",
}
ret := esbuildcli.Run(args)
if ret != 0 {
return "", fmt.Errorf("esbuild mermaid bundle failed: %d", ret)
}
return mermaidHash, nil
}
// esbuildBundleWithExternals bundles a file with Monaco and Mermaid as external dependencies
func esbuildBundleWithExternals(outDir, src, monacoHash, mermaidHash string) error {
args := []string{
src,
"--bundle",
"--sourcemap",
"--minify",
"--log-level=error",
"--outdir=" + outDir,
"--external:monaco-editor",
"--external:mermaid",
"--loader:.ttf=file",
"--loader:.eot=file",
"--loader:.woff=file",
"--loader:.woff2=file",
"--public-path=.",
"--define:__MONACO_HASH__=\"" + monacoHash + "\"",
"--define:__MERMAID_HASH__=\"" + mermaidHash + "\"",
}
ret := esbuildcli.Run(args)
if ret != 0 {
return fmt.Errorf("esbuild %s failed: %d", filepath.Base(src), ret)
}
return nil
}