htmx-ext-concurrency-limit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial release
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tresolis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # htmx-ext-concurrency-limit
2
+
3
+ An [htmx](https://htmx.org) extension that caps the number of **simultaneous in-flight
4
+ requests per page** and puts the rest in a FIFO standby queue, releasing them
5
+ automatically as active requests finish.
6
+
7
+ It changes **timing only** — request content, headers, target, swap, and response
8
+ handling are untouched. Nothing is dropped.
9
+
10
+ ## How it works
11
+
12
+ htmx fires `htmx:confirm` for every request trigger. The extension holds each request
13
+ (`preventDefault`) and either issues it immediately (a slot is free) or queues its
14
+ `issueRequest` callback. On `htmx:afterRequest` (success, error, timeout, or abort) a
15
+ slot is freed and the next queued request is issued. The cap can never be exceeded and
16
+ a finished request always frees its slot (no deadlock).
17
+
18
+ The counting/queue/drain logic lives in a pure, dependency-free module
19
+ ([`src/limiter.js`](./src/limiter.js)) and is unit-tested with `node --test`.
20
+
21
+ ## Install
22
+
23
+ In-repo pnpm workspace package. Register it in `pnpm-workspace.yaml` (`packages/*`) and
24
+ reference it with the workspace protocol:
25
+
26
+ ```jsonc
27
+ // package.json
28
+ "dependencies": {
29
+ "htmx-ext-concurrency-limit": "workspace:*"
30
+ }
31
+ ```
32
+
33
+ Then `pnpm install`. `htmx.org` (^2.0.10) is a peer dependency.
34
+
35
+ ## Usage
36
+
37
+ ```js
38
+ // bundle entry (e.g. main.js) — registers the extension
39
+ import 'htmx-ext-concurrency-limit'
40
+ ```
41
+
42
+ ```html
43
+ <!-- enable page-wide and set the limit X -->
44
+ <body hx-ext="concurrency-limit" data-htmx-max-concurrent="6">
45
+ ```
46
+
47
+ - Enable via `hx-ext="concurrency-limit"` on `<body>` (or any subtree). Descendants
48
+ inherit it; requests outside an enabled subtree are not limited.
49
+ - `data-htmx-max-concurrent` — maximum simultaneous in-flight requests. Read once at
50
+ startup. Missing / empty / `< 1` / non-numeric → defaults to **6**.
51
+
52
+ While a request waits in the queue, its triggering element gets the
53
+ `htmx-request-queued` class and `aria-busy="true"`; both are removed when it is issued.
54
+
55
+ ## Test
56
+
57
+ ```bash
58
+ npm test # node --test
59
+ ```
60
+
61
+ ## License
62
+
63
+ MIT
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "htmx-ext-concurrency-limit",
3
+ "version": "0.1.0",
4
+ "description": "htmx extension that caps simultaneous in-flight requests per page and queues the rest (FIFO).",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "module": "src/index.js",
8
+ "exports": {
9
+ ".": "./src/index.js",
10
+ "./limiter": "./src/limiter.js"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "README.md",
15
+ "CHANGELOG.md",
16
+ "LICENSE"
17
+ ],
18
+ "keywords": [
19
+ "htmx",
20
+ "htmx-extension",
21
+ "concurrency",
22
+ "rate-limit",
23
+ "queue"
24
+ ],
25
+ "license": "MIT",
26
+ "author": "Loeiz Tanguy",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/tresolis/htmx-ext-concurrency-limit.git"
30
+ },
31
+ "homepage": "https://github.com/tresolis/htmx-ext-concurrency-limit#readme",
32
+ "bugs": "https://github.com/tresolis/htmx-ext-concurrency-limit/issues",
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "publishConfig": {
37
+ "provenance": true
38
+ },
39
+ "peerDependencies": {
40
+ "htmx.org": "^2.0.10"
41
+ },
42
+ "scripts": {
43
+ "test": "node --test"
44
+ }
45
+ }
package/src/index.js ADDED
@@ -0,0 +1,66 @@
1
+ // htmx extension `concurrency-limit`: caps simultaneous in-flight htmx requests
2
+ // per page at `data-htmx-max-concurrent` (default 6) and queues the rest (FIFO).
3
+ //
4
+ // Thin glue only — the counting/queue/drain logic lives in ./limiter.js so it can
5
+ // be unit-tested without a browser. This file maps htmx's request lifecycle onto it:
6
+ // htmx:confirm -> hold the request; issue now if a slot is free, else queue it
7
+ // htmx:afterRequest -> release the slot (any outcome) and drain the next queued one
8
+ import htmx from 'htmx.org'
9
+ import { createLimiter, parseMax } from './limiter.js'
10
+
11
+ const DEFAULT_MAX = 6
12
+ const PENDING_CLASS = 'htmx-request-queued'
13
+
14
+ let limiter = null
15
+
16
+ function getLimiter() {
17
+ if (!limiter) {
18
+ const max = parseMax(document.body && document.body.dataset.htmxMaxConcurrent, DEFAULT_MAX)
19
+ limiter = createLimiter(max)
20
+ }
21
+ return limiter
22
+ }
23
+
24
+ function markQueued(elt) {
25
+ if (elt && elt.classList) {
26
+ elt.classList.add(PENDING_CLASS)
27
+ elt.setAttribute('aria-busy', 'true')
28
+ }
29
+ }
30
+
31
+ function unmarkQueued(elt) {
32
+ if (elt && elt.classList) {
33
+ elt.classList.remove(PENDING_CLASS)
34
+ elt.removeAttribute('aria-busy')
35
+ }
36
+ }
37
+
38
+ htmx.defineExtension('concurrency-limit', {
39
+ init() {
40
+ // Read the limit once at startup (page-wide, fixed for the page lifetime).
41
+ getLimiter()
42
+ },
43
+
44
+ onEvent(name, evt) {
45
+ if (name === 'htmx:confirm') {
46
+ // Leave hx-confirm requests to htmx's native flow so the confirmation
47
+ // prompt still runs exactly once. These discrete, user-confirmed actions
48
+ // are not throttled (they are not a request-flood source).
49
+ if (evt.detail.question) return
50
+
51
+ const elt = evt.detail.elt
52
+ const realIssue = evt.detail.issueRequest
53
+ // Hold every other request; we decide when (or whether) it is sent.
54
+ evt.preventDefault()
55
+ const issue = () => {
56
+ unmarkQueued(elt)
57
+ realIssue(true)
58
+ }
59
+ if (getLimiter().request(issue) === 'queued') {
60
+ markQueued(elt)
61
+ }
62
+ } else if (name === 'htmx:afterRequest') {
63
+ getLimiter().release()
64
+ }
65
+ },
66
+ })
package/src/limiter.js ADDED
@@ -0,0 +1,70 @@
1
+ // Pure concurrency limiter: a counter + FIFO queue, with no DOM or htmx dependency.
2
+ // All the risk-bearing logic lives here so it can be unit-tested with `node --test`.
3
+
4
+ /**
5
+ * Parse a max-concurrency value, falling back when missing/empty/<1/NaN.
6
+ * @param {unknown} value
7
+ * @param {number} fallback
8
+ * @returns {number}
9
+ */
10
+ export function parseMax(value, fallback) {
11
+ const n = parseInt(value, 10)
12
+ return Number.isInteger(n) && n > 0 ? n : fallback
13
+ }
14
+
15
+ /**
16
+ * Create a limiter allowing at most `max` concurrent "issued" units.
17
+ *
18
+ * @param {number} max
19
+ * @returns {{
20
+ * request: (issue: () => void) => 'active' | 'queued',
21
+ * release: () => void,
22
+ * readonly active: number,
23
+ * readonly pending: number,
24
+ * }}
25
+ */
26
+ export function createLimiter(max) {
27
+ let active = 0
28
+ const queue = []
29
+
30
+ // Issue one unit and account for it; on throw, undo the slot so we never leak it.
31
+ function run(issue) {
32
+ active++
33
+ try {
34
+ issue()
35
+ } catch {
36
+ active--
37
+ return false
38
+ }
39
+ return true
40
+ }
41
+
42
+ function request(issue) {
43
+ if (active < max) {
44
+ run(issue)
45
+ return 'active'
46
+ }
47
+ queue.push(issue)
48
+ return 'queued'
49
+ }
50
+
51
+ function release() {
52
+ if (active > 0) active--
53
+ // Fill every freed slot (normally one). A throwing unit frees its slot again,
54
+ // so the loop simply continues to the next queued unit — no deadlock.
55
+ while (queue.length > 0 && active < max) {
56
+ run(queue.shift())
57
+ }
58
+ }
59
+
60
+ return {
61
+ request,
62
+ release,
63
+ get active() {
64
+ return active
65
+ },
66
+ get pending() {
67
+ return queue.length
68
+ },
69
+ }
70
+ }