evanw/esbuild · critical
Mutating "AbsWorkingDir" is not allowed
Error message
Mutating "AbsWorkingDir" is not allowed
What it means
A panic raised at the end of esbuild's build-context setup when, after running plugin setup (loadPlugins) and option validation (validateBuildOptions), the value of buildOpts.AbsWorkingDir differs from the snapshot taken before those calls. esbuild validates AbsWorkingDir against RealFS first, then forbids any plugin or callback from mutating it, because the file-system object is already built around that directory and a silent change would invalidate path resolution. So this panic indicates a plugin (or code calling esbuild's internal API) is rewriting options.AbsWorkingDir during setup.
Source
Thrown at pkg/api/api_impl.go:916
// for performance).
DoNotCache: true,
})
if err != nil {
log := logger.NewStderrLog(logOptions)
log.AddError(nil, logger.Range{}, err.Error())
return nil, convertMessagesToPublic(logger.Error, log.Done(), logOptions.PathStyle)
}
// Do not re-evaluate plugins when rebuilding. Also make sure the working
// directory doesn't change, since breaking that invariant would break the
// validation that we just did above.
caches := cache.MakeCacheSet()
log := logger.NewDeferLog(logger.DeferLogNoVerboseOrDebug, logOptions.Overrides)
onEndCallbacks, onDisposeCallbacks, finalizeBuildOptions := loadPlugins(&buildOpts, realFS, log, caches)
options, entryPoints := validateBuildOptions(buildOpts, log, realFS)
finalizeBuildOptions(&options)
if buildOpts.AbsWorkingDir != absWorkingDir {
panic("Mutating \"AbsWorkingDir\" is not allowed")
}
// If we have errors already, then refuse to build any further. This only
// happens when the build options themselves contain validation errors.
msgs := log.Done()
if log.HasErrors() {
if logOptions.LogLevel < logger.LevelSilent {
// Print all deferred validation log messages to stderr. We defer all log
// messages that are generated above because warnings are re-printed for
// every rebuild and we don't want to double-print these warnings for the
// first build.
stderr := logger.NewStderrLog(logOptions)
for _, msg := range msgs {
stderr.AddMsg(msg)
}
stderr.Done()
}
return nil, convertMessagesToPublic(logger.Error, msgs, options.LogPathStyle)View on GitHub (pinned to 6ff1d8b0d8)
Solutions
- Do not mutate AbsWorkingDir inside plugin setup callbacks — resolve it once before calling Build/Context and never touch it again.
- If a plugin needs the working directory, read it from the args passed to onResolve/onLoad rather than mutating options.
- Pass a fresh copy of BuildOptions (deep-copied if it shares slices/maps) to each Build call to prevent aliasing.
- Audit any wrapper around esbuild.Build that holds a pointer to options and confirm it does not write to AbsWorkingDir.
Example fix
// before (Go)
plugin := api.Plugin{
Name: "bad",
Setup: func(b api.PluginBuild) {
// mutating shared options during setup -> panic
*(*string)(unsafe.Pointer(&sharedOpts.AbsWorkingDir)) = "/new"
},
}
// after
// Resolve absWorkingDir ONCE before build and never mutate it after.
abs, _ := filepath.Abs("./src")
opts.AbsWorkingDir = abs
ctx, _ := api.Build(opts) // plugins may read, never write, AbsWorkingDir Defensive patterns
Strategy: validation
Validate before calling
// Treat AbsWorkingDir as immutable after the Build call begins.
// Resolve it once, freeze the options object, and pass the frozen copy.
import * as path from 'path'
function prepareOpts(raw) {
const opts = { ...raw, absWorkingDir: path.resolve(raw.absWorkingDir || process.cwd()) }
return Object.freeze(opts) // shallow freeze discourages mutation of top-level fields
}
const opts = prepareOpts(raw) Type guard
// Go: ensure no plugin hook is given a mutable pointer to BuildOptions.AbsWorkingDir.
// (Field is a value type string; the risk is sharing the parent struct pointer.)
func isImmutableAfterBuild(opts *api.BuildOptions, snapshot string) bool {
return opts.AbsWorkingDir == snapshot
} Try / catch
try {
const ctx = await esbuild.context(opts)
} catch (e) {
if (/Mutating .AbsWorkingDir. is not allowed/i.test(String(e?.message || e))) {
console.error('A plugin or wrapper mutated options.absWorkingDir during setup.')
}
throw e
} Prevention
- Resolve absWorkingDir once before Build and never write it again.
- Plugins should read the working directory from onResolve/onLoad args, not mutate options.
- Pass a fresh, deep-copied BuildOptions to each Build/Context call to prevent aliasing.
- Avoid sharing a *BuildOptions pointer with code that may edit it on rebuild.
When it happens
Trigger: A plugin's setup function (or a Go-side hook between loadPlugins and the post-check) mutates buildOpts.AbsWorkingDir — e.g. assigns to options.AbsWorkingDir, or shares a pointer to the options struct and edits it. Reproducible in the Go API where the BuildOptions struct is passed by value but its fields (slices/maps/pointers) can still alias the caller's memory. Not reachable from the JS API, which serialises options over the service protocol.
Common situations: Writing an esbuild Go plugin that 'helpfully' normalises the working directory in its setup; wrapping Build() and modifying the options struct after passing it in (race); caching a *BuildOptions pointer and editing AbsWorkingDir on rebuild; migrating from a version where this mutation was tolerated.
Related errors
AI-assisted analysis of evanw/esbuild@6ff1d8b0d8 (2026-08-03).
Data as JSON: /data/errors/7e3a5d6eec1a6386.json.
Report an issue: GitHub.