package engine import ( "bytes" "fmt" "os" "path/filepath" "strings" "sync" "time" "github.com/evanw/esbuild/pkg/api" "github.com/sirupsen/logrus" "github.com/xyproto/algernon/cachemode" ) // bundleCacheEntry holds bundled output alongside the source file's modification time. type bundleCacheEntry struct { modTime time.Time data []byte } // bundleCache is an in-memory cache for esbuild-bundled JS/JSX/TS/TSX files, // keyed by absolute file path. Results are invalidated when the file's // modification time changes. type bundleCache struct { entries map[string]bundleCacheEntry hits map[string]uint64 mu sync.RWMutex } func newBundleCache() *bundleCache { return &bundleCache{entries: make(map[string]bundleCacheEntry), hits: make(map[string]uint64)} } // Clear clears all entries from the bundle cache. func (bc *bundleCache) Clear() { bc.mu.Lock() bc.entries = make(map[string]bundleCacheEntry) bc.hits = make(map[string]uint64) bc.mu.Unlock() } // needsBundling reports whether the JS/JSX/TS/TSX source requires full bundling, // i.e. it contains ES module import statements. func needsBundling(data []byte) bool { return bytes.Contains(data, []byte("import ")) || bytes.Contains(data, []byte("import(")) } // loaderForFile returns the appropriate esbuild loader based on file extension. func loaderForFile(filename string) api.Loader { switch { case strings.HasSuffix(strings.ToLower(filename), ".tsx"): return api.LoaderTSX case strings.HasSuffix(strings.ToLower(filename), ".ts"): return api.LoaderTS case strings.HasSuffix(strings.ToLower(filename), ".jsx"): return api.LoaderJSX default: return api.LoaderJS } } // minified) and caches the result in memory. Subsequent calls return the cached // output as long as the file's modification time has not changed. // // If srcData is non-nil it is passed directly to esbuild via stdin, avoiding a // second disk read when the caller has already loaded the source. The working // directory is always set to the file's parent directory so that esbuild can // resolve node_modules relative to the source file. // // When reactEntry is true, the react/react-dom globals shim plugin is enabled // and the cache entry is kept separate from non-reactEntry builds of the same // file, since the two produce different output. func (ac *Config) bundleFile(filename string, srcData []byte, reactEntry bool) ([]byte, error) { info, err := os.Stat(filename) if err != nil { return nil, err } modTime := info.ModTime() useCache := !ac.noCache && ac.cacheMode != cachemode.Off // Cache key must distinguish bundles produced with different plugin sets. // The null byte cannot appear in a filesystem path, so it is a safe separator. cacheKey := filename if reactEntry { cacheKey += "\x00reactEntry" } bc := ac.bundleCache if useCache { bc.mu.RLock() entry, ok := bc.entries[cacheKey] bc.mu.RUnlock() if ok && entry.modTime.Equal(modTime) { bc.mu.Lock() bc.hits[cacheKey]++ bc.mu.Unlock() return entry.data, nil } } dir := filepath.Dir(filename) if !filepath.IsAbs(dir) { if absDir, err := filepath.Abs(dir); err == nil { dir = absDir } } opts := api.BuildOptions{ Bundle: true, Platform: api.PlatformBrowser, Format: api.FormatIIFE, MinifyWhitespace: true, MinifyIdentifiers: true, MinifySyntax: true, Charset: api.CharsetUTF8, Write: false, AbsWorkingDir: dir, LogLevel: api.LogLevelSilent, } if srcData != nil { // Choose the loader based on file extension so JSX/TSX syntax is handled. loader := loaderForFile(filename) contents := string(srcData) if ac.autoRefresh { contents = injectRefreshRegistrations(contents, filepath.Base(filename)) } opts.Stdin = &api.StdinOptions{ Contents: contents, ResolveDir: dir, Sourcefile: filename, Loader: loader, } } else { opts.EntryPoints = []string{filename} } if ac.autoRefresh { opts.Plugins = []api.Plugin{reactRefreshPlugin()} } if reactEntry { opts.Plugins = append(opts.Plugins, reactGlobalsPlugin()) } result := api.Build(opts) if len(result.Errors) > 0 { msgs := make([]string, len(result.Errors)) for i, e := range result.Errors { msgs[i] = e.Text } return nil, fmt.Errorf("bundle %s: %s", filepath.Base(filename), strings.Join(msgs, "; ")) } if len(result.OutputFiles) == 0 { return nil, fmt.Errorf("bundle %s: no output produced", filepath.Base(filename)) } data := result.OutputFiles[0].Contents if useCache { bc.mu.Lock() if ac.cacheMaxEntitySize == 0 || uint64(len(data)) <= ac.cacheMaxEntitySize { if ac.bundleCacheMaxMemory == 0 || bc.BytesUsed()+uint64(len(data)) <= ac.bundleCacheMaxMemory { bc.entries[cacheKey] = bundleCacheEntry{modTime: modTime, data: data} bc.hits[cacheKey] = 0 logrus.Debugf("bundled and cached %s (%d bytes)", filepath.Base(filename), len(data)) } else { for bc.BytesUsed()+uint64(len(data)) > ac.bundleCacheMaxMemory { if !bc.evictLocked() { break } } if bc.BytesUsed()+uint64(len(data)) <= ac.bundleCacheMaxMemory { bc.entries[filename] = bundleCacheEntry{modTime: modTime, data: data} bc.hits[filename] = 0 logrus.Debugf("bundled and cached %s (%d bytes)", filepath.Base(filename), len(data)) } } } bc.mu.Unlock() } return data, nil } // BytesUsed returns the total bytes used by all entries in the bundle cache. func (bc *bundleCache) BytesUsed() uint64 { var total uint64 for _, entry := range bc.entries { total += uint64(len(entry.data)) } return total } // evictLocked removes the least-popular entry. Must be called with bc.mu held. func (bc *bundleCache) evictLocked() bool { if len(bc.entries) == 0 { return false } var targetKey string var minHits = ^uint64(0) var maxSize uint64 for key, entry := range bc.entries { hits := bc.hits[key] size := uint64(len(entry.data)) if hits < minHits || (hits == minHits && size > maxSize) { targetKey = key minHits = hits maxSize = size } } if targetKey != "" { delete(bc.entries, targetKey) delete(bc.hits, targetKey) return true } return false } // EvictLargest removes the least-popular entry from the bundle cache. func (bc *bundleCache) EvictLargest() bool { bc.mu.Lock() defer bc.mu.Unlock() return bc.evictLocked() }