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