@riebeckite/analytics-cloudflare 0.0.5 → 0.0.7
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 +96 -68
- package/README_ja.md +27 -16
- package/dist/index.d.ts +5 -0
- package/dist/index.js +84 -2
- package/dist/src/domain/analytics/rate_limit.d.ts +25 -0
- package/dist/src/infrastructure/d1/d1_analytics_rate_limiter.d.ts +22 -0
- package/dist/src/infrastructure/memory/memory_rate_limiter.d.ts +18 -0
- package/dist/src/presentation/worker/create_worker.d.ts +7 -0
- package/migrations/0002_analytics_rate_limits.sql +10 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -1,68 +1,96 @@
|
|
|
1
|
-
# @riebeckite/analytics-cloudflare
|
|
2
|
-
|
|
3
|
-
Independent Cloudflare Worker runtime for `@riebeckite/plugin-analytics`. The
|
|
4
|
-
generic plugin contains only browser configuration and analytics contracts; this
|
|
5
|
-
package owns Worker bindings and storage.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
1
|
+
# @riebeckite/analytics-cloudflare
|
|
2
|
+
|
|
3
|
+
Independent Cloudflare Worker runtime for `@riebeckite/plugin-analytics`. The
|
|
4
|
+
generic plugin contains only browser configuration and analytics contracts; this
|
|
5
|
+
package owns Worker bindings and storage.
|
|
6
|
+
|
|
7
|
+
[日本語](./README_ja.md)
|
|
8
|
+
|
|
9
|
+
## Architecture
|
|
10
|
+
|
|
11
|
+
`createWorker` is the presentation boundary for a dedicated Worker runtime. It
|
|
12
|
+
validates HTTP input, calls the generic `AnalyticsProvider` contract through the
|
|
13
|
+
runtime-owned `AnalyticsStorage` boundary, and maps unsupported reads to HTTP
|
|
14
|
+
errors. D1 and KV live in infrastructure and are never imported by
|
|
15
|
+
`@riebeckite/plugin-analytics`. The site's only public setting remains the
|
|
16
|
+
generic plugin's `collectorUrl`; bindings and storage selection stay in this
|
|
17
|
+
Worker.
|
|
18
|
+
|
|
19
|
+
## Choose exactly one storage adapter
|
|
20
|
+
|
|
21
|
+
- **`D1AnalyticsStorage`**: atomic SQLite upserts into UTC daily aggregate
|
|
22
|
+
buckets. It supports capture, content page-view totals, popular-content
|
|
23
|
+
ranking, and date-bucket time ranges. It stores no raw event log. A range is
|
|
24
|
+
bucket-based: bounds select entire UTC days, not exact sub-day event windows.
|
|
25
|
+
- **`KvAnalyticsStorage`**: minimal best-effort total capture only. KV lacks
|
|
26
|
+
atomic increment and aggregate listing, so concurrent writes can be lost and
|
|
27
|
+
`content_page_views` / `popular_content` deliberately throw
|
|
28
|
+
`UnsupportedAnalyticsQueryError` (the Worker returns `501`). Do not use it
|
|
29
|
+
for reporting.
|
|
30
|
+
|
|
31
|
+
There is no default adapter. Bind and construct exactly one in the Worker.
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { createWorker, D1AnalyticsStorage } from "@riebeckite/analytics-cloudflare";
|
|
35
|
+
|
|
36
|
+
export interface Env { ANALYTICS_DB: D1Database; }
|
|
37
|
+
export default { fetch: (request: Request, env: Env) =>
|
|
38
|
+
createWorker({ storage: new D1AnalyticsStorage(env.ANALYTICS_DB), cors: { allowedOrigins: ["https://www.example.com"] } }).fetch(request),
|
|
39
|
+
};
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The Worker binding is constructed per request because `env` exists only in
|
|
43
|
+
`fetch`.
|
|
44
|
+
|
|
45
|
+
## Rate limiting is a mitigation, not authentication
|
|
46
|
+
|
|
47
|
+
Origin checking is not authentication: any client can forge an allowed `Origin`
|
|
48
|
+
and POST fabricated `page_view` events. The optional `rateLimit` boundary adds a
|
|
49
|
+
fixed-window limit per connecting IP and returns HTTP `429` once exceeded.
|
|
50
|
+
|
|
51
|
+
- **`D1AnalyticsRateLimiter` / `d1RateLimiter(db, { maxRequests, windowMs })`**:
|
|
52
|
+
an atomic, isolate-shared D1 counter. Apply
|
|
53
|
+
`migrations/0002_analytics_rate_limits.sql` before deploying.
|
|
54
|
+
- **`MemoryAnalyticsRateLimiter`**: process-local counters for tests and local
|
|
55
|
+
development. Worker isolates are short-lived and not shared, so this does not
|
|
56
|
+
limit a distributed client across isolates. Use D1 for production.
|
|
57
|
+
|
|
58
|
+
Rate limiting only reduces abuse from a single client; it never fully prevents
|
|
59
|
+
fabricated or distributed page views. Treat collected analytics as untrusted.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
rateLimit: new D1AnalyticsRateLimiter(env.ANALYTICS_DB, {
|
|
63
|
+
maxRequests: 60,
|
|
64
|
+
windowMs: 60_000,
|
|
65
|
+
}),
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Endpoints and validation
|
|
69
|
+
|
|
70
|
+
- `POST /events` accepts only JSON `page_view` payloads with an ISO timestamp,
|
|
71
|
+
a 1–160 character trimmed content ID without control characters, optional
|
|
72
|
+
bounded path and language, and no unknown fields. Bodies are limited to 8 KiB.
|
|
73
|
+
When a rate limiter is configured, a client over its window limit gets `429`.
|
|
74
|
+
- `GET /content/:contentId/page-views?from=&to=` and `GET /popular?limit=&from=&to=`
|
|
75
|
+
are available only when storage advertises the corresponding capability.
|
|
76
|
+
|
|
77
|
+
Origin access is deny-by-default. Set explicit `allowedOrigins`; use `"any"`
|
|
78
|
+
only for a consciously public collector. `path` and `lang` are contextual
|
|
79
|
+
metadata and D1 does not persist them. When a rate limiter is configured, the
|
|
80
|
+
connecting IP (`CF-Connecting-IP`) is read as the rate-limit key; the D1
|
|
81
|
+
limiter stores that key only for the active window.
|
|
82
|
+
|
|
83
|
+
## D1 schema and deployment
|
|
84
|
+
|
|
85
|
+
Workers must not migrate D1 during a request. Apply the included schema before
|
|
86
|
+
deploying:
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
pnpm exec wrangler d1 execute ANALYTICS_DB --file node_modules/@riebeckite/analytics-cloudflare/migrations/0001_analytics_page_views.sql
|
|
90
|
+
pnpm exec wrangler d1 execute ANALYTICS_DB --file node_modules/@riebeckite/analytics-cloudflare/migrations/0002_analytics_rate_limits.sql
|
|
91
|
+
pnpm exec wrangler deploy
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Copy either `templates/analytics-cloudflare/d1` or `kv` into a separate Worker
|
|
95
|
+
repository/directory. It has its own `main` and must not replace a static
|
|
96
|
+
Riebeckite site's `wrangler.jsonc` or main entry.
|
package/README_ja.md
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
|
-
# @riebeckite/analytics-cloudflare
|
|
2
|
-
|
|
3
|
-
`@riebeckite/plugin-analytics` のための独立した Cloudflare Worker 実行環境です。汎用プラグインには Cloudflare 固有コードを含めません。
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
1
|
+
# @riebeckite/analytics-cloudflare
|
|
2
|
+
|
|
3
|
+
`@riebeckite/plugin-analytics` のための独立した Cloudflare Worker 実行環境です。汎用プラグインには Cloudflare 固有コードを含めません。
|
|
4
|
+
|
|
5
|
+
[English](./README.md)
|
|
6
|
+
|
|
7
|
+
## ストレージは必ず一つ選ぶ
|
|
8
|
+
|
|
9
|
+
- **D1**: UTC 日単位で集計し、原子的な upsert、コンテンツ別 PV、人気記事、日バケット単位の期間指定に対応します。生イベントは保存しません。時刻境界は日単位であり、サブ日精度ではありません。
|
|
10
|
+
- **KV**: 最小のベストエフォート集計のみです。原子的な加算や集計検索がないため、同時書き込みで欠損し得ます。読み取り API は対応能力として宣言せず、`UnsupportedAnalyticsQueryError` / HTTP 501 を返します。レポーティング用途には D1 を使ってください。
|
|
11
|
+
|
|
12
|
+
既定アダプターはありません。テンプレートの D1 または KV を一つだけ選び、静的サイト用の `wrangler.jsonc` や main は変更しません。
|
|
13
|
+
|
|
14
|
+
## レート制限は緩和策であり、認証ではない
|
|
15
|
+
|
|
16
|
+
Origin は認証ではないため、許可された `Origin` を偽装して `page_view` を直接 POST できます。任意の `rateLimit` 境界を渡すと、接続元 IP ごとの固定時間窓で回数を制限し、超過時に HTTP 429 を返します。
|
|
17
|
+
|
|
18
|
+
- **`D1AnalyticsRateLimiter` / `d1RateLimiter(db, { maxRequests, windowMs })`**: D1 に原子的なカウンターを持ち、isolate 間で共有します。デプロイ前に `migrations/0002_analytics_rate_limits.sql` を適用してください。
|
|
19
|
+
- **`MemoryAnalyticsRateLimiter`**: テストとローカル開発向けのプロセス内カウンターです。Worker の isolate は短命で共有されないため、分散したクライアントは制限できません。本番は D1 を使ってください。
|
|
20
|
+
|
|
21
|
+
レート制限は単一クライアントからの濫用を減らすだけで、偽造や分散アクセスを完全には防げません。収集した計測は信頼できないデータとして扱ってください。
|
|
22
|
+
|
|
23
|
+
## プライバシーと検証
|
|
24
|
+
|
|
25
|
+
`POST /events` は 8 KiB 以下の JSON `page_view` だけを受け入れ、安全な content ID、ISO 時刻、長さを制限した `path` / `lang` を検証します。Cookie、User-Agent、フィンガープリントは読み取りも保存もしません。Origin は既定で拒否されるため、許可するサイト Origin を明示してください。レート制限を設定した場合に限り、接続元 IP(`CF-Connecting-IP`)をレート制限キーとして読み取ります。D1 版はそのキーを有効な時間窓のあいだだけ保存します。
|
|
26
|
+
|
|
27
|
+
D1 スキーマはリクエスト中に実行せず、デプロイ前に `migrations/0001_analytics_page_views.sql` と `migrations/0002_analytics_rate_limits.sql` を `wrangler d1 execute` で明示適用してください。詳しい設定は各テンプレートの README を参照してください。
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
export type { AnalyticsRateLimiter } from "./src/domain/analytics/rate_limit.js";
|
|
1
2
|
export type { AnalyticsStorage } from "./src/domain/analytics/storage.js";
|
|
3
|
+
export type { D1AnalyticsRateLimiterOptions } from "./src/infrastructure/d1/d1_analytics_rate_limiter.js";
|
|
4
|
+
export { D1AnalyticsRateLimiter, d1RateLimiter, } from "./src/infrastructure/d1/d1_analytics_rate_limiter.js";
|
|
2
5
|
export { D1AnalyticsStorage, d1Storage, } from "./src/infrastructure/d1/d1_analytics_storage.js";
|
|
3
6
|
export type { D1Database } from "./src/infrastructure/d1/d1_types.js";
|
|
4
7
|
export type { KvNamespace } from "./src/infrastructure/kv/kv_analytics_storage.js";
|
|
5
8
|
export { KvAnalyticsStorage, kvStorage, } from "./src/infrastructure/kv/kv_analytics_storage.js";
|
|
9
|
+
export type { MemoryAnalyticsRateLimiterOptions } from "./src/infrastructure/memory/memory_rate_limiter.js";
|
|
10
|
+
export { MemoryAnalyticsRateLimiter } from "./src/infrastructure/memory/memory_rate_limiter.js";
|
|
6
11
|
export type { AnalyticsCorsOptions, AnalyticsWorker, AnalyticsWorkerOptions, } from "./src/presentation/worker/create_worker.js";
|
|
7
12
|
export { createWorker } from "./src/presentation/worker/create_worker.js";
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,50 @@
|
|
|
1
|
+
// src/domain/analytics/rate_limit.ts
|
|
2
|
+
function assertRateLimitPolicy(policy) {
|
|
3
|
+
if (!Number.isInteger(policy.maxRequests) || policy.maxRequests < 1)
|
|
4
|
+
throw new RangeError("rate limit maxRequests must be a positive integer");
|
|
5
|
+
if (!Number.isInteger(policy.windowMs) || policy.windowMs < 1)
|
|
6
|
+
throw new RangeError("rate limit windowMs must be a positive integer");
|
|
7
|
+
}
|
|
8
|
+
function rateLimitWindowStart(now, windowMs) {
|
|
9
|
+
return Math.floor(now / windowMs) * windowMs;
|
|
10
|
+
}
|
|
11
|
+
function rateLimitOutcome(hits, maxRequests) {
|
|
12
|
+
return hits <= maxRequests ? "allowed" : "limited";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// src/infrastructure/d1/d1_analytics_rate_limiter.ts
|
|
16
|
+
var D1AnalyticsRateLimiter = class {
|
|
17
|
+
constructor(database, options) {
|
|
18
|
+
this.database = database;
|
|
19
|
+
this.options = options;
|
|
20
|
+
assertRateLimitPolicy(options);
|
|
21
|
+
this.#now = options.now ?? Date.now;
|
|
22
|
+
}
|
|
23
|
+
database;
|
|
24
|
+
options;
|
|
25
|
+
#now;
|
|
26
|
+
async check(key) {
|
|
27
|
+
const windowStart = rateLimitWindowStart(
|
|
28
|
+
this.#now(),
|
|
29
|
+
this.options.windowMs
|
|
30
|
+
);
|
|
31
|
+
const result = await this.database.prepare(
|
|
32
|
+
`INSERT INTO analytics_rate_limits (bucket_key, window_start, hits)
|
|
33
|
+
VALUES (?, ?, 1)
|
|
34
|
+
ON CONFLICT(bucket_key, window_start)
|
|
35
|
+
DO UPDATE SET hits = hits + 1
|
|
36
|
+
RETURNING hits`
|
|
37
|
+
).bind(key, windowStart).all();
|
|
38
|
+
return rateLimitOutcome(
|
|
39
|
+
Number(result.results?.[0]?.hits ?? 0),
|
|
40
|
+
this.options.maxRequests
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
function d1RateLimiter(database, options) {
|
|
45
|
+
return new D1AnalyticsRateLimiter(database, options);
|
|
46
|
+
}
|
|
47
|
+
|
|
1
48
|
// src/infrastructure/d1/d1_analytics_storage.ts
|
|
2
49
|
import { assertAnalyticsQuerySupported } from "@riebeckite/plugin-analytics";
|
|
3
50
|
var D1AnalyticsStorage = class {
|
|
@@ -105,6 +152,25 @@ function isPageView2(event) {
|
|
|
105
152
|
return event.type === "page_view";
|
|
106
153
|
}
|
|
107
154
|
|
|
155
|
+
// src/infrastructure/memory/memory_rate_limiter.ts
|
|
156
|
+
var MemoryAnalyticsRateLimiter = class {
|
|
157
|
+
constructor(options) {
|
|
158
|
+
this.options = options;
|
|
159
|
+
assertRateLimitPolicy(options);
|
|
160
|
+
this.#now = options.now ?? Date.now;
|
|
161
|
+
}
|
|
162
|
+
options;
|
|
163
|
+
#now;
|
|
164
|
+
#windows = /* @__PURE__ */ new Map();
|
|
165
|
+
async check(key) {
|
|
166
|
+
const start = rateLimitWindowStart(this.#now(), this.options.windowMs);
|
|
167
|
+
const current = this.#windows.get(key);
|
|
168
|
+
const hits = current?.start === start ? current.hits + 1 : 1;
|
|
169
|
+
this.#windows.set(key, { start, hits });
|
|
170
|
+
return rateLimitOutcome(hits, this.options.maxRequests);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
108
174
|
// src/presentation/worker/create_worker.ts
|
|
109
175
|
var MAX_BODY_BYTES = 8192;
|
|
110
176
|
var MAX_CONTENT_ID_LENGTH = 160;
|
|
@@ -121,7 +187,12 @@ function createWorker(options) {
|
|
|
121
187
|
}
|
|
122
188
|
const url = new URL(request.url);
|
|
123
189
|
if (request.method === "POST" && url.pathname === "/events") {
|
|
124
|
-
return captureEvent(
|
|
190
|
+
return captureEvent(
|
|
191
|
+
request,
|
|
192
|
+
options.storage,
|
|
193
|
+
options.rateLimit,
|
|
194
|
+
corsHeaders
|
|
195
|
+
);
|
|
125
196
|
}
|
|
126
197
|
if (request.method === "GET" && url.pathname === "/popular") {
|
|
127
198
|
return queryPopular(url, options.storage, corsHeaders);
|
|
@@ -136,7 +207,7 @@ function createWorker(options) {
|
|
|
136
207
|
}
|
|
137
208
|
};
|
|
138
209
|
}
|
|
139
|
-
async function captureEvent(request, storage, headers) {
|
|
210
|
+
async function captureEvent(request, storage, rateLimit, headers) {
|
|
140
211
|
if (!request.headers.get("content-type")?.includes("application/json")) {
|
|
141
212
|
return json({ error: "unsupported_media_type" }, 415, headers);
|
|
142
213
|
}
|
|
@@ -156,9 +227,17 @@ async function captureEvent(request, storage, headers) {
|
|
|
156
227
|
}
|
|
157
228
|
const event = validateEvent(value);
|
|
158
229
|
if (!event) return json({ error: "invalid_event" }, 400, headers);
|
|
230
|
+
if (rateLimit) {
|
|
231
|
+
const outcome = await rateLimit.check(clientIp(request));
|
|
232
|
+
if (outcome === "limited")
|
|
233
|
+
return json({ error: "rate_limited" }, 429, headers);
|
|
234
|
+
}
|
|
159
235
|
await storage.capture(event);
|
|
160
236
|
return new Response(null, { status: 204, headers });
|
|
161
237
|
}
|
|
238
|
+
function clientIp(request) {
|
|
239
|
+
return request.headers.get("cf-connecting-ip") ?? "unknown";
|
|
240
|
+
}
|
|
162
241
|
async function queryContent(contentId, url, storage, headers) {
|
|
163
242
|
if (!isContentId(contentId))
|
|
164
243
|
return json({ error: "invalid_content_id" }, 400, headers);
|
|
@@ -281,9 +360,12 @@ function isIsoDate(value) {
|
|
|
281
360
|
return typeof value === "string" && value.length <= 40 && !Number.isNaN(Date.parse(value));
|
|
282
361
|
}
|
|
283
362
|
export {
|
|
363
|
+
D1AnalyticsRateLimiter,
|
|
284
364
|
D1AnalyticsStorage,
|
|
285
365
|
KvAnalyticsStorage,
|
|
366
|
+
MemoryAnalyticsRateLimiter,
|
|
286
367
|
createWorker,
|
|
368
|
+
d1RateLimiter,
|
|
287
369
|
d1Storage,
|
|
288
370
|
kvStorage
|
|
289
371
|
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-owned rate-limit boundary for the analytics collector.
|
|
3
|
+
*
|
|
4
|
+
* Origin checks are not authentication, so a client can forge an allowed
|
|
5
|
+
* `Origin` and POST fabricated events. Rate limiting reduces that abuse from a
|
|
6
|
+
* single client key; it is a mitigation, not complete prevention, and never
|
|
7
|
+
* makes collected analytics authorization data.
|
|
8
|
+
*/
|
|
9
|
+
export type AnalyticsRateLimitOutcome = "allowed" | "limited";
|
|
10
|
+
export interface AnalyticsRateLimiter {
|
|
11
|
+
/** Records one attempt for `key` and reports whether it may proceed. */
|
|
12
|
+
check(key: string): Promise<AnalyticsRateLimitOutcome>;
|
|
13
|
+
}
|
|
14
|
+
export interface AnalyticsRateLimitPolicy {
|
|
15
|
+
/** Maximum accepted attempts per key inside one window. */
|
|
16
|
+
readonly maxRequests: number;
|
|
17
|
+
/** Window length in milliseconds. */
|
|
18
|
+
readonly windowMs: number;
|
|
19
|
+
}
|
|
20
|
+
/** Rejects policies that would silently disable limiting (zero or non-integer). */
|
|
21
|
+
export declare function assertRateLimitPolicy(policy: AnalyticsRateLimitPolicy): void;
|
|
22
|
+
/** Fixed-window bucket start for `now` in epoch milliseconds. */
|
|
23
|
+
export declare function rateLimitWindowStart(now: number, windowMs: number): number;
|
|
24
|
+
/** Maps the hit count inside the current window to an outcome. */
|
|
25
|
+
export declare function rateLimitOutcome(hits: number, maxRequests: number): AnalyticsRateLimitOutcome;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type AnalyticsRateLimiter, type AnalyticsRateLimitOutcome, type AnalyticsRateLimitPolicy } from "../../domain/analytics/rate_limit.js";
|
|
2
|
+
import type { D1Database } from "./d1_types.js";
|
|
3
|
+
export type D1AnalyticsRateLimiterOptions = AnalyticsRateLimitPolicy & Readonly<{
|
|
4
|
+
/** Clock injection for deterministic tests. Defaults to `Date.now`. */
|
|
5
|
+
now?: () => number;
|
|
6
|
+
}>;
|
|
7
|
+
/**
|
|
8
|
+
* D1-backed fixed-window limiter shared across Worker isolates.
|
|
9
|
+
*
|
|
10
|
+
* The connecting IP is stored as the bucket key for the active window only.
|
|
11
|
+
* Expired rows are never read again, but D1 has no TTL; prune old rows
|
|
12
|
+
* periodically (for example from a scheduled Worker) if retention matters.
|
|
13
|
+
*/
|
|
14
|
+
export declare class D1AnalyticsRateLimiter implements AnalyticsRateLimiter {
|
|
15
|
+
#private;
|
|
16
|
+
private readonly database;
|
|
17
|
+
private readonly options;
|
|
18
|
+
constructor(database: D1Database, options: D1AnalyticsRateLimiterOptions);
|
|
19
|
+
check(key: string): Promise<AnalyticsRateLimitOutcome>;
|
|
20
|
+
}
|
|
21
|
+
/** Creates a D1-backed analytics rate limiter from a Worker binding. */
|
|
22
|
+
export declare function d1RateLimiter(database: D1Database, options: D1AnalyticsRateLimiterOptions): AnalyticsRateLimiter;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type AnalyticsRateLimiter, type AnalyticsRateLimitOutcome, type AnalyticsRateLimitPolicy } from "../../domain/analytics/rate_limit.js";
|
|
2
|
+
export type MemoryAnalyticsRateLimiterOptions = AnalyticsRateLimitPolicy & Readonly<{
|
|
3
|
+
/** Clock injection for deterministic tests. Defaults to `Date.now`. */
|
|
4
|
+
now?: () => number;
|
|
5
|
+
}>;
|
|
6
|
+
/**
|
|
7
|
+
* Process-local fixed-window limiter for tests and local development.
|
|
8
|
+
*
|
|
9
|
+
* In Cloudflare Workers this state lives in one isolate only: it is lost on cold
|
|
10
|
+
* starts and is not shared across isolates or locations, so it is a best-effort
|
|
11
|
+
* mitigation. Use `D1AnalyticsRateLimiter` for a shared, durable counter.
|
|
12
|
+
*/
|
|
13
|
+
export declare class MemoryAnalyticsRateLimiter implements AnalyticsRateLimiter {
|
|
14
|
+
#private;
|
|
15
|
+
private readonly options;
|
|
16
|
+
constructor(options: MemoryAnalyticsRateLimiterOptions);
|
|
17
|
+
check(key: string): Promise<AnalyticsRateLimitOutcome>;
|
|
18
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AnalyticsRateLimiter } from "../../domain/analytics/rate_limit.js";
|
|
1
2
|
import type { AnalyticsStorage } from "../../domain/analytics/storage.js";
|
|
2
3
|
export type AnalyticsCorsOptions = Readonly<{
|
|
3
4
|
/** Explicit browser origins, or "any" for a deliberately public collector. */
|
|
@@ -9,6 +10,12 @@ export type AnalyticsWorkerOptions = Readonly<{
|
|
|
9
10
|
/** One explicitly selected runtime storage adapter. No default is provided. */
|
|
10
11
|
storage: AnalyticsStorage;
|
|
11
12
|
cors?: AnalyticsCorsOptions;
|
|
13
|
+
/**
|
|
14
|
+
* Optional per-client-IP limiter applied to `POST /events`. Rate limiting is
|
|
15
|
+
* a mitigation for event forgery, not authentication; see the domain
|
|
16
|
+
* `AnalyticsRateLimiter` contract.
|
|
17
|
+
*/
|
|
18
|
+
rateLimit?: AnalyticsRateLimiter;
|
|
12
19
|
}>;
|
|
13
20
|
export type AnalyticsWorker = Readonly<{
|
|
14
21
|
fetch(request: Request): Promise<Response>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- Apply explicitly with: wrangler d1 execute <DATABASE_NAME> --file migrations/0002_analytics_rate_limits.sql
|
|
2
|
+
-- D1 Workers cannot safely run schema migration during request handling.
|
|
3
|
+
-- The connecting IP is stored as bucket_key for the active window only.
|
|
4
|
+
-- D1 has no TTL; prune expired rows periodically if retention matters.
|
|
5
|
+
CREATE TABLE IF NOT EXISTS analytics_rate_limits (
|
|
6
|
+
bucket_key TEXT NOT NULL,
|
|
7
|
+
window_start INTEGER NOT NULL,
|
|
8
|
+
hits INTEGER NOT NULL DEFAULT 0 CHECK (hits >= 0),
|
|
9
|
+
PRIMARY KEY (bucket_key, window_start)
|
|
10
|
+
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@riebeckite/analytics-cloudflare",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
}
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@riebeckite/plugin-analytics": "0.0.
|
|
17
|
+
"@riebeckite/plugin-analytics": "0.0.7"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"tsx": "^4.22.4"
|
|
18
21
|
},
|
|
19
22
|
"publishConfig": {
|
|
20
23
|
"access": "public"
|