blob: d4f7858755eb0381d31508ea4cbd753ed2830d19 [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
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700124// copyMonacoAssets copies Monaco editor assets to the output directory
125func copyMonacoAssets(buildDir, outDir string) error {
126 // Create Monaco directories
127 monacoEditorDir := filepath.Join(outDir, "monaco", "min", "vs", "editor")
128 codiconDir := filepath.Join(outDir, "monaco", "min", "vs", "base", "browser", "ui", "codicons", "codicon")
129
130 if err := os.MkdirAll(monacoEditorDir, 0o777); err != nil {
131 return fmt.Errorf("failed to create monaco editor directory: %w", err)
132 }
133
134 if err := os.MkdirAll(codiconDir, 0o777); err != nil {
135 return fmt.Errorf("failed to create codicon directory: %w", err)
136 }
137
138 // Copy Monaco editor CSS
139 editorCssPath := "node_modules/monaco-editor/min/vs/editor/editor.main.css"
140 editorCss, err := os.ReadFile(filepath.Join(buildDir, editorCssPath))
141 if err != nil {
142 return fmt.Errorf("failed to read monaco editor CSS: %w", err)
143 }
144
145 if err := os.WriteFile(filepath.Join(monacoEditorDir, "editor.main.css"), editorCss, 0o666); err != nil {
146 return fmt.Errorf("failed to write monaco editor CSS: %w", err)
147 }
148
149 // Copy Codicon font
150 codiconFontPath := "node_modules/monaco-editor/min/vs/base/browser/ui/codicons/codicon/codicon.ttf"
151 codiconFont, err := os.ReadFile(filepath.Join(buildDir, codiconFontPath))
152 if err != nil {
153 return fmt.Errorf("failed to read codicon font: %w", err)
154 }
155
156 if err := os.WriteFile(filepath.Join(codiconDir, "codicon.ttf"), codiconFont, 0o666); err != nil {
157 return fmt.Errorf("failed to write codicon font: %w", err)
158 }
159
160 return nil
161}
162
Earl Lee2e463fb2025-04-17 11:22:22 -0700163// Build unpacks and esbuild's all bundleTs typescript files
164func Build() (fs.FS, error) {
David Crawshaw8bff16a2025-04-18 01:16:49 -0700165 cacheDir, hashZip, err := zipPath()
Earl Lee2e463fb2025-04-17 11:22:22 -0700166 if err != nil {
167 return nil, err
168 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700169 buildDir := filepath.Join(cacheDir, "build")
170 if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
171 return nil, err
172 }
David Crawshaw8bff16a2025-04-18 01:16:49 -0700173 if b, err := os.ReadFile(hashZip); err == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700174 // Build already done, serve it out.
David Crawshaw8bff16a2025-04-18 01:16:49 -0700175 return zip.NewReader(bytes.NewReader(b), int64(len(b)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700176 }
177
David Crawshaw8bff16a2025-04-18 01:16:49 -0700178 // TODO: try downloading "https://sketch.dev/webui/"+filepath.Base(hashZip)
179
Earl Lee2e463fb2025-04-17 11:22:22 -0700180 // We need to do a build.
181
182 // Clear everything out of the build directory except node_modules.
183 if err := cleanBuildDir(buildDir); err != nil {
184 return nil, err
185 }
186 tmpHashDir := filepath.Join(buildDir, "out")
187 if err := os.Mkdir(tmpHashDir, 0o777); err != nil {
188 return nil, err
189 }
190
191 // Unpack everything from embedded into build dir.
192 if err := unpackFS(buildDir, embedded); err != nil {
193 return nil, err
194 }
195
Sean McCullough86b56862025-04-18 13:04:03 -0700196 // Do the build. Don't install dev dependencies, because they can be large
197 // and slow enough to install that the /init requests from the host process
198 // will run out of retries and the whole thing exits. We do need better health
199 // checking in general, but that's a separate issue. Don't do slow stuff here:
200 cmd := exec.Command("npm", "ci", "--omit", "dev")
Earl Lee2e463fb2025-04-17 11:22:22 -0700201 cmd.Dir = buildDir
202 if out, err := cmd.CombinedOutput(); err != nil {
203 return nil, fmt.Errorf("npm ci: %s: %v", out, err)
204 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700205 bundleTs := []string{
206 "src/web-components/sketch-app-shell.ts",
207 "src/web-components/sketch-monaco-view.ts",
208 "node_modules/monaco-editor/esm/vs/editor/editor.worker.js",
209 "node_modules/monaco-editor/esm/vs/language/typescript/ts.worker.js",
210 "node_modules/monaco-editor/esm/vs/language/html/html.worker.js",
211 "node_modules/monaco-editor/esm/vs/language/css/css.worker.js",
212 "node_modules/monaco-editor/esm/vs/language/json/json.worker.js",
213 }
214
Earl Lee2e463fb2025-04-17 11:22:22 -0700215 for _, tsName := range bundleTs {
Sean McCullough86b56862025-04-18 13:04:03 -0700216 if err := esbuildBundle(tmpHashDir, filepath.Join(buildDir, tsName), ""); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700217 return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
218 }
219 }
220
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700221 // Copy Monaco editor assets
222 if err := copyMonacoAssets(buildDir, tmpHashDir); err != nil {
223 return nil, fmt.Errorf("failed to copy Monaco assets: %w", err)
224 }
225
Earl Lee2e463fb2025-04-17 11:22:22 -0700226 // Copy src files used directly into the new hash output dir.
227 err = fs.WalkDir(embedded, "src", func(path string, d fs.DirEntry, err error) error {
228 if d.IsDir() {
Sean McCullough86b56862025-04-18 13:04:03 -0700229 if path == "src/web-components/demo" {
230 return fs.SkipDir
231 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700232 return nil
233 }
Pokey Rule8cac59a2025-04-24 12:21:19 +0100234 if strings.HasSuffix(path, "mockServiceWorker.js") {
235 return nil
236 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700237 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".css") || strings.HasSuffix(path, ".js") {
238 b, err := embedded.ReadFile(path)
239 if err != nil {
240 return err
241 }
242 dstPath := filepath.Join(tmpHashDir, strings.TrimPrefix(path, "src/"))
243 if err := os.WriteFile(dstPath, b, 0o777); err != nil {
244 return err
245 }
246 return nil
247 }
248 return nil
249 })
250 if err != nil {
251 return nil, err
252 }
253
254 // Copy xterm.css from node_modules
255 const xtermCssPath = "node_modules/@xterm/xterm/css/xterm.css"
256 xtermCss, err := os.ReadFile(filepath.Join(buildDir, xtermCssPath))
257 if err != nil {
258 return nil, fmt.Errorf("failed to read xterm.css: %w", err)
259 }
260 if err := os.WriteFile(filepath.Join(tmpHashDir, "xterm.css"), xtermCss, 0o666); err != nil {
261 return nil, fmt.Errorf("failed to write xterm.css: %w", err)
262 }
263
Philip Zeyliger176de792025-04-21 12:25:18 -0700264 // Compress all .js, .js.map, and .css files with gzip, leaving the originals in place
265 err = filepath.Walk(tmpHashDir, func(path string, info os.FileInfo, err error) error {
266 if err != nil {
267 return err
268 }
269 if info.IsDir() {
270 return nil
271 }
272 // Check if file is a .js or .js.map file
273 if !strings.HasSuffix(path, ".js") && !strings.HasSuffix(path, ".js.map") && !strings.HasSuffix(path, ".css") {
274 return nil
275 }
276
277 // Read the original file
278 origData, err := os.ReadFile(path)
279 if err != nil {
280 return fmt.Errorf("failed to read file %s: %w", path, err)
281 }
282
283 // Create a gzipped file
284 gzipPath := path + ".gz"
285 gzipFile, err := os.Create(gzipPath)
286 if err != nil {
287 return fmt.Errorf("failed to create gzip file %s: %w", gzipPath, err)
288 }
289 defer gzipFile.Close()
290
291 // Create a gzip writer
292 gzWriter := gzip.NewWriter(gzipFile)
293 defer gzWriter.Close()
294
295 // Write the original file content to the gzip writer
296 _, err = gzWriter.Write(origData)
297 if err != nil {
298 return fmt.Errorf("failed to write to gzip file %s: %w", gzipPath, err)
299 }
300
301 // Ensure we flush and close properly
302 if err := gzWriter.Close(); err != nil {
303 return fmt.Errorf("failed to close gzip writer for %s: %w", gzipPath, err)
304 }
305 if err := gzipFile.Close(); err != nil {
306 return fmt.Errorf("failed to close gzip file %s: %w", gzipPath, err)
307 }
308
309 return nil
310 })
311 if err != nil {
312 return nil, fmt.Errorf("failed to compress .js/.js.map/.css files: %w", err)
313 }
314
David Crawshaw8bff16a2025-04-18 01:16:49 -0700315 // Everything succeeded, so we write tmpHashDir to hashZip
316 buf := new(bytes.Buffer)
317 w := zip.NewWriter(buf)
318 if err := w.AddFS(os.DirFS(tmpHashDir)); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700319 return nil, err
320 }
David Crawshaw8bff16a2025-04-18 01:16:49 -0700321 if err := w.Close(); err != nil {
322 return nil, err
323 }
324 if err := os.WriteFile(hashZip, buf.Bytes(), 0o666); err != nil {
325 return nil, err
326 }
327 return zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
Earl Lee2e463fb2025-04-17 11:22:22 -0700328}
329
Sean McCullough86b56862025-04-18 13:04:03 -0700330func esbuildBundle(outDir, src, metafilePath string) error {
331 args := []string{
Earl Lee2e463fb2025-04-17 11:22:22 -0700332 src,
333 "--bundle",
334 "--sourcemap",
335 "--log-level=error",
336 // Disable minification for now
337 // "--minify",
338 "--outdir=" + outDir,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700339 "--loader:.ttf=file",
340 "--loader:.eot=file",
341 "--loader:.woff=file",
342 "--loader:.woff2=file",
343 // This changes where the sourcemap points to; we need relative dirs if we're proxied into a subdirectory.
344 "--public-path=.",
Sean McCullough86b56862025-04-18 13:04:03 -0700345 }
346
347 // Add metafile option if path is provided
348 if metafilePath != "" {
349 args = append(args, "--metafile="+metafilePath)
350 }
351
352 ret := esbuildcli.Run(args)
Earl Lee2e463fb2025-04-17 11:22:22 -0700353 if ret != 0 {
354 return fmt.Errorf("esbuild %s failed: %d", filepath.Base(src), ret)
355 }
356 return nil
357}
Sean McCullough86b56862025-04-18 13:04:03 -0700358
359// unpackTS unpacks all the typescript-relevant files from the embedded filesystem into tmpDir.
360func unpackTS(outDir string, embedded fs.FS) error {
361 return fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error {
362 if err != nil {
363 return err
364 }
365 tgt := filepath.Join(outDir, path)
366 if d.IsDir() {
367 if err := os.MkdirAll(tgt, 0o777); err != nil {
368 return err
369 }
370 return nil
371 }
372 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".md") || strings.HasSuffix(path, ".css") {
373 return nil
374 }
375 data, err := fs.ReadFile(embedded, path)
376 if err != nil {
377 return err
378 }
379 if err := os.WriteFile(tgt, data, 0o666); err != nil {
380 return err
381 }
382 return nil
383 })
384}
385
386// GenerateBundleMetafile creates metafiles for bundle analysis with esbuild.
387//
388// The metafiles contain information about bundle size and dependencies
389// that can be visualized at https://esbuild.github.io/analyze/
390//
391// It takes the output directory where the metafiles will be written.
392// Returns the file path of the generated metafiles.
393func GenerateBundleMetafile(outputDir string) (string, error) {
394 tmpDir, err := os.MkdirTemp("", "bundle-analysis-")
395 if err != nil {
396 return "", err
397 }
398 defer os.RemoveAll(tmpDir)
399
400 // Create output directory if it doesn't exist
Philip Zeyligerd1402952025-04-23 03:54:37 +0000401 if err := os.MkdirAll(outputDir, 0o755); err != nil {
Sean McCullough86b56862025-04-18 13:04:03 -0700402 return "", err
403 }
404
405 cacheDir, _, err := zipPath()
406 if err != nil {
407 return "", err
408 }
409 buildDir := filepath.Join(cacheDir, "build")
410 if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
411 return "", err
412 }
413
414 // Ensure we have a source to bundle
415 if err := unpackTS(tmpDir, embedded); err != nil {
416 return "", err
417 }
418
419 // All bundles to analyze
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700420 bundleTs := []string{
421 "src/web-components/sketch-app-shell.ts",
422 "src/web-components/sketch-monaco-view.ts",
423 }
Sean McCullough86b56862025-04-18 13:04:03 -0700424 metafiles := make([]string, len(bundleTs))
425
426 for i, tsName := range bundleTs {
427 // Create a metafile path for this bundle
428 baseFileName := filepath.Base(tsName)
429 metaFileName := strings.TrimSuffix(baseFileName, ".ts") + ".meta.json"
430 metafilePath := filepath.Join(outputDir, metaFileName)
431 metafiles[i] = metafilePath
432
433 // Bundle with metafile generation
434 outTmpDir, err := os.MkdirTemp("", "metafile-bundle-")
435 if err != nil {
436 return "", err
437 }
438 defer os.RemoveAll(outTmpDir)
439
440 if err := esbuildBundle(outTmpDir, filepath.Join(buildDir, tsName), metafilePath); err != nil {
441 return "", fmt.Errorf("failed to generate metafile for %s: %w", tsName, err)
442 }
443 }
444
445 return outputDir, nil
446}