@profullstack/throttle 0.3.2 → 0.3.4
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/README.md +9 -0
- package/index.d.ts +9 -0
- package/package.json +2 -2
- package/src/index.js +1 -1
- package/src/throttle.js +33 -1
package/README.md
CHANGED
|
@@ -160,6 +160,15 @@ authenticated. **The sign-in routes need this**, or a brute-force attempt bolts
|
|
|
160
160
|
an `Authorization` header onto every guess and buys itself the integration
|
|
161
161
|
budget.
|
|
162
162
|
|
|
163
|
+
### Subresources
|
|
164
|
+
|
|
165
|
+
Images, fonts, scripts and styles (by `Sec-Fetch-Dest`) count against a bucket
|
|
166
|
+
of their own, 1,000 a minute by default, so a page that pulls in hundreds of
|
|
167
|
+
images never spends the reader's page allowance; at worst an image breaks, the
|
|
168
|
+
page does not 402. They are metered rather than exempt because the header is
|
|
169
|
+
the client's to write. `subresources: { limit }` changes the number;
|
|
170
|
+
`subresources: false` counts them with pages, as 0.3 did.
|
|
171
|
+
|
|
163
172
|
### Never metered
|
|
164
173
|
|
|
165
174
|
`/robots.txt`, `/favicon.ico`, `/.well-known/`, and — when a gateway is
|
package/index.d.ts
CHANGED
|
@@ -62,6 +62,12 @@ export interface ThrottleOptions {
|
|
|
62
62
|
rules?: ThrottleRule[];
|
|
63
63
|
/** Budget for callers presenting a credential. `false` to treat them as anonymous. */
|
|
64
64
|
credential?: false | CredentialBudget;
|
|
65
|
+
/**
|
|
66
|
+
* Images, fonts, scripts and styles (by Sec-Fetch-Dest) count against their
|
|
67
|
+
* own bucket, so a page full of images never spends the page allowance.
|
|
68
|
+
* Default 1000 per window; `false` counts them with pages.
|
|
69
|
+
*/
|
|
70
|
+
subresources?: false | { limit?: number };
|
|
65
71
|
/** Told about every refusal. Its errors are swallowed. */
|
|
66
72
|
onThrottle?: (event: ThrottleEvent) => void | Promise<void>;
|
|
67
73
|
}
|
|
@@ -75,12 +81,15 @@ export interface Throttle {
|
|
|
75
81
|
windowSeconds: number;
|
|
76
82
|
rules: Required<ThrottleRule>[];
|
|
77
83
|
credential: { limit: number; ceiling: number } | null;
|
|
84
|
+
subresources: { limit: number } | null;
|
|
78
85
|
openPaths: string[];
|
|
79
86
|
gateway: boolean;
|
|
80
87
|
};
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
export function createThrottle(options?: ThrottleOptions): Throttle;
|
|
91
|
+
/** Whether Sec-Fetch-Dest names an image, font, style, script or media fetch. */
|
|
92
|
+
export function isSubresource(request: Request): boolean;
|
|
84
93
|
|
|
85
94
|
export function memoryStore(options?: {
|
|
86
95
|
now?: () => number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/throttle",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "An app-wide rate limit whose answer to going over is a price, not a 429. 100 requests a minute free, then x402 Payment Required with an offer, settled by CoinPay. Meters every route, so a scraper wearing a browser user agent is charged like any other crawler.",
|
|
6
6
|
"keywords": [
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
},
|
|
55
55
|
"sideEffects": false,
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@profullstack/x402-gateway": "^0.6.0 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^0.10.0"
|
|
57
|
+
"@profullstack/x402-gateway": "^0.6.0 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0"
|
|
58
58
|
},
|
|
59
59
|
"scripts": {
|
|
60
60
|
"test": "node --test"
|
package/src/index.js
CHANGED
package/src/throttle.js
CHANGED
|
@@ -45,6 +45,7 @@ import { memoryStore } from './store.js';
|
|
|
45
45
|
* @param {string[]} [options.openPaths] extra paths never metered
|
|
46
46
|
* @param {Array} [options.rules] per-path allowances, most specific wins
|
|
47
47
|
* @param {{limit?: number, ceiling?: number}} [options.credential] budget for callers presenting a credential
|
|
48
|
+
* @param {false|{limit?: number}} [options.subresources] separate budget for images, fonts, scripts and styles; default 1000
|
|
48
49
|
* @param {(event: object) => void|Promise<void>} [options.onThrottle] told about every refusal; its errors are swallowed
|
|
49
50
|
*/
|
|
50
51
|
export function createThrottle(options = {}) {
|
|
@@ -93,6 +94,19 @@ export function createThrottle(options = {}) {
|
|
|
93
94
|
...(options.openPaths ?? []),
|
|
94
95
|
];
|
|
95
96
|
|
|
97
|
+
/*
|
|
98
|
+
* Images, fonts, scripts and styles get a bucket of their own, ten times the
|
|
99
|
+
* page allowance. On 2026-10-23 one refresh of an admin page that listed
|
|
100
|
+
* ~1,500 logos at relative paths spent the whole page allowance in a second,
|
|
101
|
+
* and the page itself answered 402: the reader was billed for markup they
|
|
102
|
+
* did not write. Kept separate rather than exempt, because Sec-Fetch-Dest is
|
|
103
|
+
* a header the client writes; a scraper that claims every page is an image
|
|
104
|
+
* buys itself a bigger bucket, not an unmetered site.
|
|
105
|
+
*/
|
|
106
|
+
const subresources = options.subresources === false ? null : {
|
|
107
|
+
limit: positive(options.subresources?.limit, 1000),
|
|
108
|
+
};
|
|
109
|
+
|
|
96
110
|
const quotaFor = (rule) => ({
|
|
97
111
|
requests: rule.limit,
|
|
98
112
|
windowSeconds: rule.windowSeconds,
|
|
@@ -216,6 +230,13 @@ export function createThrottle(options = {}) {
|
|
|
216
230
|
return null;
|
|
217
231
|
}
|
|
218
232
|
|
|
233
|
+
if (subresources && isSubresource(request)) {
|
|
234
|
+
const assetRule = { ...rule, limit: subresources.limit };
|
|
235
|
+
const usage = await spend(quotaFor(assetRule), `asset:${rule.path || '*'}:${key}`);
|
|
236
|
+
if (usage?.overLimit) return refuse(request, assetRule, usage);
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
219
240
|
const usage = await spend(quotaFor(rule), `${rule.path || '*'}:${key}`);
|
|
220
241
|
if (usage?.overLimit) return refuse(request, rule, usage);
|
|
221
242
|
return null;
|
|
@@ -225,10 +246,21 @@ export function createThrottle(options = {}) {
|
|
|
225
246
|
handle,
|
|
226
247
|
/** For tests and for a site that wants to reclaim memory on demand. */
|
|
227
248
|
store,
|
|
228
|
-
options: { ...defaults, rules, credential, openPaths, gateway: Boolean(gateway) },
|
|
249
|
+
options: { ...defaults, rules, credential, subresources, openPaths, gateway: Boolean(gateway) },
|
|
229
250
|
};
|
|
230
251
|
}
|
|
231
252
|
|
|
253
|
+
/**
|
|
254
|
+
* What a browser fetches to render a page, by its own account
|
|
255
|
+
* (Sec-Fetch-Dest). A navigation says `document`, fetch() and XHR say `empty`;
|
|
256
|
+
* both stay on the page allowance.
|
|
257
|
+
*/
|
|
258
|
+
const SUBRESOURCE = new Set(['image', 'font', 'style', 'script', 'audio', 'video', 'track', 'manifest']);
|
|
259
|
+
|
|
260
|
+
export function isSubresource(request) {
|
|
261
|
+
return SUBRESOURCE.has(request.headers.get('sec-fetch-dest') ?? '');
|
|
262
|
+
}
|
|
263
|
+
|
|
232
264
|
function positive(value, fallback) {
|
|
233
265
|
const n = Number(value);
|
|
234
266
|
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : fallback;
|