npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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

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 binmx
import 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 document

Triggers

<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, constructor and 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 to FormData.
<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 set binmx.config.historyCacheSize = 0 to 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> and iframe[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:4173

The 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