blob: d4af63635002fa49b846786060ece1f36750a206 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001// Package webui provides the web interface for the sketch loop.
2// It bundles typescript files into JavaScript using esbuild.
3//
4// This is substantially the same mechanism as /esbuild.go in this repo as well.
5package webui
6
7import (
David Crawshaw8bff16a2025-04-18 01:16:49 -07008 "archive/zip"
9 "bytes"
Earl Lee2e463fb2025-04-17 11:22:22 -070010 "crypto/sha256"
11 "embed"
12 "encoding/hex"
13 "fmt"
14 "io"
15 "io/fs"
16 "os"
17 "os/exec"
18 "path/filepath"
19 "strings"
20
21 esbuildcli "github.com/evanw/esbuild/pkg/cli"
22)
23
Sean McCullough86b56862025-04-18 13:04:03 -070024//go:embed package.json package-lock.json src tsconfig.json
Earl Lee2e463fb2025-04-17 11:22:22 -070025var embedded embed.FS
26
27func embeddedHash() (string, error) {
28 h := sha256.New()
29 err := fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error {
30 if d.IsDir() {
31 return nil
32 }
33 f, err := embedded.Open(path)
34 if err != nil {
35 return err
36 }
37 defer f.Close()
38 if _, err := io.Copy(h, f); err != nil {
39 return fmt.Errorf("%s: %w", path, err)
40 }
41 return nil
42 })
43 if err != nil {
44 return "", fmt.Errorf("embedded hash: %w", err)
45 }
David Crawshaw8bff16a2025-04-18 01:16:49 -070046 return hex.EncodeToString(h.Sum(nil))[:32], nil
Earl Lee2e463fb2025-04-17 11:22:22 -070047}
48
49func cleanBuildDir(buildDir string) error {
50 err := fs.WalkDir(os.DirFS(buildDir), ".", func(path string, d fs.DirEntry, err error) error {
51 if d.Name() == "." {
52 return nil
53 }
54 if d.Name() == "node_modules" {
55 return fs.SkipDir
56 }
57 osPath := filepath.Join(buildDir, path)
Earl Lee2e463fb2025-04-17 11:22:22 -070058 os.RemoveAll(osPath)
59 if d.IsDir() {
60 return fs.SkipDir
61 }
62 return nil
63 })
64 if err != nil {
65 return fmt.Errorf("clean build dir: %w", err)
66 }
67 return nil
68}
69
70func unpackFS(out string, srcFS fs.FS) error {
71 err := fs.WalkDir(srcFS, ".", func(path string, d fs.DirEntry, err error) error {
72 if d.Name() == "." {
73 return nil
74 }
75 if d.IsDir() {
76 if err := os.Mkdir(filepath.Join(out, path), 0o777); err != nil {
77 return err
78 }
79 return nil
80 }
81 f, err := srcFS.Open(path)
82 if err != nil {
83 return err
84 }
85 defer f.Close()
86 dst, err := os.Create(filepath.Join(out, path))
87 if err != nil {
88 return err
89 }
90 defer dst.Close()
91 if _, err := io.Copy(dst, f); err != nil {
92 return err
93 }
94 if err := dst.Close(); err != nil {
95 return err
96 }
97 return nil
98 })
99 if err != nil {
100 return fmt.Errorf("unpack fs into out dir %s: %w", out, err)
101 }
102 return nil
103}
104
David Crawshaw8bff16a2025-04-18 01:16:49 -0700105func ZipPath() (string, error) {
106 _, hashZip, err := zipPath()
107 return hashZip, err
108}
109
110func zipPath() (cacheDir, hashZip string, err error) {
111 homeDir, err := os.UserHomeDir()
112 if err != nil {
113 return "", "", err
114 }
115 hash, err := embeddedHash()
116 if err != nil {
117 return "", "", err
118 }
119 cacheDir = filepath.Join(homeDir, ".cache", "sketch", "webui")
120 return cacheDir, filepath.Join(cacheDir, "skui-"+hash+".zip"), nil
121}
122
Earl Lee2e463fb2025-04-17 11:22:22 -0700123// Build unpacks and esbuild's all bundleTs typescript files
124func Build() (fs.FS, error) {
David Crawshaw8bff16a2025-04-18 01:16:49 -0700125 cacheDir, hashZip, err := zipPath()
Earl Lee2e463fb2025-04-17 11:22:22 -0700126 if err != nil {
127 return nil, err
128 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700129 buildDir := filepath.Join(cacheDir, "build")
130 if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
131 return nil, err
132 }
David Crawshaw8bff16a2025-04-18 01:16:49 -0700133 if b, err := os.ReadFile(hashZip); err == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700134 // Build already done, serve it out.
David Crawshaw8bff16a2025-04-18 01:16:49 -0700135 return zip.NewReader(bytes.NewReader(b), int64(len(b)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700136 }
137
David Crawshaw8bff16a2025-04-18 01:16:49 -0700138 // TODO: try downloading "https://sketch.dev/webui/"+filepath.Base(hashZip)
139
Earl Lee2e463fb2025-04-17 11:22:22 -0700140 // We need to do a build.
141
142 // Clear everything out of the build directory except node_modules.
143 if err := cleanBuildDir(buildDir); err != nil {
144 return nil, err
145 }
146 tmpHashDir := filepath.Join(buildDir, "out")
147 if err := os.Mkdir(tmpHashDir, 0o777); err != nil {
148 return nil, err
149 }
150
151 // Unpack everything from embedded into build dir.
152 if err := unpackFS(buildDir, embedded); err != nil {
153 return nil, err
154 }
155
Sean McCullough86b56862025-04-18 13:04:03 -0700156 // Do the build. Don't install dev dependencies, because they can be large
157 // and slow enough to install that the /init requests from the host process
158 // will run out of retries and the whole thing exits. We do need better health
159 // checking in general, but that's a separate issue. Don't do slow stuff here:
160 cmd := exec.Command("npm", "ci", "--omit", "dev")
Earl Lee2e463fb2025-04-17 11:22:22 -0700161 cmd.Dir = buildDir
162 if out, err := cmd.CombinedOutput(); err != nil {
163 return nil, fmt.Errorf("npm ci: %s: %v", out, err)
164 }
Sean McCullough86b56862025-04-18 13:04:03 -0700165 bundleTs := []string{"src/web-components/sketch-app-shell.ts"}
Earl Lee2e463fb2025-04-17 11:22:22 -0700166 for _, tsName := range bundleTs {
Sean McCullough86b56862025-04-18 13:04:03 -0700167 if err := esbuildBundle(tmpHashDir, filepath.Join(buildDir, tsName), ""); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700168 return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
169 }
170 }
171
172 // Copy src files used directly into the new hash output dir.
173 err = fs.WalkDir(embedded, "src", func(path string, d fs.DirEntry, err error) error {
174 if d.IsDir() {
Sean McCullough86b56862025-04-18 13:04:03 -0700175 if path == "src/web-components/demo" {
176 return fs.SkipDir
177 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700178 return nil
179 }
180 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".css") || strings.HasSuffix(path, ".js") {
181 b, err := embedded.ReadFile(path)
182 if err != nil {
183 return err
184 }
185 dstPath := filepath.Join(tmpHashDir, strings.TrimPrefix(path, "src/"))
186 if err := os.WriteFile(dstPath, b, 0o777); err != nil {
187 return err
188 }
189 return nil
190 }
191 return nil
192 })
193 if err != nil {
194 return nil, err
195 }
196
197 // Copy xterm.css from node_modules
198 const xtermCssPath = "node_modules/@xterm/xterm/css/xterm.css"
199 xtermCss, err := os.ReadFile(filepath.Join(buildDir, xtermCssPath))
200 if err != nil {
201 return nil, fmt.Errorf("failed to read xterm.css: %w", err)
202 }
203 if err := os.WriteFile(filepath.Join(tmpHashDir, "xterm.css"), xtermCss, 0o666); err != nil {
204 return nil, fmt.Errorf("failed to write xterm.css: %w", err)
205 }
206
David Crawshaw8bff16a2025-04-18 01:16:49 -0700207 // Everything succeeded, so we write tmpHashDir to hashZip
208 buf := new(bytes.Buffer)
209 w := zip.NewWriter(buf)
210 if err := w.AddFS(os.DirFS(tmpHashDir)); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700211 return nil, err
212 }
David Crawshaw8bff16a2025-04-18 01:16:49 -0700213 if err := w.Close(); err != nil {
214 return nil, err
215 }
216 if err := os.WriteFile(hashZip, buf.Bytes(), 0o666); err != nil {
217 return nil, err
218 }
219 return zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
Earl Lee2e463fb2025-04-17 11:22:22 -0700220}
221
Sean McCullough86b56862025-04-18 13:04:03 -0700222func esbuildBundle(outDir, src, metafilePath string) error {
223 args := []string{
Earl Lee2e463fb2025-04-17 11:22:22 -0700224 src,
225 "--bundle",
226 "--sourcemap",
227 "--log-level=error",
228 // Disable minification for now
229 // "--minify",
230 "--outdir=" + outDir,
Sean McCullough86b56862025-04-18 13:04:03 -0700231 }
232
233 // Add metafile option if path is provided
234 if metafilePath != "" {
235 args = append(args, "--metafile="+metafilePath)
236 }
237
238 ret := esbuildcli.Run(args)
Earl Lee2e463fb2025-04-17 11:22:22 -0700239 if ret != 0 {
240 return fmt.Errorf("esbuild %s failed: %d", filepath.Base(src), ret)
241 }
242 return nil
243}
Sean McCullough86b56862025-04-18 13:04:03 -0700244
245// unpackTS unpacks all the typescript-relevant files from the embedded filesystem into tmpDir.
246func unpackTS(outDir string, embedded fs.FS) error {
247 return fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error {
248 if err != nil {
249 return err
250 }
251 tgt := filepath.Join(outDir, path)
252 if d.IsDir() {
253 if err := os.MkdirAll(tgt, 0o777); err != nil {
254 return err
255 }
256 return nil
257 }
258 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".md") || strings.HasSuffix(path, ".css") {
259 return nil
260 }
261 data, err := fs.ReadFile(embedded, path)
262 if err != nil {
263 return err
264 }
265 if err := os.WriteFile(tgt, data, 0o666); err != nil {
266 return err
267 }
268 return nil
269 })
270}
271
272// GenerateBundleMetafile creates metafiles for bundle analysis with esbuild.
273//
274// The metafiles contain information about bundle size and dependencies
275// that can be visualized at https://esbuild.github.io/analyze/
276//
277// It takes the output directory where the metafiles will be written.
278// Returns the file path of the generated metafiles.
279func GenerateBundleMetafile(outputDir string) (string, error) {
280 tmpDir, err := os.MkdirTemp("", "bundle-analysis-")
281 if err != nil {
282 return "", err
283 }
284 defer os.RemoveAll(tmpDir)
285
286 // Create output directory if it doesn't exist
287 if err := os.MkdirAll(outputDir, 0755); err != nil {
288 return "", err
289 }
290
291 cacheDir, _, err := zipPath()
292 if err != nil {
293 return "", err
294 }
295 buildDir := filepath.Join(cacheDir, "build")
296 if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
297 return "", err
298 }
299
300 // Ensure we have a source to bundle
301 if err := unpackTS(tmpDir, embedded); err != nil {
302 return "", err
303 }
304
305 // All bundles to analyze
306 bundleTs := []string{"src/web-components/sketch-app-shell.ts"}
307 metafiles := make([]string, len(bundleTs))
308
309 for i, tsName := range bundleTs {
310 // Create a metafile path for this bundle
311 baseFileName := filepath.Base(tsName)
312 metaFileName := strings.TrimSuffix(baseFileName, ".ts") + ".meta.json"
313 metafilePath := filepath.Join(outputDir, metaFileName)
314 metafiles[i] = metafilePath
315
316 // Bundle with metafile generation
317 outTmpDir, err := os.MkdirTemp("", "metafile-bundle-")
318 if err != nil {
319 return "", err
320 }
321 defer os.RemoveAll(outTmpDir)
322
323 if err := esbuildBundle(outTmpDir, filepath.Join(buildDir, tsName), metafilePath); err != nil {
324 return "", fmt.Errorf("failed to generate metafile for %s: %w", tsName, err)
325 }
326 }
327
328 return outputDir, nil
329}