@bgunnarsson/binmx
v0.1.0
Published
Hypermedia-driven HTML attributes without the eval. A security-first htmx-compatible core: bin-get/post/put/patch/delete, swapping, out-of-band swaps, history — no JavaScript in your markup.
Maintainers
Readme
binmx
Hypermedia-driven HTML attributes, without the eval.
binmx is an htmx-shaped library: you put bin-get / bin-post / bin-put /
bin-patch / bin-delete on an element, the server answers with HTML, and
binmx swaps that HTML into the page. Targets, swap styles, out-of-band swaps,
triggers, indicators, history — the whole model is here.
What is not here is every htmx feature whose implementation requires turning a
string from your markup into executable code. There is no hx-on:* equivalent,
no js: prefix on values or headers, and no JavaScript expressions in trigger
filters. The bundle contains no eval, no new Function, and
a test asserts it stays that way.
<script src="binmx.min.js"></script>
<button bin-post="/clicked" bin-target="#result" bin-swap="innerHTML">
Click me
</button>
<div id="result"></div>16 KB min+gzip, no dependencies.
Contents
- Why
- Install
- Attribute reference
- Triggers
- Trigger filters
- Swapping
- Out-of-band swaps
- Parameters
- Request and response headers
- Events
- History
- JavaScript API
- Configuration
- Differences from htmx
- Development
Why
htmx's core idea — the server sends HTML, the client swaps it — needs no code
evaluation at all. But htmx also ships features that do: hx-on:click="..."
compiles an attribute into a function, hx-vals="js:{...}" evaluates an
expression, and hx-trigger="click[expr]" compiles the filter with
new Function. Those features are why an htmx page cannot run under a strict
Content-Security-Policy, and they turn any attribute-injection bug into
straightforward code execution.
binmx keeps the first idea and drops the second. The escape hatches that remain are ordinary JavaScript in ordinary script files, where your CSP, your bundler and your reviewers can all see them.
On top of that, swapped-in HTML is sanitized by default and requests are same-origin by default. See SECURITY.md for the full threat model and the list of what is stripped.
Install
npm install binmximport binmx from 'binmx'Or drop in the browser build, which self-initializes on DOMContentLoaded and
exposes a global binmx:
<script src="node_modules/binmx/dist/binmx.min.js"></script>Both bin-get and data-bin-get spellings work everywhere.
Attribute reference
Requests
| Attribute | Description |
| --- | --- |
| bin-get="/path" | Issue a GET when triggered |
| bin-post="/path" | Issue a POST |
| bin-put="/path" | Issue a PUT |
| bin-patch="/path" | Issue a PATCH |
| bin-delete="/path" | Issue a DELETE |
| bin-trigger="..." | What causes the request — see Triggers |
| bin-target="..." | Where the response goes (default: the element itself) |
| bin-swap="..." | How the response is applied (default: innerHTML) |
| bin-select="css" | Swap only the part of the response matching this selector |
| bin-select-oob="css" | Additionally route matching parts to their own targets |
| bin-confirm="text" | Ask for confirmation before sending |
| bin-prompt="text" | Prompt for a value, sent as the BIN-Prompt header |
| bin-indicator="css" | Element(s) to mark busy during the request |
| bin-disabled-elt="css" | Element(s) to disable during the request |
| bin-sync="css:strategy" | Coordinate requests that would otherwise race |
| bin-boost="true" | AJAX-ify descendant links and forms |
| bin-encoding="multipart/form-data" | Force a multipart body |
| bin-request='{"timeout":5000}' | Per-element timeout / credentials / noHeaders |
| bin-ext="name" | Enable a registered extension for this subtree |
| bin-disable | binmx ignores this element and its descendants entirely |
Parameters
| Attribute | Description |
| --- | --- |
| bin-vals='{"a":1}' | Extra parameters. JSON only — no js: |
| bin-headers='{"X-A":"b"}' | Extra headers. JSON only — no js: |
| bin-include="css" | Include values from other elements |
| bin-params="not a,b" | Filter parameters: *, none, not a,b, or a,b |
| bin-validate="true" | Run HTML5 validation before sending |
Swapping and history
| Attribute | Description |
| --- | --- |
| bin-swap-oob="true" | (in a response) Swap this element by id, out of band |
| bin-preserve="true" | Keep this element across swaps, with its state |
| bin-push-url="true" | Push the request URL into browser history |
| bin-replace-url="true" | Replace the current history entry |
| bin-history="false" | Never write this page's content to the history cache |
| bin-history-elt | Which element represents "the page" for history |
Inheritance
Most attributes are inherited by descendants — bin-target on a container
applies to every element inside it. Not inherited: the verb attributes,
bin-trigger, bin-swap-oob and bin-preserve.
| Attribute | Description |
| --- | --- |
| bin-disinherit="bin-target" | Stop descendants inheriting these attributes (* for all) |
| bin-inherit="bin-target" | Re-enable inheritance blocked by a broader bin-disinherit |
Extended selectors
Anywhere a selector is accepted (bin-target, bin-include, bin-indicator,
bin-sync, trigger from: / target:):
this the element itself
closest <selector> nearest matching ancestor
find <selector> first matching descendant
next / next <sel> next match in document order
previous / prev <sel> previous match in document order
body | document | window
<any css selector> queried against the documentTriggers
<input bin-get="/search" bin-trigger="input changed delay:300ms">
<div bin-get="/rows" bin-trigger="load">
<div bin-get="/status" bin-trigger="every 2s">
<div bin-get="/more" bin-trigger="revealed">Defaults when bin-trigger is absent: submit for forms, change for inputs,
selects and textareas, click for everything else.
Special triggers
| Trigger | Fires |
| --- | --- |
| load | Once, when binmx processes the element |
| revealed | The first time the element scrolls into view |
| intersect | Whenever it intersects — supports root: and threshold: |
| every <time> | On an interval, until the element leaves the DOM |
Modifiers
| Modifier | Effect |
| --- | --- |
| once | Fire at most one request |
| changed | Only when the element's value differs from last time |
| delay:<time> | Debounce — reset the timer on each event |
| throttle:<time> | Rate-limit — ignore events inside the window |
| from:<selector> | Listen on another element |
| target:<selector> | Only when the event's target matches |
| consume | Stop the event propagating |
| queue:<first\|last\|all\|none> | Queueing behaviour |
| root:<selector>, threshold:<n> | For intersect |
Times are 500ms, 2s, 1m, or a bare number of milliseconds.
Trigger filters
htmx compiles [...] filters with new Function. binmx parses them into a data
structure and walks it. Nothing is compiled, and only allow-listed properties
are readable.
<input bin-get="/s" bin-trigger="keyup[key=='Enter']">
<div bin-get="/x" bin-trigger="click[ctrlKey && !shiftKey]">
<input bin-get="/s" bin-trigger="input[target.value.length > 2]">
<input bin-get="/s" bin-trigger="change[value ^= 'AB']">Grammar
expr := or
or := and ('||' and)*
and := unary ('&&' unary)*
unary := '!' unary | comparison
comparison := operand (op operand)?
operand := '(' expr ')' | path | literal
op := == | != | > | >= | < | <= | ^= | $= | *=
literal := 'string' | 123 | true | false | null
path := name ('.' name)*^=, $= and *= are starts-with, ends-with and contains.
Readable paths
- Bare names resolve against the event first, then the element:
key,ctrlKey,button,value,checked, ... event.*,this.*/elt.*,target.*,detail.*are explicit roots.- Element properties are limited to an allow-list (
value,checked,id,name,type,textContent,dataset.*,length, ...).innerHTML,outerHTML,constructorand friends are not readable. - Anything that resolves to a function becomes
undefined.
A filter that fails to parse fires bin:securityViolation and is treated as
never fire, not "always fire".
Need something the grammar cannot express? Do it in JavaScript:
binmx.on('bin:beforeRequest', (e) => {
if (!myCondition(e.detail.elt)) e.preventDefault()
})Swapping
<div bin-get="/x" bin-swap="outerHTML swap:100ms settle:200ms scroll:top">| Style | Effect |
| --- | --- |
| innerHTML | Replace the target's contents (default) |
| outerHTML | Replace the target itself |
| textContent | Insert the response as text, never markup |
| beforebegin / afterbegin / beforeend / afterend | Insert relative to the target |
| delete | Remove the target |
| none | Swap nothing (out-of-band swaps still apply) |
| Modifier | Effect |
| --- | --- |
| swap:<time> | Wait before swapping — pairs with a .bin-swapping fade-out |
| settle:<time> | Time between insertion and the settle step |
| transition:true | Use the View Transition API where available |
| scroll:top\|bottom | Scroll the target (or scroll:#sel:top) |
| show:top\|bottom | Scroll an element into view (or show:window:top) |
| ignoreTitle:true | Do not apply a <title> from the response |
CSS hooks
| Class | When |
| --- | --- |
| .bin-request | On the element (or its bin-indicator) while a request is in flight |
| .bin-indicator | Marks an element as an indicator — hidden until .bin-request |
| .bin-swapping | On the target between the request finishing and the swap |
| .bin-added | On new content, removed at settle |
| .bin-settling | On new content during the settle window |
tr { transition: opacity 200ms ease; }
tr.bin-added { opacity: 0; }Out-of-band swaps
Update parts of the page unrelated to the request's target, in the same response:
<!-- server response -->
<div>the main content, swapped into bin-target</div>
<span id="cart" bin-swap-oob="true">3 items</span>
<li bin-swap-oob="beforeend:#log">something happened</li>bin-swap-oob takes true (match by id, outerHTML), a swap style, or
<style>:<selector>.
Parameters
- A form sends all of its controls.
- A control inside a form sends the whole form for non-GET requests.
- GET and DELETE put parameters in the query string; other verbs use the body.
- File inputs, or
bin-encoding="multipart/form-data", switch toFormData.
<form bin-post="/save" bin-params="not password">
<input name="q"><input name="password" type="password">
</form>
<button bin-post="/x" bin-vals='{"id": 7}' bin-include="#other-input">Go</button>bin-vals and bin-headers accept JSON only. For computed values, use the
bin:configRequest event:
binmx.on('bin:configRequest', (e) => {
e.detail.filteredParameters.append('csrf', getToken())
e.detail.headers['X-Request-Id'] = crypto.randomUUID()
})Request and response headers
Sent by binmx
| Header | Value |
| --- | --- |
| BIN-Request | Always true |
| BIN-Trigger | id of the requesting element |
| BIN-Trigger-Name | name of the requesting element |
| BIN-Target | id of the target |
| BIN-Current-URL | location.href |
| BIN-Boosted | true for boosted links and forms |
| BIN-Prompt | The user's bin-prompt response |
| BIN-History-Restore-Request | true when refilling a history cache miss |
Honoured in responses
| Header | Effect |
| --- | --- |
| BIN-Trigger | Fire events. Bare name, comma list, or {"name":{detail}} |
| BIN-Trigger-After-Swap | Same, after the swap |
| BIN-Trigger-After-Settle | Same, after settling |
| BIN-Retarget | Override bin-target |
| BIN-Reswap | Override bin-swap |
| BIN-Reselect | Override bin-select |
| BIN-Push-Url / BIN-Replace-Url | Update history (same-origin only) |
| BIN-Redirect | Navigate (validated against allowedUrlSchemes / origin policy) |
| BIN-Location | Client-side navigation, same-origin only |
| BIN-Refresh | true reloads the page |
BIN-Trigger only dispatches DOM events. It cannot execute code.
By default 2xx responses swap, 204 does not, and 4xx/5xx fire
bin:responseError without swapping. Change that with
binmx.config.responseHandling, or per-request:
binmx.on('bin:beforeSwap', (e) => {
if (e.detail.status === 422) e.detail.shouldSwap = true
})Events
Every event is dispatched in both bin:camelCase and bin:kebab-case form and
bubbles. Cancelling a cancellable event aborts that step.
| Event | Cancelable | Notes |
| --- | --- | --- |
| bin:confirm | ✅ | Cancel and call detail.issueRequest() for async dialogs |
| bin:configRequest | ✅ | Mutate parameters, filteredParameters, headers, path |
| bin:beforeRequest | ✅ | Last chance to abort |
| bin:beforeSend | | About to hit the network |
| bin:beforeSwap | ✅ | Mutate shouldSwap, target, text, swapSpec |
| bin:afterSwap | | Content is in the DOM |
| bin:afterSettle | | Settling finished |
| bin:afterRequest | | Always fires, success or failure |
| bin:responseError | | 4xx/5xx |
| bin:sendError | | Network failure |
| bin:timeout | | Request exceeded its timeout |
| bin:targetError | | bin-target matched nothing |
| bin:swapError | | The swap itself failed |
| bin:securityViolation | | Something was blocked — see SECURITY.md |
| bin:validation:failed / :halted | | HTML5 validation |
| bin:load | | New content was processed |
| bin:beforeProcessNode | ✅ | Before an element is wired up |
| bin:beforeCleanupElement | | Before listeners are torn down |
| bin:pushedIntoHistory / bin:historyRestore | | History |
| bin:abort | | Fire this on an element to cancel its request |
History
<a bin-get="/page/2" bin-target="#content" bin-push-url="true">Next</a>Pages are cached in localStorage so Back is instant. Two things to know:
- The cache holds rendered HTML. On a page showing sensitive data, set
bin-history="false"so it is never written, or setbinmx.config.historyCacheSize = 0to disable caching entirely. - Call
binmx.clearHistoryCache()on logout.
Restored content is re-sanitized on the way back in, because localStorage is
writable by anything running in the page.
JavaScript API
binmx.process(element) // wire up markup you inserted yourself
binmx.ajax('GET', '/rows', '#target')
binmx.ajax('POST', '/save', { target: '#out', values: { id: 3 }, swap: 'beforeend' })
binmx.swap('#target', '<p>hi</p>') // sanitized, settled, OOB-aware
binmx.values(formElement, 'post') // what would be sent
binmx.abort('#element') // cancel in-flight requests
binmx.on('bin:afterSwap', handler)
binmx.off('bin:afterSwap', handler)
binmx.trigger('#el', 'my:event', { detail: 1 })
binmx.find('.sel'); binmx.findAll('.sel'); binmx.closest('#a', '.wrap')
binmx.remove('#el'); binmx.addClass('#el', 'x'); binmx.takeClass('#el', 'active')
binmx.clearHistoryCache()
binmx.defineExtension('name', { /* ... */ })Extensions
Extensions are registered in JavaScript. bin-ext only selects one by name —
markup can never introduce behaviour.
binmx.defineExtension('json-body', {
encodeParameters(params, ctx) {
ctx.headers['Content-Type'] = 'application/json'
return JSON.stringify(Object.fromEntries(params))
},
})<form bin-post="/api" bin-ext="json-body">…</form>Hooks: init, onEvent, transformResponse, encodeParameters, handleSwap.
bin-ext="ignore:name" switches one off for a subtree.
Configuration
binmx.config.timeout = 5000
binmx.config.defaultSwapStyle = 'outerHTML'Or, for non-security options only:
<meta name="binmx-config" content='{"timeout":5000}'>| Option | Default | |
| --- | --- | --- |
| defaultSwapStyle | 'innerHTML' | |
| defaultSwapDelay / defaultSettleDelay | 0 / 20 | ms |
| allowScriptTags | false | 🔒 Execute <script> in responses |
| allowEventHandlerAttributes | false | 🔒 Keep onclick etc. |
| sanitizeSwapContent | true | 🔒 Run the sanitizer |
| allowedUrlSchemes | ['http:','https:'] | 🔒 |
| selfRequestsOnly | true | 🔒 |
| allowedOrigins | [] | 🔒 Extra origins when selfRequestsOnly |
| inlineScriptNonce / inlineStyleNonce | '' | 🔒 CSP nonces |
| historyEnabled / historyCacheSize | true / 10 | |
| verbsThatUseUrlParams | ['get','delete'] | |
| withCredentials / timeout | false / 0 | |
| responseHandling | 2xx swaps, 4xx/5xx error | |
| globalViewTransitions | false | |
| includeIndicatorStyles | true | Inject the .bin-indicator rule |
| allowNestedOobSwaps | true | |
| logLevel | 'error' | 'silent', 'error', 'debug' |
🔒 = cannot be set from the <meta> tag. A meta tag is content; content must not
be able to switch off the sanitizer.
Differences from htmx
Removed, deliberately
| htmx | binmx |
| --- | --- |
| hx-on:click="js" | Not supported. Use a script file and binmx.on(...) |
| hx-vals="js:{...}" | JSON only. Use bin:configRequest for computed values |
| hx-headers="js:{...}" | JSON only |
| hx-trigger="[jsExpr]" | Safe filter grammar, parsed not compiled |
| hx-ext loading remote code | Extensions are registered in JS only |
| WebSockets / SSE | Out of scope |
Changed defaults
<script>in responses does not execute (allowScriptTags). Inert types —application/json,application/ld+json— are kept, since no browser runs them and hydration props travel in them.- Inline
on*handlers in responses are stripped. - Requests are same-origin only (
selfRequestsOnly). <base>,<meta http-equiv=refresh>andiframe[srcdoc]are stripped from responses.
Same as htmx — the request/swap model, targets, swap styles and modifiers,
out-of-band swaps, bin-preserve, triggers and modifiers, indicators,
bin-sync, boosting, history, inheritance, and the event lifecycle.
Porting from htmx
Rename hx- to bin-, HX- headers to BIN-, htmx: events to bin:, and
htmx-* classes to bin-*. Then deal with the eval-backed features:
<!-- htmx -->
<button hx-post="/x" hx-on:click="console.log('hi')" hx-vals="js:{t: Date.now()}">
<!-- binmx -->
<button id="b" bin-post="/x">
<script>
binmx.on(binmx.find('#b'), 'click', () => console.log('hi'))
binmx.on('bin:configRequest', (e) => {
if (e.detail.elt.id === 'b') e.detail.filteredParameters.append('t', Date.now())
})
</script>Development
npm install
npm test # 190 tests
npm run typecheck
npm run build # dist/binmx.{js,min.js,mjs,cjs,d.ts}
npm run demo # http://localhost:4173The demo at examples/ exercises active search, out-of-band swaps, polling,
revealed, safe trigger filters, error handling, and a /hostile endpoint that
returns a <script>, an onerror, a javascript: link and a <base> tag so
you can watch all four get stripped.
License
MIT
