blob: 8f1fcfe0458b905e19918444d36d7f4625b30757 [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 }
Pokey Rule8cac59a2025-04-24 12:21:19 +0100179 if strings.HasSuffix(path, "mockServiceWorker.js") {
180 return nil
181 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700182 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".css") || strings.HasSuffix(path, ".js") {
183 b, err := embedded.ReadFile(path)
184 if err != nil {
185 return err
186 }
187 dstPath := filepath.Join(tmpHashDir, strings.TrimPrefix(path, "src/"))
188 if err := os.WriteFile(dstPath, b, 0o777); err != nil {
189 return err
190 }
191 return nil
192 }
193 return nil
194 })
195 if err != nil {
196 return nil, err
197 }
198
199 // Copy xterm.css from node_modules
200 const xtermCssPath = "node_modules/@xterm/xterm/css/xterm.css"
201 xtermCss, err := os.ReadFile(filepath.Join(buildDir, xtermCssPath))
202 if err != nil {
203 return nil, fmt.Errorf("failed to read xterm.css: %w", err)
204 }
205 if err := os.WriteFile(filepath.Join(tmpHashDir, "xterm.css"), xtermCss, 0o666); err != nil {
206 return nil, fmt.Errorf("failed to write xterm.css: %w", err)
207 }
208
Philip Zeyliger176de792025-04-21 12:25:18 -0700209 // Compress all .js, .js.map, and .css files with gzip, leaving the originals in place
210 err = filepath.Walk(tmpHashDir, func(path string, info os.FileInfo, err error) error {
211 if err != nil {
212 return err
213 }
214 if info.IsDir() {
215 return nil
216 }
217 // Check if file is a .js or .js.map file
218 if !strings.HasSuffix(path, ".js") && !strings.HasSuffix(path, ".js.map") && !strings.HasSuffix(path, ".css") {
219 return nil
220 }
221
222 // Read the original file
223 origData, err := os.ReadFile(path)
224 if err != nil {
225 return fmt.Errorf("failed to read file %s: %w", path, err)
226 }
227
228 // Create a gzipped file
229 gzipPath := path + ".gz"
230 gzipFile, err := os.Create(gzipPath)
231 if err != nil {
232 return fmt.Errorf("failed to create gzip file %s: %w", gzipPath, err)
233 }
234 defer gzipFile.Close()
235
236 // Create a gzip writer
237 gzWriter := gzip.NewWriter(gzipFile)
238 defer gzWriter.Close()
239
240 // Write the original file content to the gzip writer
241 _, err = gzWriter.Write(origData)
242 if err != nil {
243 return fmt.Errorf("failed to write to gzip file %s: %w", gzipPath, err)
244 }
245
246 // Ensure we flush and close properly
247 if err := gzWriter.Close(); err != nil {
248 return fmt.Errorf("failed to close gzip writer for %s: %w", gzipPath, err)
249 }
250 if err := gzipFile.Close(); err != nil {
251 return fmt.Errorf("failed to close gzip file %s: %w", gzipPath, err)
252 }
253
254 return nil
255 })
256 if err != nil {
257 return nil, fmt.Errorf("failed to compress .js/.js.map/.css files: %w", err)
258 }
259
David Crawshaw8bff16a2025-04-18 01:16:49 -0700260 // Everything succeeded, so we write tmpHashDir to hashZip
261 buf := new(bytes.Buffer)
262 w := zip.NewWriter(buf)
263 if err := w.AddFS(os.DirFS(tmpHashDir)); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700264 return nil, err
265 }
David Crawshaw8bff16a2025-04-18 01:16:49 -0700266 if err := w.Close(); err != nil {
267 return nil, err
268 }
269 if err := os.WriteFile(hashZip, buf.Bytes(), 0o666); err != nil {
270 return nil, err
271 }
272 return zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
Earl Lee2e463fb2025-04-17 11:22:22 -0700273}
274
Sean McCullough86b56862025-04-18 13:04:03 -0700275func esbuildBundle(outDir, src, metafilePath string) error {
276 args := []string{
Earl Lee2e463fb2025-04-17 11:22:22 -0700277 src,
278 "--bundle",
279 "--sourcemap",
280 "--log-level=error",
281 // Disable minification for now
282 // "--minify",
283 "--outdir=" + outDir,
Sean McCullough86b56862025-04-18 13:04:03 -0700284 }
285
286 // Add metafile option if path is provided
287 if metafilePath != "" {
288 args = append(args, "--metafile="+metafilePath)
289 }
290
291 ret := esbuildcli.Run(args)
Earl Lee2e463fb2025-04-17 11:22:22 -0700292 if ret != 0 {
293 return fmt.Errorf("esbuild %s failed: %d", filepath.Base(src), ret)
294 }
295 return nil
296}
Sean McCullough86b56862025-04-18 13:04:03 -0700297
298// unpackTS unpacks all the typescript-relevant files from the embedded filesystem into tmpDir.
299func unpackTS(outDir string, embedded fs.FS) error {
300 return fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error {
301 if err != nil {
302 return err
303 }
304 tgt := filepath.Join(outDir, path)
305 if d.IsDir() {
306 if err := os.MkdirAll(tgt, 0o777); err != nil {
307 return err
308 }
309 return nil
310 }
311 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".md") || strings.HasSuffix(path, ".css") {
312 return nil
313 }
314 data, err := fs.ReadFile(embedded, path)
315 if err != nil {
316 return err
317 }
318 if err := os.WriteFile(tgt, data, 0o666); err != nil {
319 return err
320 }
321 return nil
322 })
323}
324
325// GenerateBundleMetafile creates metafiles for bundle analysis with esbuild.
326//
327// The metafiles contain information about bundle size and dependencies
328// that can be visualized at https://esbuild.github.io/analyze/
329//
330// It takes the output directory where the metafiles will be written.
331// Returns the file path of the generated metafiles.
332func GenerateBundleMetafile(outputDir string) (string, error) {
333 tmpDir, err := os.MkdirTemp("", "bundle-analysis-")
334 if err != nil {
335 return "", err
336 }
337 defer os.RemoveAll(tmpDir)
338
339 // Create output directory if it doesn't exist
Philip Zeyligerd1402952025-04-23 03:54:37 +0000340 if err := os.MkdirAll(outputDir, 0o755); err != nil {
Sean McCullough86b56862025-04-18 13:04:03 -0700341 return "", err
342 }
343
344 cacheDir, _, err := zipPath()
345 if err != nil {
346 return "", err
347 }
348 buildDir := filepath.Join(cacheDir, "build")
349 if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
350 return "", err
351 }
352
353 // Ensure we have a source to bundle
354 if err := unpackTS(tmpDir, embedded); err != nil {
355 return "", err
356 }
357
358 // All bundles to analyze
359 bundleTs := []string{"src/web-components/sketch-app-shell.ts"}
360 metafiles := make([]string, len(bundleTs))
361
362 for i, tsName := range bundleTs {
363 // Create a metafile path for this bundle
364 baseFileName := filepath.Base(tsName)
365 metaFileName := strings.TrimSuffix(baseFileName, ".ts") + ".meta.json"
366 metafilePath := filepath.Join(outputDir, metaFileName)
367 metafiles[i] = metafilePath
368
369 // Bundle with metafile generation
370 outTmpDir, err := os.MkdirTemp("", "metafile-bundle-")
371 if err != nil {
372 return "", err
373 }
374 defer os.RemoveAll(outTmpDir)
375
376 if err := esbuildBundle(outTmpDir, filepath.Join(buildDir, tsName), metafilePath); err != nil {
377 return "", fmt.Errorf("failed to generate metafile for %s: %w", tsName, err)
378 }
379 }
380
381 return outputDir, nil
382}