Context
On a scaffolded project with the default strict CSP (script-src 'nonce-…' 'strict-dynamic', no unsafe-eval), every hx-on / hx-vals js: handler is silently broken — htmx evaluates them with eval()/new Function(), which the CSP blocks. The symptom in our app was a modal trigger (hx-on:htmx:after:swap="…showModal()") that swapped its content in but never ran, so a button appeared to "do nothing."
The fix is the documented hx-csp extension + htmx.config.safeEval = true. Adopting it worked, but hit several rough edges worth smoothing in the framework.
Friction points
1. hx-csp is not re-exported from @alltuner/vibetuner
@alltuner/vibetuner re-exports htmx/preload, htmx/sse, htmx/live, but not htmx/csp. Because htmx.org is a private transitive under bun's isolated linker, import "htmx.org/dist/ext/hx-csp.js" does not resolve from a project's config.js. We had to add htmx.org as a direct devDependency (pinned to the framework's exact 4.0.0-beta4) just to import the extension — which creates a version-drift footgun the moment the framework bumps htmx.
Ask: add "./htmx/csp": "./htmx-csp.js" to the package exports (mirroring htmx/live) so projects can import "@alltuner/vibetuner/htmx/csp" with no direct htmx.org dep.
2. The docs' import path assumes a hoisted layout
The development guide says to add import "./node_modules/htmx.org/dist/ext/hx-csp.js";. With bun's isolated installs htmx.org is not at top-level node_modules, so that path doesn't exist. Worth fixing the doc to use the re-export from (1).
3. Enabling safeEval requires careful import ordering
htmx.config.safeEval must be set before the extension's init runs, but ESM hoists imports, so a plain htmx.config.safeEval = true statement in config.js runs after the extension import. We worked around it with a tiny separate module imported before the extension. A documented one-liner (e.g. a meta[name=htmx-config] snippet in the skeleton, or the re-export reading config) would avoid the gotcha.
4. Fail-closed nonce gate vs. the <body> tag
The extension is fail-closed: every htmx element needs a matching hx-nonce. The pragmatic single-stamp approach is hx-nonce:inherited="{{ csp_nonce }}" on <body> (everything inherits). But <body> lives in the core skeleton with no attribute hook, forcing a full skeleton override just to add one attribute.
Ask: when CSP is configured, have the framework either (a) stamp hx-nonce:inherited on <body> itself, or (b) expose a body_attrs block/variable on the skeleton so projects can add it without forking the whole template.
Environment
- vibetuner 10.18.0, htmx.org 4.0.0-beta4, bun isolated linker.
Filed by Claude Code.
Context
On a scaffolded project with the default strict CSP (
script-src 'nonce-…' 'strict-dynamic', nounsafe-eval), everyhx-on/hx-vals js:handler is silently broken — htmx evaluates them witheval()/new Function(), which the CSP blocks. The symptom in our app was a modal trigger (hx-on:htmx:after:swap="…showModal()") that swapped its content in but never ran, so a button appeared to "do nothing."The fix is the documented
hx-cspextension +htmx.config.safeEval = true. Adopting it worked, but hit several rough edges worth smoothing in the framework.Friction points
1.
hx-cspis not re-exported from@alltuner/vibetuner@alltuner/vibetunerre-exportshtmx/preload,htmx/sse,htmx/live, but nothtmx/csp. Becausehtmx.orgis a private transitive under bun's isolated linker,import "htmx.org/dist/ext/hx-csp.js"does not resolve from a project'sconfig.js. We had to addhtmx.orgas a direct devDependency (pinned to the framework's exact4.0.0-beta4) just to import the extension — which creates a version-drift footgun the moment the framework bumps htmx.Ask: add
"./htmx/csp": "./htmx-csp.js"to the packageexports(mirroringhtmx/live) so projects canimport "@alltuner/vibetuner/htmx/csp"with no direct htmx.org dep.2. The docs' import path assumes a hoisted layout
The development guide says to add
import "./node_modules/htmx.org/dist/ext/hx-csp.js";. With bun's isolated installshtmx.orgis not at top-levelnode_modules, so that path doesn't exist. Worth fixing the doc to use the re-export from (1).3. Enabling
safeEvalrequires careful import orderinghtmx.config.safeEvalmust be set before the extension'sinitruns, but ESM hoists imports, so a plainhtmx.config.safeEval = truestatement inconfig.jsruns after the extension import. We worked around it with a tiny separate module imported before the extension. A documented one-liner (e.g. ameta[name=htmx-config]snippet in the skeleton, or the re-export reading config) would avoid the gotcha.4. Fail-closed nonce gate vs. the
<body>tagThe extension is fail-closed: every htmx element needs a matching
hx-nonce. The pragmatic single-stamp approach ishx-nonce:inherited="{{ csp_nonce }}"on<body>(everything inherits). But<body>lives in the core skeleton with no attribute hook, forcing a full skeleton override just to add one attribute.Ask: when CSP is configured, have the framework either (a) stamp
hx-nonce:inheritedon<body>itself, or (b) expose abody_attrsblock/variable on the skeleton so projects can add it without forking the whole template.Environment
Filed by Claude Code.