@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 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.2",
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
@@ -1,4 +1,4 @@
1
- export { createThrottle } from './throttle.js';
1
+ export { createThrottle, isSubresource } from './throttle.js';
2
2
  export { memoryStore } from './store.js';
3
3
  export { addressOf, fingerprint, presentedCredential } from './identify.js';
4
4
  export {
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;