@posthog/core 1.3.1 → 1.5.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.
@@ -0,0 +1,6 @@
1
+ export declare const DEFAULT_BLOCKED_UA_STRS: string[];
2
+ /**
3
+ * Block various web spiders from executing our JS and sending false capturing data
4
+ */
5
+ export declare const isBlockedUA: (ua: string | undefined, customBlockedUserAgents?: string[]) => boolean;
6
+ //# sourceMappingURL=bot-detection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bot-detection.d.ts","sourceRoot":"","sources":["../../src/utils/bot-detection.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,uBAAuB,UA+FnC,CAAA;AAED;;GAEG;AACH,eAAO,MAAM,WAAW,GAAa,IAAI,MAAM,GAAG,SAAS,EAAE,0BAAyB,MAAM,EAAO,KAAG,OAWrG,CAAA"}
@@ -0,0 +1,125 @@
1
+ "use strict";
2
+ var __webpack_require__ = {};
3
+ (()=>{
4
+ __webpack_require__.d = (exports1, definition)=>{
5
+ for(var key in definition)if (__webpack_require__.o(definition, key) && !__webpack_require__.o(exports1, key)) Object.defineProperty(exports1, key, {
6
+ enumerable: true,
7
+ get: definition[key]
8
+ });
9
+ };
10
+ })();
11
+ (()=>{
12
+ __webpack_require__.o = (obj, prop)=>Object.prototype.hasOwnProperty.call(obj, prop);
13
+ })();
14
+ (()=>{
15
+ __webpack_require__.r = (exports1)=>{
16
+ if ('undefined' != typeof Symbol && Symbol.toStringTag) Object.defineProperty(exports1, Symbol.toStringTag, {
17
+ value: 'Module'
18
+ });
19
+ Object.defineProperty(exports1, '__esModule', {
20
+ value: true
21
+ });
22
+ };
23
+ })();
24
+ var __webpack_exports__ = {};
25
+ __webpack_require__.r(__webpack_exports__);
26
+ __webpack_require__.d(__webpack_exports__, {
27
+ DEFAULT_BLOCKED_UA_STRS: ()=>DEFAULT_BLOCKED_UA_STRS,
28
+ isBlockedUA: ()=>isBlockedUA
29
+ });
30
+ const DEFAULT_BLOCKED_UA_STRS = [
31
+ 'amazonbot',
32
+ 'amazonproductbot',
33
+ 'app.hypefactors.com',
34
+ 'applebot',
35
+ 'archive.org_bot',
36
+ 'awariobot',
37
+ 'backlinksextendedbot',
38
+ 'baiduspider',
39
+ 'bingbot',
40
+ 'bingpreview',
41
+ 'chrome-lighthouse',
42
+ 'dataforseobot',
43
+ 'deepscan',
44
+ 'duckduckbot',
45
+ 'facebookexternal',
46
+ 'facebookcatalog',
47
+ 'http://yandex.com/bots',
48
+ 'hubspot',
49
+ 'ia_archiver',
50
+ 'leikibot',
51
+ 'linkedinbot',
52
+ 'meta-externalagent',
53
+ 'mj12bot',
54
+ 'msnbot',
55
+ 'nessus',
56
+ 'petalbot',
57
+ 'pinterest',
58
+ 'prerender',
59
+ 'rogerbot',
60
+ 'screaming frog',
61
+ 'sebot-wa',
62
+ 'sitebulb',
63
+ 'slackbot',
64
+ 'slurp',
65
+ 'trendictionbot',
66
+ 'turnitin',
67
+ 'twitterbot',
68
+ 'vercel-screenshot',
69
+ 'vercelbot',
70
+ 'yahoo! slurp',
71
+ 'yandexbot',
72
+ 'zoombot',
73
+ 'bot.htm',
74
+ 'bot.php',
75
+ '(bot;',
76
+ 'bot/',
77
+ 'crawler',
78
+ 'ahrefsbot',
79
+ 'ahrefssiteaudit',
80
+ 'semrushbot',
81
+ 'siteauditbot',
82
+ 'splitsignalbot',
83
+ 'gptbot',
84
+ 'oai-searchbot',
85
+ 'chatgpt-user',
86
+ 'perplexitybot',
87
+ 'better uptime bot',
88
+ 'sentryuptimebot',
89
+ 'uptimerobot',
90
+ 'headlesschrome',
91
+ 'cypress',
92
+ 'google-hoteladsverifier',
93
+ 'adsbot-google',
94
+ 'apis-google',
95
+ 'duplexweb-google',
96
+ 'feedfetcher-google',
97
+ 'google favicon',
98
+ 'google web preview',
99
+ 'google-read-aloud',
100
+ 'googlebot',
101
+ 'googleother',
102
+ 'google-cloudvertexbot',
103
+ 'googleweblight',
104
+ 'mediapartners-google',
105
+ 'storebot-google',
106
+ 'google-inspectiontool',
107
+ 'bytespider'
108
+ ];
109
+ const isBlockedUA = function(ua, customBlockedUserAgents = []) {
110
+ if (!ua) return false;
111
+ const uaLower = ua.toLowerCase();
112
+ return DEFAULT_BLOCKED_UA_STRS.concat(customBlockedUserAgents).some((blockedUA)=>{
113
+ const blockedUaLower = blockedUA.toLowerCase();
114
+ return -1 !== uaLower.indexOf(blockedUaLower);
115
+ });
116
+ };
117
+ exports.DEFAULT_BLOCKED_UA_STRS = __webpack_exports__.DEFAULT_BLOCKED_UA_STRS;
118
+ exports.isBlockedUA = __webpack_exports__.isBlockedUA;
119
+ for(var __webpack_i__ in __webpack_exports__)if (-1 === [
120
+ "DEFAULT_BLOCKED_UA_STRS",
121
+ "isBlockedUA"
122
+ ].indexOf(__webpack_i__)) exports[__webpack_i__] = __webpack_exports__[__webpack_i__];
123
+ Object.defineProperty(exports, '__esModule', {
124
+ value: true
125
+ });
@@ -0,0 +1,88 @@
1
+ const DEFAULT_BLOCKED_UA_STRS = [
2
+ 'amazonbot',
3
+ 'amazonproductbot',
4
+ 'app.hypefactors.com',
5
+ 'applebot',
6
+ 'archive.org_bot',
7
+ 'awariobot',
8
+ 'backlinksextendedbot',
9
+ 'baiduspider',
10
+ 'bingbot',
11
+ 'bingpreview',
12
+ 'chrome-lighthouse',
13
+ 'dataforseobot',
14
+ 'deepscan',
15
+ 'duckduckbot',
16
+ 'facebookexternal',
17
+ 'facebookcatalog',
18
+ 'http://yandex.com/bots',
19
+ 'hubspot',
20
+ 'ia_archiver',
21
+ 'leikibot',
22
+ 'linkedinbot',
23
+ 'meta-externalagent',
24
+ 'mj12bot',
25
+ 'msnbot',
26
+ 'nessus',
27
+ 'petalbot',
28
+ 'pinterest',
29
+ 'prerender',
30
+ 'rogerbot',
31
+ 'screaming frog',
32
+ 'sebot-wa',
33
+ 'sitebulb',
34
+ 'slackbot',
35
+ 'slurp',
36
+ 'trendictionbot',
37
+ 'turnitin',
38
+ 'twitterbot',
39
+ 'vercel-screenshot',
40
+ 'vercelbot',
41
+ 'yahoo! slurp',
42
+ 'yandexbot',
43
+ 'zoombot',
44
+ 'bot.htm',
45
+ 'bot.php',
46
+ '(bot;',
47
+ 'bot/',
48
+ 'crawler',
49
+ 'ahrefsbot',
50
+ 'ahrefssiteaudit',
51
+ 'semrushbot',
52
+ 'siteauditbot',
53
+ 'splitsignalbot',
54
+ 'gptbot',
55
+ 'oai-searchbot',
56
+ 'chatgpt-user',
57
+ 'perplexitybot',
58
+ 'better uptime bot',
59
+ 'sentryuptimebot',
60
+ 'uptimerobot',
61
+ 'headlesschrome',
62
+ 'cypress',
63
+ 'google-hoteladsverifier',
64
+ 'adsbot-google',
65
+ 'apis-google',
66
+ 'duplexweb-google',
67
+ 'feedfetcher-google',
68
+ 'google favicon',
69
+ 'google web preview',
70
+ 'google-read-aloud',
71
+ 'googlebot',
72
+ 'googleother',
73
+ 'google-cloudvertexbot',
74
+ 'googleweblight',
75
+ 'mediapartners-google',
76
+ 'storebot-google',
77
+ 'google-inspectiontool',
78
+ 'bytespider'
79
+ ];
80
+ const isBlockedUA = function(ua, customBlockedUserAgents = []) {
81
+ if (!ua) return false;
82
+ const uaLower = ua.toLowerCase();
83
+ return DEFAULT_BLOCKED_UA_STRS.concat(customBlockedUserAgents).some((blockedUA)=>{
84
+ const blockedUaLower = blockedUA.toLowerCase();
85
+ return -1 !== uaLower.indexOf(blockedUaLower);
86
+ });
87
+ };
88
+ export { DEFAULT_BLOCKED_UA_STRS, isBlockedUA };
@@ -1,23 +1,19 @@
1
1
  import { Logger } from '../types';
