Skip to content

Make strict-CSP hx-csp adoption ergonomic: re-export the extension + a body-nonce hook #1938

@davidpoblador

Description

@davidpoblador

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions