Stacks Plugins and the Preload Chain
Stacks has no @stacksjs/plugins package. "Plugins" here means Bun's native
plugin API plus the two modules bunfig.toml preloads before any Stacks code
runs. If you are chasing "why is User undefined in this command" or "why did
.env not decrypt", this is the skill.
Key Paths
- Preload declaration:
bunfig.toml(preload, at the root and under[test]) - Env plugin:
storage/framework/core/env/plugin.ts - Framework preloader:
storage/framework/defaults/resources/plugins/preloader.ts - Static-serve plugin:
bunfig.toml,[serve.static] plugins = ["bun-plugin-stx"]
The preload chain
bunfig.toml preloads two modules, in this order:
preload = [
"./storage/framework/core/env/plugin.ts",
"./storage/framework/defaults/resources/plugins/preloader.ts",
]
- Env plugin loads
.envand decrypts dotenvx-style encrypted values, so everything downstream reads plaintext fromprocess.env. - Preloader loads env files for the resolved environment, then injects the
framework's server auto-imports into
globalThis.
Order is load-bearing: the preloader reads decrypted secrets, so it has to run second.
Tests preload a different pair - the env plugin and tests/setup.ts - so the
suite gets production env semantics without the auto-import graph.
What the preloader does
1. Resolves the environment
Before loading any .env file it works out APP_ENV, because that decides
which file gets decrypted:
deploy staginganddeploy --stagingboth resolve tostaging. Both forms are handled; CI uses the flag form.cloud:remove,cloud:destroy,cloud:cleanup,undeployforceproduction.- Bare
deploydefaults toproduction.
2. Loads and decrypts env files
Gated on isRepl / isPostinstall only - never on the fast-command list
below. Fast commands (migrate, build, seed, ...) genuinely need decrypted
config; gating them here caused stacksjs/stacks#2048, where an encrypted
.env.<env> silently never decrypted even with the key present.
Postinstall skips because @stacksjs/env may not be linked yet mid-install.
3. Injects auto-imports into globalThis
Loads ~24 framework packages plus everything in app/Jobs/ and
resources/functions/, assigning every named export onto globalThis. That is
why await User.find(1) and response.json(...) work in an action with no
import statement. See stacks-auto-imports for the full manifest.
A protectedGlobals set guards the runtime: process, fetch, Promise,
Bun, console and friends are never overwritten, whatever a package exports.
The fast-command skip (the usual gotcha)
Auto-import injection is skipped when the first CLI argument matches:
dev build test lint migrate fresh seed generate make
key:generate scaffold:crud version help --version -v --help -h
Prefix matches count, so generate:migrations skips along with generate.
Two reasons: cold-start speed, and correctness. Codegen commands must not pull
the router and ORM graph in before bun-query-builder can diff schemas - a
broken @stacksjs/bun-router install used to make generate:migrations exit 1
with no output at all, because the preloader died loading @stacksjs/router.
Consequence: inside those commands, framework globals do not exist. Import what you need explicitly.
The REPL skips too (!process.argv[1]), as does anything running under
npm_lifecycle_event === 'postinstall'.
Opting back in
The preloader exports loadAutoImports() so a server entrypoint can pull the
graph in deliberately:
const { loadAutoImports } = await import('../../../defaults/resources/plugins/preloader.ts')
await loadAutoImports()
dev/api.ts does exactly this. Bun consumes --watch, so a directly-run server
script sees an empty argv.slice(2) and would otherwise be indistinguishable
from a bare invocation.
Writing a Bun plugin
Use Bun's native API directly - there is no framework wrapper to import:
import { plugin } from 'bun'
plugin({
name: 'my-loader',
setup(build) {
build.onLoad({ filter: /\.custom$/ }, async (args) => {
const contents = await Bun.file(args.path).text()
return { contents: transform(contents), loader: 'ts' }
})
},
})
Register it by adding the module to preload in bunfig.toml. A plugin only
affects the process that preloaded it.
Gotchas
- No
@stacksjs/pluginspackage. Importpluginfrombun. - Preload order matters. Env decryption has to precede the preloader.
- Fast commands have no globals. If a
make:*orgenerate:*command needs a framework symbol, import it - do not rely on auto-imports. bun-plugin-stxis a static-serve plugin, wired through[serve.static] pluginsinbunfig.toml. The preloader's ownimport 'bun-plugin-stx'line is commented out; stx compilation in the dev server runs through the stx package, not that import.- Preloader failures are silent per package. Each import sits in its own
try {} catch {}, so a package that fails to build leaves its globals undefined instead of crashing the process. A mysteriously undefined global is usually an unbuilt package - checkstorage/framework/core/<pkg>/dist. - Adding a global? Export it from a package already in the preloader's list,
or from
resources/functions/, then runbuddy generate --typesso the declarations match the runtime.