engine/hmr.go
Rama main · réplica de lectura del repositorio original.
package engine
// Hot Module Replacement (HMR) update endpoint.
// React-specific logic lives in react.go.
import (
"fmt"
"net/http"
"os"
"path/filepath"
"slices"
"strings"
"github.com/evanw/esbuild/pkg/api"
"github.com/sirupsen/logrus"
"github.com/xyproto/algernon/utils"
)
// hmrUpdatePrefix is the URL prefix for the HMR update endpoint
const hmrUpdatePrefix = "/@algernon/hmr/"
// hmrExtensions are the file extensions that the HMR endpoint may serve
var hmrExtensions = []string{".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"}
// servableByHMR reports whether the given file can be served by the HMR endpoint
func servableByHMR(filename string) bool {
ext := strings.ToLower(filepath.Ext(filename))
return slices.Contains(hmrExtensions, ext)
}
// HMRUpdateHandler serves a freshly compiled (never cached) version of a
// JSX or JS file so the browser can hot-swap it without reloading the page.
func (ac *Config) HMRUpdateHandler(w http.ResponseWriter, req *http.Request) {
relPath := strings.TrimPrefix(req.URL.Path, hmrUpdatePrefix)
if relPath == "" {
http.Error(w, "missing path", http.StatusBadRequest)
return
}
relPath = filepath.FromSlash(relPath)
if filepath.IsAbs(relPath) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// Canonicalize the server root.
serverRoot, err := filepath.Abs(ac.serverDirOrFilename)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if resolved, err := filepath.EvalSymlinks(serverRoot); err == nil {
serverRoot = resolved
}
// Containment check via filepath.Rel after normalization.
absPath := filepath.Clean(filepath.Join(serverRoot, relPath))
if !utils.WithinDir(serverRoot, absPath) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// Re-check after resolving symlinks inside the root.
if resolved, err := filepath.EvalSymlinks(absPath); err == nil {
if !utils.WithinDir(serverRoot, resolved) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
absPath = resolved
}
// Only script files can be hot-swapped. Serving anything else, like .env
// or .lua, would just be a way to read files.
if !servableByHMR(absPath) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
src, err := os.ReadFile(absPath)
if err != nil {
logrus.Errorf("hmr: cannot read %s: %s", absPath, err)
http.Error(w, "not found", http.StatusNotFound)
return
}
var data []byte
if needsBundling(src) {
data, err = bundleUncached(absPath, src, ac.jsxOptions, ac.autoRefresh)
if err != nil {
logrus.Errorf("hmr: compile error for %s: %s", absPath, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} else {
srcStr := string(src)
if ac.autoRefresh {
srcStr = injectRefreshRegistrations(srcStr, filepath.Base(absPath))
}
opts := ac.jsxOptions
opts.Loader = loaderForFile(absPath)
result := api.Transform(srcStr, opts)
if len(result.Errors) > 0 {
msgs := make([]string, len(result.Errors))
for i, e := range result.Errors {
msgs[i] = e.Text
}
http.Error(w, strings.Join(msgs, "\n"), http.StatusInternalServerError)
return
}
data = result.Code
}
w.Header().Set("Content-Type", "text/javascript;charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Write(data)
}
// bundleUncached runs esbuild on the given source without consulting the cache
func bundleUncached(filename string, srcData []byte, jsxOpts api.TransformOptions, withRefresh bool) ([]byte, error) {
dir := filepath.Dir(filename)
loader := loaderForFile(filename)
contents := string(srcData)
if withRefresh {
contents = injectRefreshRegistrations(contents, filepath.Base(filename))
}
opts := api.BuildOptions{
Bundle: true,
Platform: api.PlatformBrowser,
Format: api.FormatIIFE,
MinifyWhitespace: jsxOpts.MinifyWhitespace,
MinifyIdentifiers: jsxOpts.MinifyIdentifiers,
MinifySyntax: jsxOpts.MinifySyntax,
Charset: jsxOpts.Charset,
Write: false,
AbsWorkingDir: dir,
LogLevel: api.LogLevelSilent,
Stdin: &api.StdinOptions{
Contents: contents,
ResolveDir: dir,
Sourcefile: filename,
Loader: loader,
},
}
if withRefresh {
opts.Plugins = []api.Plugin{reactRefreshPlugin()}
}
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))
}
return result.OutputFiles[0].Contents, nil
}