2
2
  export declare class BucketedRateLimiter<T extends string | number> {
3
- private readonly _options;
4
3
  private _bucketSize;
5
4
  private _refillRate;
6
5
  private _refillInterval;
7
6
  private _onBucketRateLimited?;
8
7
  private _buckets;
9
- private _removeInterval;
10
- constructor(_options: {
8
+ constructor(options: {
11
9
  bucketSize: number;
12
10
  refillRate: number;
13
11
  refillInterval: number;
14
12
  _logger: Logger;
15
13
  _onBucketRateLimited?: (key: T) => void;
16
14
  });
17
- private _refillBuckets;
18
- private _getBucket;
19
- private _setBucket;
20
- consumeRateLimit: (key: T) => boolean;
15
+ private _applyRefill;
16
+ consumeRateLimit(key: T): boolean;
21
17
  stop(): void;
22
18
  }
23
19
  //# sourceMappingURL=bucketed-rate-limiter.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"bucketed-rate-limiter.d.ts","sourceRoot":"","sources":["../../src/utils/bucketed-rate-limiter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AAGjC,qBAAa,mBAAmB,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM;IAUtD,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAT3B,OAAO,CAAC,WAAW,CAAA;IACnB,OAAO,CAAC,WAAW,CAAA;IACnB,OAAO,CAAC,eAAe,CAAA;IACvB,OAAO,CAAC,oBAAoB,CAAC,CAAkB;IAE/C,OAAO,CAAC,QAAQ,CAA6B;IAC7C,OAAO,CAAC,eAAe,CAA4B;gBAGhC,QAAQ,EAAE;QACzB,UAAU,EAAE,MAAM,CAAA;QAClB,UAAU,EAAE,MAAM,CAAA;QAClB,cAAc,EAAE,MAAM,CAAA;QACtB,OAAO,EAAE,MAAM,CAAA;QACf,oBAAoB,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,IAAI,CAAA;KACxC;IAqBH,OAAO,CAAC,cAAc,CAUrB;IAED,OAAO,CAAC,UAAU,CAEjB;IACD,OAAO,CAAC,UAAU,CAEjB;IAEM,gBAAgB,GAAI,KAAK,CAAC,aAiBhC;IAEM,IAAI;CAMZ"}
1
+ {"version":3,"file":"bucketed-rate-limiter.d.ts","sourceRoot":"","sources":["../../src/utils/bucketed-rate-limiter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AAMjC,qBAAa,mBAAmB,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM;IACxD,OAAO,CAAC,WAAW,CAAQ;IAC3B,OAAO,CAAC,WAAW,CAAQ;IAC3B,OAAO,CAAC,eAAe,CAAQ;IAC/B,OAAO,CAAC,oBAAoB,CAAC,CAAkB;IAC/C,OAAO,CAAC,QAAQ,CAA6B;gBAEjC,OAAO,EAAE;QACnB,UAAU,EAAE,MAAM,CAAA;QAClB,UAAU,EAAE,MAAM,CAAA;QAClB,cAAc,EAAE,MAAM,CAAA;QACtB,OAAO,EAAE,MAAM,CAAA;QACf,oBAAoB,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,IAAI,CAAA;KACxC;IAOD,OAAO,CAAC,YAAY;IAWb,gBAAgB,CAAC,GAAG,EAAE,CAAC,GAAG,OAAO;IA0BjC,IAAI,IAAI,IAAI;CAGpB"}
@@ -27,43 +27,43 @@ __webpack_require__.d(__webpack_exports__, {
27
27
  BucketedRateLimiter: ()=>BucketedRateLimiter
28
28
  });
29
29
  const external_number_utils_js_namespaceObject = require("./number-utils.js");
30
+ const ONE_DAY_IN_MS = 86400000;
30
31
  class BucketedRateLimiter {
31
- constructor(_options){
32
- this._options = _options;
32
+ constructor(options){
33
33
  this._buckets = {};
34
- this._refillBuckets = ()=>{
35
- Object.keys(this._buckets).forEach((key)=>{
36
- const newTokens = this._getBucket(key) + this._refillRate;
37
- if (newTokens >= this._bucketSize) delete this._buckets[key];
38
- else this._setBucket(key, newTokens);
39
- });
40
- };
41
- this._getBucket = (key)=>this._buckets[String(key)];
42
- this._setBucket = (key, value)=>{
43
- this._buckets[String(key)] = value;
44
- };
45
- this.consumeRateLimit = (key)=>{
46
- let tokens = this._getBucket(key) ?? this._bucketSize;
47
- tokens = Math.max(tokens - 1, 0);
48
- if (0 === tokens) return true;
49
- this._setBucket(key, tokens);
50
- const hasReachedZero = 0 === tokens;
51
- if (hasReachedZero) this._onBucketRateLimited?.(key);
52
- return hasReachedZero;
53
- };
54
- this._onBucketRateLimited = this._options._onBucketRateLimited;
55
- this._bucketSize = (0, external_number_utils_js_namespaceObject.clampToRange)(this._options.bucketSize, 0, 100, this._options._logger);
56
- this._refillRate = (0, external_number_utils_js_namespaceObject.clampToRange)(this._options.refillRate, 0, this._bucketSize, this._options._logger);
57
- this._refillInterval = (0, external_number_utils_js_namespaceObject.clampToRange)(this._options.refillInterval, 0, 86400000, this._options._logger);
58
- this._removeInterval = setInterval(()=>{
59
- this._refillBuckets();
60
- }, this._refillInterval);
34
+ this._onBucketRateLimited = options._onBucketRateLimited;
35
+ this._bucketSize = (0, external_number_utils_js_namespaceObject.clampToRange)(options.bucketSize, 0, 100, options._logger);
36
+ this._refillRate = (0, external_number_utils_js_namespaceObject.clampToRange)(options.refillRate, 0, this._bucketSize, options._logger);
37
+ this._refillInterval = (0, external_number_utils_js_namespaceObject.clampToRange)(options.refillInterval, 0, ONE_DAY_IN_MS, options._logger);
61
38
  }
62
- stop() {
63
- if (this._removeInterval) {
64
- clearInterval(this._removeInterval);
65
- this._removeInterval = void 0;
39
+ _applyRefill(bucket, now) {
40
+ const elapsedMs = now - bucket.lastAccess;
41
+ const refillIntervals = Math.floor(elapsedMs / this._refillInterval);
42
+ if (refillIntervals > 0) {
43
+ const tokensToAdd = refillIntervals * this._refillRate;
44
+ bucket.tokens = Math.min(bucket.tokens + tokensToAdd, this._bucketSize);
45
+ bucket.lastAccess = bucket.lastAccess + refillIntervals * this._refillInterval;
46
+ }
47
+ }
48
+ consumeRateLimit(key) {
49
+ const now = Date.now();
50
+ const keyStr = String(key);
51
+ let bucket = this._buckets[keyStr];
52
+ if (bucket) this._applyRefill(bucket, now);
53
+ else {
54
+ bucket = {
55
+ tokens: this._bucketSize,
56
+ lastAccess: now
57
+ };
58
+ this._buckets[keyStr] = bucket;
66
59
  }
60
+ if (0 === bucket.tokens) return true;
61
+ bucket.tokens--;
62
+ if (0 === bucket.tokens) this._onBucketRateLimited?.(key);
63
+ return 0 === bucket.tokens;
64
+ }
65
+ stop() {
66
+ this._buckets = {};
67
67
  }
68
68
  }
69
69
  exports.BucketedRateLimiter = __webpack_exports__.BucketedRateLimiter;
@@ -1,41 +1,41 @@
1
1
  import { clampToRange } from "./number-utils.mjs";
2
+ const ONE_DAY_IN_MS = 86400000;
2
3
  class BucketedRateLimiter {
3
- constructor(_options){
4
- this._options = _options;
4
+ constructor(options){
5
5
  this._buckets = {};
6
- this._refillBuckets = ()=>{
7
- Object.keys(this._buckets).forEach((key)=>{
8
- const newTokens = this._getBucket(key) + this._refillRate;
9
- if (newTokens >= this._bucketSize) delete this._buckets[key];
10
- else this._setBucket(key, newTokens);
11
- });
12
- };
13
- this._getBucket = (key)=>this._buckets[String(key)];
14
- this._setBucket = (key, value)=>{
15
- this._buckets[String(key)] = value;
16
- };
17
- this.consumeRateLimit = (key)=>{
18
- let tokens = this._getBucket(key) ?? this._bucketSize;
19
- tokens = Math.max(tokens - 1, 0);
20
- if (0 === tokens) return true;
21
- this._setBucket(key, tokens);
22
- const hasReachedZero = 0 === tokens;
23
- if (hasReachedZero) this._onBucketRateLimited?.(key);
24
- return hasReachedZero;
25
- };
26
- this._onBucketRateLimited = this._options._onBucketRateLimited;
27
- this._bucketSize = clampToRange(this._options.bucketSize, 0, 100, this._options._logger);
28
- this._refillRate = clampToRange(this._options.refillRate, 0, this._bucketSize, this._options._logger);
29
- this._refillInterval = clampToRange(this._options.refillInterval, 0, 86400000, this._options._logger);
30
- this._removeInterval = setInterval(()=>{
31
- this._refillBuckets();
32
- }, this._refillInterval);
6
+ this._onBucketRateLimited = options._onBucketRateLimited;
7
+ this._bucketSize = clampToRange(options.bucketSize, 0, 100, options._logger);
8
+ this._refillRate = clampToRange(options.refillRate, 0, this._bucketSize, options._logger);
9
+ this._refillInterval = clampToRange(options.refillInterval, 0, ONE_DAY_IN_MS, options._logger);
33
10
  }
34
- stop() {
35
- if (this._removeInterval) {
36
- clearInterval(this._removeInterval);
37
- this._removeInterval = void 0;
11
+ _applyRefill(bucket, now) {
12
+ const elapsedMs = now - bucket.lastAccess;
13
+ const refillIntervals = Math.floor(elapsedMs / this._refillInterval);
14
+ if (refillIntervals > 0) {
15
+ const tokensToAdd = refillIntervals * this._refillRate;
16
+ bucket.tokens = Math.min(bucket.tokens + tokensToAdd, this._bucketSize);
17
+ bucket.lastAccess = bucket.lastAccess + refillIntervals * this._refillInterval;
18
+ }
19
+ }
20
+ consumeRateLimit(key) {
21
+ const now = Date.now();
22
+ const keyStr = String(key);
23
+ let bucket = this._buckets[keyStr];
24
+ if (bucket) this._applyRefill(bucket, now);
25
+ else {
26
+ bucket = {
27
+ tokens: this._bucketSize,
28
+ lastAccess: now
29
+ };
30
+ this._buckets[keyStr] = bucket;
38
31
  }
32
+ if (0 === bucket.tokens) return true;
33
+ bucket.tokens--;
34
+ if (0 === bucket.tokens) this._onBucketRateLimited?.(key);
35
+ return 0 === bucket.tokens;
36
+ }
37
+ stop() {
38
+ this._buckets = {};
39
39
  }
40
40
  }
41
41
  export { BucketedRateLimiter };
@@ -1,4 +1,5 @@
1
1
  import { FetchLike } from '../types';
2
+ export * from './bot-detection';
2
3
  export * from './bucketed-rate-limiter';
3
4
  export * from './number-utils';
4
5
  export * from './string-utils';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAA;AAEpC,cAAc,yBAAyB,CAAA;AACvC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,cAAc,CAAA;AAC5B,cAAc,iBAAiB,CAAA;AAE/B,eAAO,MAAM,aAAa,SAAS,CAAA;AAEnC,wBAAgB,MAAM,CAAC,WAAW,EAAE,GAAG,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAI9D;AASD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAA;CACtC;AAED,wBAAsB,SAAS,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC,CAAC,CAAC,CAqB5F;AAED,wBAAgB,gBAAgB,IAAI,MAAM,CAEzC;AAED,wBAAgB,cAAc,IAAI,MAAM,CAEvC;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,OAAO,EAAE,MAAM,GAAG,GAAG,CAOnE;AAGD,eAAO,MAAM,SAAS,GAAI,KAAK,GAAG,KAAG,GAAG,IAAI,OAAO,CAAC,GAAG,CAEtD,CAAA;AAED,eAAO,MAAM,OAAO,GAAI,GAAG,OAAO,KAAG,CAAC,IAAI,KAEzC,CAAA;AAED,wBAAgB,QAAQ,IAAI,SAAS,GAAG,SAAS,CAEhD;AAED,wBAAgB,UAAU,CAAC,CAAC,EAC1B,QAAQ,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,IAAI,GAAG,SAAS,CAAC,EAAE,GAC1C,OAAO,CAAC,CAAC;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,CAAC,EAAE,CAAC,CAStF"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAA;AAEpC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,yBAAyB,CAAA;AACvC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,cAAc,CAAA;AAC5B,cAAc,iBAAiB,CAAA;AAE/B,eAAO,MAAM,aAAa,SAAS,CAAA;AAEnC,wBAAgB,MAAM,CAAC,WAAW,EAAE,GAAG,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAI9D;AASD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAA;CACtC;AAED,wBAAsB,SAAS,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC,CAAC,CAAC,CAqB5F;AAED,wBAAgB,gBAAgB,IAAI,MAAM,CAEzC;AAED,wBAAgB,cAAc,IAAI,MAAM,CAEvC;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,OAAO,EAAE,MAAM,GAAG,GAAG,CAOnE;AAGD,eAAO,MAAM,SAAS,GAAI,KAAK,GAAG,KAAG,GAAG,IAAI,OAAO,CAAC,GAAG,CAEtD,CAAA;AAED,eAAO,MAAM,OAAO,GAAI,GAAG,OAAO,KAAG,CAAC,IAAI,KAEzC,CAAA;AAED,wBAAgB,QAAQ,IAAI,SAAS,GAAG,SAAS,CAEhD;AAED,wBAAgB,UAAU,CAAC,CAAC,EAC1B,QAAQ,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,IAAI,GAAG,SAAS,CAAC,EAAE,GAC1C,OAAO,CAAC,CAAC;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,CAAC,EAAE,CAAC,CAStF"}
@@ -1,5 +1,8 @@
1
1
  "use strict";
2
2
  var __webpack_modules__ = {
3
+ "./bot-detection": function(module) {
4
+ module.exports = require("./bot-detection.js");
5
+ },
3
6
  "./bucketed-rate-limiter": function(module) {
4
7
  module.exports = require("./bucketed-rate-limiter.js");
5
8
  },
@@ -72,9 +75,28 @@ var __webpack_exports__ = {};
72
75
  retriable: ()=>retriable,
73
76
  safeSetTimeout: ()=>safeSetTimeout
74
77
  });
75
- var _bucketed_rate_limiter__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./bucketed-rate-limiter");
78
+ var _bot_detection__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./bot-detection");
79
+ var __WEBPACK_REEXPORT_OBJECT__ = {};
80
+ for(var __WEBPACK_IMPORT_KEY__ in _bot_detection__WEBPACK_IMPORTED_MODULE_0__)if ([
81
+ "removeTrailingSlash",
82
+ "retriable",
83
+ "default",
84
+ "currentISOTime",
85
+ "currentTimestamp",
86
+ "STRING_FORMAT",
87
+ "isError",
88
+ "safeSetTimeout",
89
+ "getFetch",
90
+ "isPromise",
91
+ "assert",
92
+ "allSettled"
93
+ ].indexOf(__WEBPACK_IMPORT_KEY__) < 0) __WEBPACK_REEXPORT_OBJECT__[__WEBPACK_IMPORT_KEY__] = (function(key) {
94
+ return _bot_detection__WEBPACK_IMPORTED_MODULE_0__[key];
95
+ }).bind(0, __WEBPACK_IMPORT_KEY__);
96
+ __webpack_require__.d(__webpack_exports__, __WEBPACK_REEXPORT_OBJECT__);
97
+ var _bucketed_rate_limiter__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__("./bucketed-rate-limiter");
76
98
  var __WEBPACK_REEXPORT_OBJECT__ = {};
77
- for(var __WEBPACK_IMPORT_KEY__ in _bucketed_rate_limiter__WEBPACK_IMPORTED_MODULE_0__)if ([
99
+ for(var __WEBPACK_IMPORT_KEY__ in _bucketed_rate_limiter__WEBPACK_IMPORTED_MODULE_1__)if ([
78
100
  "removeTrailingSlash",
79
101
  "retriable",
80
102
  "default",
@@ -88,12 +110,12 @@ var __webpack_exports__ = {};
88
110
  "assert",
89
111
  "allSettled"
90
112
  ].indexOf(__WEBPACK_IMPORT_KEY__) < 0) __WEBPACK_REEXPORT_OBJECT__[__WEBPACK_IMPORT_KEY__] = (function(key) {
91
- return _bucketed_rate_limiter__WEBPACK_IMPORTED_MODULE_0__[key];
113
+ return _bucketed_rate_limiter__WEBPACK_IMPORTED_MODULE_1__[key];
92
114
  }).bind(0, __WEBPACK_IMPORT_KEY__);
93
115
  __webpack_require__.d(__webpack_exports__, __WEBPACK_REEXPORT_OBJECT__);
94
- var _number_utils__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__("./number-utils");
116
+ var _number_utils__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__("./number-utils");
95
117
  var __WEBPACK_REEXPORT_OBJECT__ = {};
96
- for(var __WEBPACK_IMPORT_KEY__ in _number_utils__WEBPACK_IMPORTED_MODULE_1__)if ([
118
+ for(var __WEBPACK_IMPORT_KEY__ in _number_utils__WEBPACK_IMPORTED_MODULE_2__)if ([
97
119
  "removeTrailingSlash",
98
120
  "retriable",
99
121
  "default",
@@ -107,12 +129,12 @@ var __webpack_exports__ = {};
107
129
  "assert",
108
130
  "allSettled"
109
131
  ].indexOf(__WEBPACK_IMPORT_KEY__) < 0) __WEBPACK_REEXPORT_OBJECT__[__WEBPACK_IMPORT_KEY__] = (function(key) {
110
- return _number_utils__WEBPACK_IMPORTED_MODULE_1__[key];
132
+ return _number_utils__WEBPACK_IMPORTED_MODULE_2__[key];
111
133
  }).bind(0, __WEBPACK_IMPORT_KEY__);
112
134
  __webpack_require__.d(__webpack_exports__, __WEBPACK_REEXPORT_OBJECT__);
113
- var _string_utils__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__("./string-utils");
135
+ var _string_utils__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__("./string-utils");
114
136
  var __WEBPACK_REEXPORT_OBJECT__ = {};
115
- for(var __WEBPACK_IMPORT_KEY__ in _string_utils__WEBPACK_IMPORTED_MODULE_2__)if ([
137
+ for(var __WEBPACK_IMPORT_KEY__ in _string_utils__WEBPACK_IMPORTED_MODULE_3__)if ([
116
138
  "removeTrailingSlash",
117
139
  "retriable",
118
140
  "default",
@@ -126,12 +148,12 @@ var __webpack_exports__ = {};
126
148
  "assert",
127
149
  "allSettled"
128
150
  ].indexOf(__WEBPACK_IMPORT_KEY__) < 0) __WEBPACK_REEXPORT_OBJECT__[__WEBPACK_IMPORT_KEY__] = (function(key) {
129
- return _string_utils__WEBPACK_IMPORTED_MODULE_2__[key];
151
+ return _string_utils__WEBPACK_IMPORTED_MODULE_3__[key];
130
152
  }).bind(0, __WEBPACK_IMPORT_KEY__);
131
153
  __webpack_require__.d(__webpack_exports__, __WEBPACK_REEXPORT_OBJECT__);
132
- var _type_utils__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__("./type-utils");
154
+ var _type_utils__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__("./type-utils");
133
155
  var __WEBPACK_REEXPORT_OBJECT__ = {};
134
- for(var __WEBPACK_IMPORT_KEY__ in _type_utils__WEBPACK_IMPORTED_MODULE_3__)if ([
156
+ for(var __WEBPACK_IMPORT_KEY__ in _type_utils__WEBPACK_IMPORTED_MODULE_4__)if ([
135
157
  "removeTrailingSlash",
136
158
  "retriable",
137
159
  "default",
@@ -145,12 +167,12 @@ var __webpack_exports__ = {};
145
167
  "assert",
146
168
  "allSettled"
147
169
  ].indexOf(__WEBPACK_IMPORT_KEY__) < 0) __WEBPACK_REEXPORT_OBJECT__[__WEBPACK_IMPORT_KEY__] = (function(key) {
148
- return _type_utils__WEBPACK_IMPORTED_MODULE_3__[key];
170
+ return _type_utils__WEBPACK_IMPORTED_MODULE_4__[key];
149
171
  }).bind(0, __WEBPACK_IMPORT_KEY__);
150
172
  __webpack_require__.d(__webpack_exports__, __WEBPACK_REEXPORT_OBJECT__);
151
- var _promise_queue__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__("./promise-queue");
173
+ var _promise_queue__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__("./promise-queue");
152
174
  var __WEBPACK_REEXPORT_OBJECT__ = {};
153
- for(var __WEBPACK_IMPORT_KEY__ in _promise_queue__WEBPACK_IMPORTED_MODULE_4__)if ([
175
+ for(var __WEBPACK_IMPORT_KEY__ in _promise_queue__WEBPACK_IMPORTED_MODULE_5__)if ([
154
176
  "removeTrailingSlash",
155
177
  "retriable",
156
178
  "default",
@@ -164,7 +186,7 @@ var __webpack_exports__ = {};
164
186
  "assert",
165
187
  "allSettled"
166
188
  ].indexOf(__WEBPACK_IMPORT_KEY__) < 0) __WEBPACK_REEXPORT_OBJECT__[__WEBPACK_IMPORT_KEY__] = (function(key) {
167
- return _promise_queue__WEBPACK_IMPORTED_MODULE_4__[key];
189
+ return _promise_queue__WEBPACK_IMPORTED_MODULE_5__[key];
168
190
  }).bind(0, __WEBPACK_IMPORT_KEY__);
169
191
  __webpack_require__.d(__webpack_exports__, __WEBPACK_REEXPORT_OBJECT__);
170
192
  const STRING_FORMAT = 'utf8';
@@ -1,3 +1,4 @@
1
+ export * from "./bot-detection.mjs";
1
2
  export * from "./bucketed-rate-limiter.mjs";
2
3
  export * from "./number-utils.mjs";
3
4
  export * from "./string-utils.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/core",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -0,0 +1,114 @@
1
+ // List of blocked user agent strings that identify bots
2
+ // This is shared between browser and node SDKs to ensure consistent bot detection
3
+ export const DEFAULT_BLOCKED_UA_STRS = [
4
+ // Random assortment of bots
5
+ 'amazonbot',
6
+ 'amazonproductbot',
7
+ 'app.hypefactors.com', // Buck, but "buck" is too short to be safe to block (https://app.hypefactors.com/media-monitoring/about.htm)
8
+ 'applebot',
9
+ 'archive.org_bot',
10
+ 'awariobot',
11
+ 'backlinksextendedbot',
12
+ 'baiduspider',
13
+ 'bingbot',
14
+ 'bingpreview',
15
+ 'chrome-lighthouse',
16
+ 'dataforseobot',
17
+ 'deepscan',
18
+ 'duckduckbot',
19
+ 'facebookexternal',
20
+ 'facebookcatalog',
21
+ 'http://yandex.com/bots',
22
+ 'hubspot',
23
+ 'ia_archiver',
24
+ 'leikibot',
25
+ 'linkedinbot',
26
+ 'meta-externalagent',
27
+ 'mj12bot',
28
+ 'msnbot',
29
+ 'nessus',
30
+ 'petalbot',
31
+ 'pinterest',
32
+ 'prerender',
33
+ 'rogerbot',
34
+ 'screaming frog',
35
+ 'sebot-wa',
36
+ 'sitebulb',
37
+ 'slackbot',
38
+ 'slurp',
39
+ 'trendictionbot',
40
+ 'turnitin',
41
+ 'twitterbot',
42
+ 'vercel-screenshot',
43
+ 'vercelbot',
44
+ 'yahoo! slurp',
45
+ 'yandexbot',
46
+ 'zoombot',
47
+
48
+ // Bot-like words, maybe we should block `bot` entirely?
49
+ 'bot.htm',
50
+ 'bot.php',
51
+ '(bot;',
52
+ 'bot/',
53
+ 'crawler',
54
+
55
+ // Ahrefs: https://ahrefs.com/seo/glossary/ahrefsbot
56
+ 'ahrefsbot',
57
+ 'ahrefssiteaudit',
58
+
59
+ // Semrush bots: https://www.semrush.com/bot/
60
+ 'semrushbot',
61
+ 'siteauditbot',
62
+ 'splitsignalbot',
63
+
64
+ // AI Crawlers
65
+ 'gptbot',
66
+ 'oai-searchbot',
67
+ 'chatgpt-user',
68
+ 'perplexitybot',
69
+
70
+ // Uptime-like stuff
71
+ 'better uptime bot',
72
+ 'sentryuptimebot',
73
+ 'uptimerobot',
74
+
75
+ // headless browsers
76
+ 'headlesschrome',
77
+ 'cypress',
78
+ // we don't block electron here, as many customers use posthog-js in electron apps
79
+
80
+ // a whole bunch of goog-specific crawlers
81
+ // https://developers.google.com/search/docs/advanced/crawling/overview-google-crawlers
82
+ 'google-hoteladsverifier',
83
+ 'adsbot-google',
84
+ 'apis-google',
85
+ 'duplexweb-google',
86
+ 'feedfetcher-google',
87
+ 'google favicon',
88
+ 'google web preview',
89
+ 'google-read-aloud',
90
+ 'googlebot',
91
+ 'googleother',
92
+ 'google-cloudvertexbot',
93
+ 'googleweblight',
94
+ 'mediapartners-google',
95
+ 'storebot-google',
96
+ 'google-inspectiontool',
97
+ 'bytespider',
98
+ ]
99
+
100
+ /**
101
+ * Block various web spiders from executing our JS and sending false capturing data
102
+ */
103
+ export const isBlockedUA = function (ua: string | undefined, customBlockedUserAgents: string[] = []): boolean {
104
+ if (!ua) {
105
+ return false
106
+ }
107
+
108
+ const uaLower = ua.toLowerCase()
109
+ return DEFAULT_BLOCKED_UA_STRS.concat(customBlockedUserAgents).some((blockedUA) => {
110
+ const blockedUaLower = blockedUA.toLowerCase()
111
+ // can't use includes because IE 11 :/
112
+ return uaLower.indexOf(blockedUaLower) !== -1
113
+ })
114
+ }
@@ -19,15 +19,272 @@ describe('BucketedRateLimiter', () => {
19
19
  jest.clearAllMocks()
20
20
  })
21
21
 
22
- test('it is not rate limited by default', () => {
23
- const result = rateLimiter.consumeRateLimit('ResizeObserver')
24
- expect(result).toBe(false)
22
+ describe('basic consumption', () => {
23
+ test('it is not rate limited by default', () => {
24
+ const result = rateLimiter.consumeRateLimit('ResizeObserver')
25
+ expect(result).toBe(false)
26
+ })
27
+
28
+ test('returns bucket is rate limited if no mutations are left', () => {
29
+ rateLimiter['_buckets']['ResizeObserver'] = { tokens: 0, lastAccess: Date.now() }
30
+
31
+ const result = rateLimiter.consumeRateLimit('ResizeObserver')
32
+ expect(result).toBe(true)
33
+ })
34
+
35
+ test.each([
36
+ { bucketSize: 1, consumptions: 1 },
37
+ { bucketSize: 5, consumptions: 5 },
38
+ { bucketSize: 10, consumptions: 10 },
39
+ { bucketSize: 50, consumptions: 50 },
40
+ ])('exhausts bucket of size $bucketSize after $consumptions consumptions', ({ bucketSize, consumptions }) => {
41
+ const limiter = new BucketedRateLimiter({
42
+ bucketSize,
43
+ refillRate: 1,
44
+ refillInterval: 1000,
45
+ _logger: {} as unknown as Logger,
46
+ })
47
+
48
+ for (let i = 0; i < consumptions - 1; i++) {
49
+ expect(limiter.consumeRateLimit('test')).toBe(false)
50
+ }
51
+
52
+ expect(limiter.consumeRateLimit('test')).toBe(true)
53
+ // can check the same bucket more than once
54
+ expect(limiter.consumeRateLimit('test')).toBe(true)
55
+ })
56
+ })
57
+
58
+ describe('refill behavior', () => {
59
+ test('refills tokens based on elapsed time', () => {
60
+ const key = 'ResizeObserver'
61
+
62
+ for (let i = 0; i < 9; i++) {
63
+ expect(rateLimiter.consumeRateLimit(key)).toBe(false)
64
+ }
65
+
66
+ expect(rateLimiter.consumeRateLimit(key)).toBe(true)
67
+
68
+ jest.advanceTimersByTime(2000)
69
+
70
+ const result = rateLimiter.consumeRateLimit(key)
71
+ expect(result).toBe(false)
72
+
73
+ const bucket = rateLimiter['_buckets'][key]
74
+ expect(bucket.tokens).toBe(1)
75
+ })
76
+
77
+ test('refills to bucket size maximum', () => {
78
+ const key = 'ResizeObserver'
79
+ rateLimiter.consumeRateLimit(key)
80
+
81
+ jest.advanceTimersByTime(20000)
82
+
83
+ rateLimiter.consumeRateLimit(key)
84
+
85
+ const bucket = rateLimiter['_buckets'][key]
86
+ expect(bucket.tokens).toBe(9)
87
+ })
88
+
89
+ test('partial refill intervals do not refill tokens', () => {
90
+ const key = 'test'
91
+
92
+ for (let i = 0; i < 9; i++) {
93
+ rateLimiter.consumeRateLimit(key)
94
+ }
95
+
96
+ jest.advanceTimersByTime(999)
97
+
98
+ rateLimiter.consumeRateLimit(key)
99
+ expect(rateLimiter['_buckets'][key].tokens).toBe(0)
100
+ })
101
+
102
+ test.each([
103
+ { refillRate: 1, intervals: 1, expected: 9 },
104
+ { refillRate: 2, intervals: 1, expected: 9 },
105
+ { refillRate: 1, intervals: 2, expected: 9 },
106
+ { refillRate: 3, intervals: 1, tokensLeft: 5, expected: 7 },
107
+ { refillRate: 2, intervals: 2, tokensLeft: 5, expected: 8 },
108
+ ])(
109
+ 'with rate $refillRate, $intervals intervals, starting at $tokensLeft tokens, ends at $expected',
110
+ ({ refillRate, intervals, tokensLeft = 9, expected }) => {
111
+ const limiter = new BucketedRateLimiter({
112
+ bucketSize: 10,
113
+ refillRate,
114
+ refillInterval: 1000,
115
+ _logger: {} as unknown as Logger,
116
+ })
117
+
118
+ const consumptions = 10 - tokensLeft
119
+ for (let i = 0; i < consumptions; i++) {
120
+ limiter.consumeRateLimit('test')
121
+ }
122
+
123
+ jest.advanceTimersByTime(intervals * 1000)
124
+
125
+ limiter.consumeRateLimit('test')
126
+ expect(limiter['_buckets']['test'].tokens).toBe(expected)
127
+ }
128
+ )
129
+ })
130
+
131
+ describe('bucket isolation', () => {
132
+ test('different keys maintain separate buckets', () => {
133
+ const key1 = 'bucket1'
134
+ const key2 = 'bucket2'
135
+
136
+ for (let i = 0; i < 9; i++) {
137
+ rateLimiter.consumeRateLimit(key1)
138
+ }
139
+
140
+ expect(rateLimiter.consumeRateLimit(key1)).toBe(true)
141
+ expect(rateLimiter.consumeRateLimit(key2)).toBe(false)
142
+
143
+ expect(rateLimiter['_buckets'][key1].tokens).toBe(0)
144
+ expect(rateLimiter['_buckets'][key2].tokens).toBe(9)
145
+ })
146
+
147
+ test('supports both string and number keys', () => {
148
+ const limiter = new BucketedRateLimiter<string | number>({
149
+ bucketSize: 5,
150
+ refillRate: 1,
151
+ refillInterval: 1000,
152
+ _logger: {} as unknown as Logger,
153
+ })
154
+
155
+ for (let i = 0; i < 4; i++) {
156
+ limiter.consumeRateLimit('string-key')
157
+ limiter.consumeRateLimit(123)
158
+ }
159
+
160
+ expect(limiter['_buckets']['string-key'].tokens).toBe(1)
161
+ expect(limiter['_buckets']['123'].tokens).toBe(1)
162
+ })
163
+ })
164
+
165
+ describe('callback behavior', () => {
166
+ test('invokes callback when bucket reaches zero', () => {
167
+ const callback = jest.fn()
168
+ const limiter = new BucketedRateLimiter({
169
+ bucketSize: 3,
170
+ refillRate: 1,
171
+ refillInterval: 1000,
172
+ _logger: {} as unknown as Logger,
173
+ _onBucketRateLimited: callback,
174
+ })
175
+
176
+ limiter.consumeRateLimit('test')
177
+ limiter.consumeRateLimit('test')
178
+ expect(callback).not.toHaveBeenCalled()
179
+
180
+ limiter.consumeRateLimit('test')
181
+ expect(callback).toHaveBeenCalledWith('test')
182
+ expect(callback).toHaveBeenCalledTimes(1)
183
+ })
184
+
185
+ test('does not invoke callback for subsequent calls when already at zero', () => {
186
+ const callback = jest.fn()
187
+ const limiter = new BucketedRateLimiter({
188
+ bucketSize: 2,
189
+ refillRate: 1,
190
+ refillInterval: 1000,
191
+ _logger: {} as unknown as Logger,
192
+ _onBucketRateLimited: callback,
193
+ })
194
+
195
+ limiter.consumeRateLimit('test')
196
+ limiter.consumeRateLimit('test')
197
+ expect(callback).toHaveBeenCalledTimes(1)
198
+
199
+ limiter.consumeRateLimit('test')
200
+ limiter.consumeRateLimit('test')
201
+ expect(callback).toHaveBeenCalledTimes(1)
202
+ })
203
+
204
+ test('invokes callback again after refill and re-exhaustion', () => {
205
+ const callback = jest.fn()
206
+ const limiter = new BucketedRateLimiter({
207
+ bucketSize: 2,
208
+ refillRate: 1,
209
+ refillInterval: 1000,
210
+ _logger: {} as unknown as Logger,
211
+ _onBucketRateLimited: callback,
212
+ })
213
+
214
+ limiter.consumeRateLimit('test')
215
+ limiter.consumeRateLimit('test')
216
+ expect(callback).toHaveBeenCalledTimes(1)
217
+
218
+ jest.advanceTimersByTime(2000)
219
+
220
+ limiter.consumeRateLimit('test')
221
+ limiter.consumeRateLimit('test')
222
+ expect(callback).toHaveBeenCalledTimes(2)
223
+ })
224
+ })
225
+
226
+ describe('stop method', () => {
227
+ test('clears all buckets', () => {
228
+ rateLimiter.consumeRateLimit('key1')
229
+ rateLimiter.consumeRateLimit('key2')
230
+
231
+ expect(Object.keys(rateLimiter['_buckets']).length).toBe(2)
232
+
233
+ rateLimiter.stop()
234
+
235
+ expect(Object.keys(rateLimiter['_buckets']).length).toBe(0)
236
+ })
237
+
238
+ test('resets state after stop', () => {
239
+ for (let i = 0; i < 9; i++) {
240
+ rateLimiter.consumeRateLimit('test')
241
+ }
242
+
243
+ rateLimiter.stop()
244
+
245
+ expect(rateLimiter.consumeRateLimit('test')).toBe(false)
246
+ expect(rateLimiter['_buckets']['test'].tokens).toBe(9)
247
+ })
25
248
  })
26
249
 
27
- test('returns true if no mutations are left', () => {
28
- rateLimiter['_buckets']['ResizeObserver'] = 0
250
+ describe('timestamp tracking', () => {
251
+ test('preserves fractional intervals', () => {
252
+ const key = 'test'
253
+ const startTime = Date.now()
254
+
255
+ rateLimiter.consumeRateLimit(key)
256
+ expect(rateLimiter['_buckets'][key].lastAccess).toBe(startTime)
257
+ expect(rateLimiter['_buckets'][key].tokens).toBe(9)
258
+
259
+ jest.advanceTimersByTime(500)
260
+
261
+ rateLimiter.consumeRateLimit(key)
262
+ expect(rateLimiter['_buckets'][key].lastAccess).toBe(startTime)
263
+ expect(rateLimiter['_buckets'][key].tokens).toBe(8)
29
264
 
30
- const result = rateLimiter.consumeRateLimit('ResizeObserver')
31
- expect(result).toBe(true)
265
+ jest.advanceTimersByTime(600)
266
+
267
+ rateLimiter.consumeRateLimit(key)
268
+ expect(rateLimiter['_buckets'][key].lastAccess).toBe(startTime + 1000)
269
+ expect(rateLimiter['_buckets'][key].tokens).toBe(8)
270
+ })
271
+
272
+ test('advances lastAccess by complete intervals on refill', () => {
273
+ const key = 'test'
274
+ const startTime = Date.now()
275
+
276
+ for (let i = 0; i < 9; i++) {
277
+ rateLimiter.consumeRateLimit(key)
278
+ }
279
+
280
+ expect(rateLimiter['_buckets'][key].lastAccess).toBe(startTime)
281
+
282
+ jest.advanceTimersByTime(2500)
283
+
284
+ rateLimiter.consumeRateLimit(key)
285
+
286
+ expect(rateLimiter['_buckets'][key].lastAccess).toBe(startTime + 2000)
287
+ expect(rateLimiter['_buckets'][key].tokens).toBe(2)
288
+ })
32
289
  })
33
290
  })
@@ -1,85 +1,67 @@
1
1
  import { Logger } from '../types'
2
2
  import { clampToRange } from './number-utils'
3
3
 
4
+ type Bucket = { tokens: number; lastAccess: number }
5
+ const ONE_DAY_IN_MS = 86400000
6
+
4
7
  export class BucketedRateLimiter<T extends string | number> {
5
- private _bucketSize
6
- private _refillRate
7
- private _refillInterval
8
+ private _bucketSize: number
9
+ private _refillRate: number
10
+ private _refillInterval: number
8
11
  private _onBucketRateLimited?: (key: T) => void
12
+ private _buckets: Record<string, Bucket> = {}
9
13
 
10
- private _buckets: Record<string, number> = {}
11
- private _removeInterval: NodeJS.Timeout | undefined
12
-
13
- constructor(
14
- private readonly _options: {
15
- bucketSize: number
16
- refillRate: number
17
- refillInterval: number
18
- _logger: Logger
19
- _onBucketRateLimited?: (key: T) => void
20
- }
21
- ) {
22
- this._onBucketRateLimited = this._options._onBucketRateLimited
23
- this._bucketSize = clampToRange(this._options.bucketSize, 0, 100, this._options._logger)
24
- this._refillRate = clampToRange(
25
- this._options.refillRate,
26
- 0,
27
- this._bucketSize, // never refill more than bucket size
28
- this._options._logger
29
- )
30
- this._refillInterval = clampToRange(
31
- this._options.refillInterval,
32
- 0,
33
- 86400000, // one day in milliseconds
34
- this._options._logger
35
- )
36
- this._removeInterval = setInterval(() => {
37
- this._refillBuckets()
38
- }, this._refillInterval)
14
+ constructor(options: {
15
+ bucketSize: number
16
+ refillRate: number
17
+ refillInterval: number
18
+ _logger: Logger
19
+ _onBucketRateLimited?: (key: T) => void
20
+ }) {
21
+ this._onBucketRateLimited = options._onBucketRateLimited
22
+ this._bucketSize = clampToRange(options.bucketSize, 0, 100, options._logger)
23
+ this._refillRate = clampToRange(options.refillRate, 0, this._bucketSize, options._logger)
24
+ this._refillInterval = clampToRange(options.refillInterval, 0, ONE_DAY_IN_MS, options._logger)
39
25
  }
40
26
 
41
- private _refillBuckets = () => {
42
- Object.keys(this._buckets).forEach((key) => {
43
- const newTokens = this._getBucket(key) + this._refillRate
27
+ private _applyRefill(bucket: Bucket, now: number): void {
28
+ const elapsedMs = now - bucket.lastAccess
29
+ const refillIntervals = Math.floor(elapsedMs / this._refillInterval)
44
30
 
45
- if (newTokens >= this._bucketSize) {
46
- delete this._buckets[key]
47
- } else {
48
- this._setBucket(key, newTokens)
49
- }
50
- })
31
+ if (refillIntervals > 0) {
32
+ const tokensToAdd = refillIntervals * this._refillRate
33
+ bucket.tokens = Math.min(bucket.tokens + tokensToAdd, this._bucketSize)
34
+ bucket.lastAccess = bucket.lastAccess + refillIntervals * this._refillInterval
35
+ }
51
36
  }
52
37
 
53
- private _getBucket = (key: T | string) => {
54
- return this._buckets[String(key)]
55
- }
56
- private _setBucket = (key: T | string, value: number) => {
57
- this._buckets[String(key)] = value
58
- }
38
+ public consumeRateLimit(key: T): boolean {
39
+ const now = Date.now()
40
+ const keyStr = String(key)
59
41
 
60
- public consumeRateLimit = (key: T) => {
61
- let tokens = this._getBucket(key) ?? this._bucketSize
62
- tokens = Math.max(tokens - 1, 0)
42
+ let bucket = this._buckets[keyStr]
63
43
 
64
- if (tokens === 0) {
65
- return true
44
+ if (!bucket) {
45
+ bucket = { tokens: this._bucketSize, lastAccess: now }
46
+ this._buckets[keyStr] = bucket
47
+ } else {
48
+ this._applyRefill(bucket, now)
66
49
  }
67
50
 
68
- this._setBucket(key, tokens)
51
+ if (bucket.tokens === 0) {
52
+ return true
53
+ }
69
54
 
70
- const hasReachedZero = tokens === 0
55
+ bucket.tokens--
71
56
 
72
- if (hasReachedZero) {
57
+ if (bucket.tokens === 0) {
73
58
  this._onBucketRateLimited?.(key)
74
59
  }
75
60
 
76
- return hasReachedZero
61
+ return bucket.tokens === 0
77
62
  }
78
63
 
79
- public stop() {
80
- if (this._removeInterval) {
81
- clearInterval(this._removeInterval)
82
- this._removeInterval = undefined
83
- }
64
+ public stop(): void {
65
+ this._buckets = {}
84
66
  }
85
67
  }
@@ -1,5 +1,6 @@
1
1
  import { FetchLike } from '../types'
2
2
 
3
+ export * from './bot-detection'
3
4
  export * from './bucketed-rate-limiter'
4
5
  export * from './number-utils'
5
6
  export * from './string-utils'