engine/jsxcache.go
Rama main · réplica de lectura del repositorio original.
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()
}