blob: b86b7d777c5f714fee175cd8fafdc70fd6a6eb4a [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.
Earl Lee2e463fb2025-04-17 11:22:22 -07003package webui
4
5import (
David Crawshaw8bff16a2025-04-18 01:16:49 -07006 "archive/zip"
7 "bytes"
Philip Zeyliger176de792025-04-21 12:25:18 -07008 "compress/gzip"
Earl Lee2e463fb2025-04-17 11:22:22 -07009 "crypto/sha256"
10 "embed"
11 "encoding/hex"
12 "fmt"
13 "io"
14 "io/fs"
15 "os"
16 "os/exec"
17 "path/filepath"
18 "strings"
19
20 esbuildcli "github.com/evanw/esbuild/pkg/cli"
21)
22
Sean McCullough86b56862025-04-18 13:04:03 -070023//go:embed package.json package-lock.json src tsconfig.json
Earl Lee2e463fb2025-04-17 11:22:22 -070024var embedded embed.FS
25
26func embeddedHash() (string, error) {
27 h := sha256.New()
28 err := fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error {
29 if d.IsDir() {
30 return nil
31 }
32 f, err := embedded.Open(path)
33 if err != nil {
34 return err
35 }
36 defer f.Close()
37 if _, err := io.Copy(h, f); err != nil {
38 return fmt.Errorf("%s: %w", path, err)
39 }
40 return nil
41 })
42 if err != nil {
43 return "", fmt.Errorf("embedded hash: %w", err)
44 }
David Crawshaw8bff16a2025-04-18 01:16:49 -070045 return hex.EncodeToString(h.Sum(nil))[:32], nil
Earl Lee2e463fb2025-04-17 11:22:22 -070046}
47
48func cleanBuildDir(buildDir string) error {
49 err := fs.WalkDir(os.DirFS(buildDir), ".", func(path string, d fs.DirEntry, err error) error {
50 if d.Name() == "." {
51 return nil
52 }
53 if d.Name() == "node_modules" {
54 return fs.SkipDir
55 }
56 osPath := filepath.Join(buildDir, path)
Earl Lee2e463fb2025-04-17 11:22:22 -070057 os.RemoveAll(osPath)
58 if d.IsDir() {
59 return fs.SkipDir
60 }
61 return nil
62 })
63 if err != nil {
64 return fmt.Errorf("clean build dir: %w", err)
65 }
66 return nil
67}
68
69func unpackFS(out string, srcFS fs.FS) error {
70 err := fs.WalkDir(srcFS, ".", func(path string, d fs.DirEntry, err error) error {
71 if d.Name() == "." {
72 return nil
73 }
74 if d.IsDir() {
75 if err := os.Mkdir(filepath.Join(out, path), 0o777); err != nil {
76 return err
77 }
78 return nil
79 }
80 f, err := srcFS.Open(path)
81 if err != nil {
82 return err
83 }
84 defer f.Close()
85 dst, err := os.Create(filepath.Join(out, path))
86 if err != nil {
87 return err
88 }
89 defer dst.Close()
90 if _, err := io.Copy(dst, f); err != nil {
91 return err
92 }
93 if err := dst.Close(); err != nil {
94 return err
95 }
96 return nil
97 })
98 if err != nil {
99 return fmt.Errorf("unpack fs into out dir %s: %w", out, err)
100 }
101 return nil
102}
103
David Crawshaw8bff16a2025-04-18 01:16:49 -0700104func ZipPath() (string, error) {
105 _, hashZip, err := zipPath()
106 return hashZip, err
107}
108
109func zipPath() (cacheDir, hashZip string, err error) {
110 homeDir, err := os.UserHomeDir()
111 if err != nil {
112 return "", "", err
113 }
114 hash, err := embeddedHash()
115 if err != nil {
116 return "", "", err
117 }
118 cacheDir = filepath.Join(homeDir, ".cache", "sketch", "webui")
119 return cacheDir, filepath.Join(cacheDir, "skui-"+hash+".zip"), nil
120}
121
Earl Lee2e463fb2025-04-17 11:22:22 -0700122// Build unpacks and esbuild's all bundleTs typescript files
123func Build() (fs.FS, error) {
David Crawshaw8bff16a2025-04-18 01:16:49 -0700124 cacheDir, hashZip, err := zipPath()
Earl Lee2e463fb2025-04-17 11:22:22 -0700125 if err != nil {
126 return nil, err
127 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700128 buildDir := filepath.Join(cacheDir, "build")
129 if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
130 return nil, err
131 }
David Crawshaw8bff16a2025-04-18 01:16:49 -0700132 if b, err := os.ReadFile(hashZip); err == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700133 // Build already done, serve it out.
David Crawshaw8bff16a2025-04-18 01:16:49 -0700134 return zip.NewReader(bytes.NewReader(b), int64(len(b)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700135 }
136
David Crawshaw8bff16a2025-04-18 01:16:49 -0700137 // TODO: try downloading "https://sketch.dev/webui/"+filepath.Base(hashZip)
138
Earl Lee2e463fb2025-04-17 11:22:22 -0700139 // We need to do a build.
140
141 // Clear everything out of the build directory except node_modules.
142 if err := cleanBuildDir(buildDir); err != nil {
143 return nil, err
144 }
145 tmpHashDir := filepath.Join(buildDir, "out")
146 if err := os.Mkdir(tmpHashDir, 0o777); err != nil {
147 return nil, err
148 }
149
150 // Unpack everything from embedded into build dir.
151 if err := unpackFS(buildDir, embedded); err != nil {
152 return nil, err
153 }
154
Sean McCullough86b56862025-04-18 13:04:03 -0700155 // Do the build. Don't install dev dependencies, because they can be large
156 // and slow enough to install that the /init requests from the host process
157 // will run out of retries and the whole thing exits. We do need better health
158 // checking in general, but that's a separate issue. Don't do slow stuff here:
159 cmd := exec.Command("npm", "ci", "--omit", "dev")
Earl Lee2e463fb2025-04-17 11:22:22 -0700160 cmd.Dir = buildDir
161 if out, err := cmd.CombinedOutput(); err != nil {
162 return nil, fmt.Errorf("npm ci: %s: %v", out, err)
163 }
Sean McCullough86b56862025-04-18 13:04:03 -0700164 bundleTs := []string{"src/web-components/sketch-app-shell.ts"}
Earl Lee2e463fb2025-04-17 11:22:22 -0700165 for _, tsName := range bundleTs {
Sean McCullough86b56862025-04-18 13:04:03 -0700166 if err := esbuildBundle(tmpHashDir, filepath.Join(buildDir, tsName), ""); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700167 return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
168 }
169 }
170
171 // Copy src files used directly into the new hash output dir.
172 err = fs.WalkDir(embedded, "src", func(path string, d fs.DirEntry, err error) error {
173 if d.IsDir() {
Sean McCullough86b56862025-04-18 13:04:03 -0700174 if path == "src/web-components/demo" {
175 return fs.SkipDir
176 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700177 return nil
178 }
179 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".css") || strings.HasSuffix(path, ".js") {
180 b, err := embedded.ReadFile(path)
181 if err != nil {
182 return err
183 }
184 dstPath := filepath.Join(tmpHashDir, strings.TrimPrefix(path, "src/"))
185 if err := os.WriteFile(dstPath, b, 0o777); err != nil {
186 return err
187 }
188 return nil
189 }
190 return nil
191 })
192 if err != nil {
193 return nil, err
194 }
195
196 // Copy xterm.css from node_modules
197 const xtermCssPath = "node_modules/@xterm/xterm/css/xterm.css"
198 xtermCss, err := os.ReadFile(filepath.Join(buildDir, xtermCssPath))
199 if err != nil {
200 return nil, fmt.Errorf("failed to read xterm.css: %w", err)
201 }
202 if err := os.WriteFile(filepath.Join(tmpHashDir, "xterm.css"), xtermCss, 0o666); err != nil {
203 return nil, fmt.Errorf("failed to write xterm.css: %w", err)
204 }
205
Philip Zeyliger176de792025-04-21 12:25:18 -0700206 // Compress all .js, .js.map, and .css files with gzip, leaving the originals in place
207 err = filepath.Walk(tmpHashDir, func(path string, info os.FileInfo, err error) error {
208 if err != nil {
209 return err
210 }
211 if info.IsDir() {
212 return nil
213 }
214 // Check if file is a .js or .js.map file
215 if !strings.HasSuffix(path, ".js") && !strings.HasSuffix(path, ".js.map") && !strings.HasSuffix(path, ".css") {
216 return nil
217 }
218
219 // Read the original file
220 origData, err := os.ReadFile(path)
221 if err != nil {
222 return fmt.Errorf("failed to read file %s: %w", path, err)
223 }
224
225 // Create a gzipped file
226 gzipPath := path + ".gz"
227 gzipFile, err := os.Create(gzipPath)
228 if err != nil {
229 return fmt.Errorf("failed to create gzip file %s: %w", gzipPath, err)
230 }
231 defer gzipFile.Close()
232
233 // Create a gzip writer
234 gzWriter := gzip.NewWriter(gzipFile)
235 defer gzWriter.Close()
236
237 // Write the original file content to the gzip writer
238 _, err = gzWriter.Write(origData)
239 if err != nil {
240 return fmt.Errorf("failed to write to gzip file %s: %w", gzipPath, err)
241 }
242
243 // Ensure we flush and close properly
244 if err := gzWriter.Close(); err != nil {
245 return fmt.Errorf("failed to close gzip writer for %s: %w", gzipPath, err)
246 }
247 if err := gzipFile.Close(); err != nil {
248 return fmt.Errorf("failed to close gzip file %s: %w", gzipPath, err)
249 }
250
251 return nil
252 })
253 if err != nil {
254 return nil, fmt.Errorf("failed to compress .js/.js.map/.css files: %w", err)
255 }
256
David Crawshaw8bff16a2025-04-18 01:16:49 -0700257 // Everything succeeded, so we write tmpHashDir to hashZip
258 buf := new(bytes.Buffer)
259 w := zip.NewWriter(buf)
260 if err := w.AddFS(os.DirFS(tmpHashDir)); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700261 return nil, err
262 }
David Crawshaw8bff16a2025-04-18 01:16:49 -0700263 if err := w.Close(); err != nil {
264 return nil, err
265 }
266 if err := os.WriteFile(hashZip, buf.Bytes(), 0o666); err != nil {
267 return nil, err
268 }
269 return zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
Earl Lee2e463fb2025-04-17 11:22:22 -0700270}
271
Sean McCullough86b56862025-04-18 13:04:03 -0700272func esbuildBundle(outDir, src, metafilePath string) error {
273 args := []string{
Earl Lee2e463fb2025-04-17 11:22:22 -0700274 src,
275 "--bundle",
276 "--sourcemap",
277 "--log-level=error",
278 // Disable minification for now
279 // "--minify",
280 "--outdir=" + outDir,
Sean McCullough86b56862025-04-18 13:04:03 -0700281 }
282
283 // Add metafile option if path is provided
284 if metafilePath != "" {
285 args = append(args, "--metafile="+metafilePath)
286 }
287
288 ret := esbuildcli.Run(args)
Earl Lee2e463fb2025-04-17 11:22:22 -0700289 if ret != 0 {
290 return fmt.Errorf("esbuild %s failed: %d", filepath.Base(src), ret)
291 }
292 return nil
293}
Sean McCullough86b56862025-04-18 13:04:03 -0700294
295// unpackTS unpacks all the typescript-relevant files from the embedded filesystem into tmpDir.
296func unpackTS(outDir string, embedded fs.FS) error {
297 return fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error {
298 if err != nil {
299 return err
300 }
301 tgt := filepath.Join(outDir, path)
302 if d.IsDir() {
303 if err := os.MkdirAll(tgt, 0o777); err != nil {
304 return err
305 }
306 return nil
307 }
308 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".md") || strings.HasSuffix(path, ".css") {
309 return nil
310 }
311 data, err := fs.ReadFile(embedded, path)
312 if err != nil {
313 return err
314 }
315 if err := os.WriteFile(tgt, data, 0o666); err != nil {
316 return err
317 }
318 return nil
319 })
320}
321
322// GenerateBundleMetafile creates metafiles for bundle analysis with esbuild.
323//
324// The metafiles contain information about bundle size and dependencies
325// that can be visualized at https://esbuild.github.io/analyze/
326//
327// It takes the output directory where the metafiles will be written.
328// Returns the file path of the generated metafiles.
329func GenerateBundleMetafile(outputDir string) (string, error) {
330 tmpDir, err := os.MkdirTemp("", "bundle-analysis-")
331 if err != nil {
332 return "", err
333 }
334 defer os.RemoveAll(tmpDir)
335
336 // Create output directory if it doesn't exist
Philip Zeyligerd1402952025-04-23 03:54:37 +0000337 if err := os.MkdirAll(outputDir, 0o755); err != nil {
Sean McCullough86b56862025-04-18 13:04:03 -0700338 return "", err
339 }
340
341 cacheDir, _, err := zipPath()
342 if err != nil {
343 return "", err
344 }
345 buildDir := filepath.Join(cacheDir, "build")
346 if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
347 return "", err
348 }
349
350 // Ensure we have a source to bundle
351 if err := unpackTS(tmpDir, embedded); err != nil {
352 return "", err
353 }
354
355 // All bundles to analyze
356 bundleTs := []string{"src/web-components/sketch-app-shell.ts"}
357 metafiles := make([]string, len(bundleTs))
358
359 for i, tsName := range bundleTs {
360 // Create a metafile path for this bundle
361 baseFileName := filepath.Base(tsName)
362 metaFileName := strings.TrimSuffix(baseFileName, ".ts") + ".meta.json"
363 metafilePath := filepath.Join(outputDir, metaFileName)
364 metafiles[i] = metafilePath
365
366 // Bundle with metafile generation
367 outTmpDir, err := os.MkdirTemp("", "metafile-bundle-")
368 if err != nil {
369 return "", err
370 }
371 defer os.RemoveAll(outTmpDir)
372
373 if err := esbuildBundle(outTmpDir, filepath.Join(buildDir, tsName), metafilePath); err != nil {
374 return "", fmt.Errorf("failed to generate metafile for %s: %w", tsName, err)
375 }
376 }
377
378 return outputDir, nil
379}