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 +5 -0
- package/LICENSE +21 -0
- package/README.md +63 -0
- package/package.json +45 -0
- package/src/index.js +66 -0
- package/src/limiter.js +70 -0
package/CHANGELOG.md
ADDED
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
|
+
}
|